refactor(channels): dedupe message routing and telegram helpers

This commit is contained in:
Peter Steinberger
2026-02-22 07:37:54 +00:00
parent b109fa53ea
commit 75c1bfbae8
21 changed files with 566 additions and 410 deletions

View File

@@ -3,6 +3,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
function mockGetChatMemberStatus(status: string) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
}
async function auditSingleGroup() {
return auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
}
describe("telegram audit", () => {
beforeAll(async () => {
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
@@ -27,42 +48,16 @@ describe("telegram audit", () => {
});
it("audits membership via getChatMember", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status: "member" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
mockGetChatMemberStatus("member");
const res = await auditSingleGroup();
expect(res.ok).toBe(true);
expect(res.groups[0]?.chatId).toBe("-1001");
expect(res.groups[0]?.status).toBe("member");
});
it("reports bot not in group when status is left", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status: "left" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const res = await auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
mockGetChatMemberStatus("left");
const res = await auditSingleGroup();
expect(res.ok).toBe(false);
expect(res.groups[0]?.ok).toBe(false);
expect(res.groups[0]?.status).toBe("left");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContext } from "./bot-message-context.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const transcribeFirstAudioMock = vi.fn();
@@ -11,39 +11,22 @@ describe("buildTelegramMessageContext audio transcript body", () => {
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
const ctx = await buildTelegramMessageContext({
primaryCtx: {
message: {
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
from: { id: 42, first_name: "Alice" },
voice: { file_id: "voice-1" },
},
me: { id: 7, username: "bot" },
} as never,
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: undefined,
from: { id: 42, first_name: "Alice" },
voice: { file_id: "voice-1" },
},
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
storeAllowFrom: [],
options: { forceWasMentioned: true },
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
} as never,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({

View File

@@ -1,50 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContext } from "./bot-message-context.js";
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
describe("buildTelegramMessageContext sender prefix", () => {
async function buildCtx(params: {
messageId: number;
options?: Record<string, unknown>;
}): Promise<Awaited<ReturnType<typeof buildTelegramMessageContext>>> {
return await buildTelegramMessageContext({
primaryCtx: {
message: {
message_id: params.messageId,
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: () => undefined,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
return await buildTelegramMessageContextForTest({
message: {
message_id: params.messageId,
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: params.options,
});
}

View File

@@ -9,8 +9,15 @@ export const baseTelegramMessageContextConfig = {
type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
allMedia?: Array<Record<string, unknown>>;
options?: Record<string, unknown>;
cfg?: Record<string, unknown>;
resolveGroupActivation?: () => boolean | undefined;
resolveGroupRequireMention?: () => boolean;
resolveTelegramGroupConfig?: () => {
groupConfig?: { requireMention?: boolean };
topicConfig?: unknown;
};
};
export async function buildTelegramMessageContextForTest(
@@ -27,7 +34,7 @@ export async function buildTelegramMessageContextForTest(
},
me: { id: 7, username: "bot" },
} as never,
allMedia: [],
allMedia: params.allMedia ?? [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {
@@ -36,7 +43,7 @@ export async function buildTelegramMessageContextForTest(
setMessageReaction: vi.fn(),
},
} as never,
cfg: baseTelegramMessageContextConfig,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
account: { accountId: "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
@@ -46,10 +53,12 @@ export async function buildTelegramMessageContextForTest(
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
}),
resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
(() => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
})),
});
}

View File

@@ -62,6 +62,7 @@ import {
} from "./bot/helpers.js";
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import {
buildTelegramStatusReactionVariants,
resolveTelegramAllowedEmojiReactions,
@@ -675,13 +676,10 @@ export const buildTelegramMessageContext = async ({
});
}
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
groupConfig,
topicConfig,
});
const commandBody = normalizeCommandBody(rawBody, { botUsername });
const inboundHistory =
isGroup && historyKey && historyLimit > 0

View File

@@ -36,6 +36,20 @@ vi.mock("./bot/delivery.js", () => ({
}));
describe("registerTelegramNativeCommands", () => {
type RegisteredCommand = {
command: string;
description: string;
};
async function waitForRegisteredCommands(
setMyCommands: ReturnType<typeof vi.fn>,
): Promise<RegisteredCommand[]> {
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[];
}
beforeEach(() => {
listSkillCommandsForAgents.mockClear();
listSkillCommandsForAgents.mockReturnValue([]);
@@ -166,14 +180,7 @@ describe("registerTelegramNativeCommands", () => {
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
@@ -207,14 +214,7 @@ describe("registerTelegramNativeCommands", () => {
} as TelegramAccountConfig,
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
const registeredCommands = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.length).toBeGreaterThan(0);
for (const entry of registeredCommands) {

View File

@@ -41,7 +41,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import { isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
import {
buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands,
@@ -64,6 +64,7 @@ import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
import { buildInlineKeyboard } from "./send.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
@@ -552,13 +553,10 @@ export const registerTelegramNativeCommands = ({
})
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
const systemPromptParts = [
groupConfig?.systemPrompt?.trim() || null,
topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
groupConfig,
topicConfig,
});
const conversationLabel = isGroup
? msg.chat.title
? `${msg.chat.title} id:${chatId}`

View File

@@ -17,6 +17,11 @@ async function createMessageHandlerAndReplySpy() {
return { handler, replySpy };
}
function expectSingleReplyPayload(replySpy: ReturnType<typeof vi.fn>) {
expect(replySpy).toHaveBeenCalledTimes(1);
return replySpy.mock.calls[0][0] as Record<string, unknown>;
}
describe("telegram inbound media", () => {
const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
it(
@@ -40,8 +45,7 @@ describe("telegram inbound media", () => {
getFile: async () => ({ file_path: "unused" }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
const payload = expectSingleReplyPayload(replySpy);
expect(payload.Body).toContain("Meet here");
expect(payload.Body).toContain("48.858844");
expect(payload.LocationLat).toBe(48.858844);
@@ -72,8 +76,7 @@ describe("telegram inbound media", () => {
getFile: async () => ({ file_path: "unused" }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
const payload = expectSingleReplyPayload(replySpy);
expect(payload.Body).toContain("Eiffel Tower");
expect(payload.LocationName).toBe("Eiffel Tower");
expect(payload.LocationAddress).toBe("Champ de Mars, Paris");

View File

@@ -61,6 +61,16 @@ function mockMediaLoad(fileName: string, contentType: string, data: string) {
});
}
function createSendMessageHarness(messageId = 4) {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: messageId,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
return { runtime, sendMessage, bot };
}
describe("deliverReplies", () => {
beforeEach(() => {
loadWebMedia.mockReset();
@@ -178,12 +188,7 @@ describe("deliverReplies", () => {
});
it("includes message_thread_id for DM topics", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Hello" }],
@@ -202,12 +207,7 @@ describe("deliverReplies", () => {
});
it("does not include link_preview_options when linkPreview is true", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 4,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
const { runtime, sendMessage, bot } = createSendMessageHarness();
await deliverWith({
replies: [{ text: "Check https://example.com" }],

View File

@@ -0,0 +1,19 @@
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import { firstDefined } from "./bot-access.js";
export function resolveTelegramGroupPromptSettings(params: {
groupConfig?: TelegramGroupConfig;
topicConfig?: TelegramTopicConfig;
}): {
skillFilter: string[] | undefined;
groupSystemPrompt: string | undefined;
} {
const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills);
const systemPromptParts = [
params.groupConfig?.systemPrompt?.trim() || null,
params.topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
return { skillFilter, groupSystemPrompt };
}

View File

@@ -2,9 +2,44 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
type ReactionResolution = ReturnType<typeof resolveTelegramReactionLevel>;
describe("resolveTelegramReactionLevel", () => {
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const expectReactionFlags = (
result: ReactionResolution,
expected: {
level: "off" | "ack" | "minimal" | "extensive";
ackEnabled: boolean;
agentReactionsEnabled: boolean;
agentReactionGuidance?: "minimal" | "extensive";
},
) => {
expect(result.level).toBe(expected.level);
expect(result.ackEnabled).toBe(expected.ackEnabled);
expect(result.agentReactionsEnabled).toBe(expected.agentReactionsEnabled);
expect(result.agentReactionGuidance).toBe(expected.agentReactionGuidance);
};
const expectMinimalFlags = (result: ReactionResolution) => {
expectReactionFlags(result, {
level: "minimal",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "minimal",
});
};
const expectExtensiveFlags = (result: ReactionResolution) => {
expectReactionFlags(result, {
level: "extensive",
ackEnabled: false,
agentReactionsEnabled: true,
agentReactionGuidance: "extensive",
});
};
beforeAll(() => {
process.env.TELEGRAM_BOT_TOKEN = "test-token";
});
@@ -23,10 +58,7 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("minimal");
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
expectMinimalFlags(result);
});
it("returns off level with no reactions enabled", () => {
@@ -35,10 +67,11 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("off");
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(false);
expect(result.agentReactionGuidance).toBeUndefined();
expectReactionFlags(result, {
level: "off",
ackEnabled: false,
agentReactionsEnabled: false,
});
});
it("returns ack level with only ackEnabled", () => {
@@ -47,10 +80,11 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("ack");
expect(result.ackEnabled).toBe(true);
expect(result.agentReactionsEnabled).toBe(false);
expect(result.agentReactionGuidance).toBeUndefined();
expectReactionFlags(result, {
level: "ack",
ackEnabled: true,
agentReactionsEnabled: false,
});
});
it("returns minimal level with agent reactions enabled and minimal guidance", () => {
@@ -59,10 +93,7 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("minimal");
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
expectMinimalFlags(result);
});
it("returns extensive level with agent reactions enabled and extensive guidance", () => {
@@ -71,10 +102,7 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg });
expect(result.level).toBe("extensive");
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("extensive");
expectExtensiveFlags(result);
});
it("resolves reaction level from a specific account", () => {
@@ -90,10 +118,7 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
expect(result.level).toBe("extensive");
expect(result.ackEnabled).toBe(false);
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("extensive");
expectExtensiveFlags(result);
});
it("falls back to global level when account has no reactionLevel", () => {
@@ -109,8 +134,6 @@ describe("resolveTelegramReactionLevel", () => {
};
const result = resolveTelegramReactionLevel({ cfg, accountId: "work" });
expect(result.level).toBe("minimal");
expect(result.agentReactionsEnabled).toBe(true);
expect(result.agentReactionGuidance).toBe("minimal");
expectMinimalFlags(result);
});
});