diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 32bed072e05..912d8e77540 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -444,7 +444,29 @@ curl "https://api.telegram.org/bot/getUpdates" - message sends omit `message_thread_id` (Telegram rejects `sendMessage(...thread_id=1)`) - typing actions still include `message_thread_id` - Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`). + Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`, `agentId`). + + **Per-topic agent routing**: Each topic can route to a different agent by setting `agentId` in the topic config. This gives each topic its own isolated workspace, memory, and session. Example: + + ```json5 + { + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { + "1": { agentId: "main" }, // General topic → main agent + "3": { agentId: "zu" }, // Dev topic → zu agent + "5": { agentId: "coder" } // Code review → coder agent + } + } + } + } + } + } + ``` + + Each topic then has its own session key: `agent:main:telegram:group:-1001234567890:topic:3` Template context includes: @@ -752,8 +774,10 @@ Primary reference: - `channels.telegram.groups..systemPrompt`: extra system prompt for the group. - `channels.telegram.groups..enabled`: disable the group when `false`. - `channels.telegram.groups..topics..*`: per-topic overrides (same fields as group). + - `channels.telegram.groups..topics..agentId`: route this topic to a specific agent (overrides group-level and binding routing). - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. + - `channels.telegram.direct..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). - `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). - `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands. diff --git a/src/config/config.telegram-topic-agentid.test.ts b/src/config/config.telegram-topic-agentid.test.ts new file mode 100644 index 00000000000..9df2d05b7ca --- /dev/null +++ b/src/config/config.telegram-topic-agentid.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("telegram topic agentId schema", () => { + it("accepts valid agentId in forum group topic config", () => { + const res = OpenClawSchema.safeParse({ + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { + "42": { + agentId: "main", + }, + }, + }, + }, + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) { + console.error(res.error.format()); + return; + } + expect(res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics?.["42"]?.agentId).toBe( + "main", + ); + }); + + it("accepts valid agentId in DM topic config", () => { + const res = OpenClawSchema.safeParse({ + channels: { + telegram: { + direct: { + "123456789": { + topics: { + "99": { + agentId: "support", + systemPrompt: "You are support", + }, + }, + }, + }, + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) { + console.error(res.error.format()); + return; + } + expect(res.data.channels?.telegram?.direct?.["123456789"]?.topics?.["99"]?.agentId).toBe( + "support", + ); + }); + + it("accepts empty config without agentId (backward compatible)", () => { + const res = OpenClawSchema.safeParse({ + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { + "42": { + systemPrompt: "Be helpful", + }, + }, + }, + }, + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) { + console.error(res.error.format()); + return; + } + expect(res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics?.["42"]).toEqual({ + systemPrompt: "Be helpful", + }); + }); + + it("accepts multiple topics with different agentIds", () => { + const res = OpenClawSchema.safeParse({ + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { + "1": { agentId: "main" }, + "3": { agentId: "zu" }, + "5": { agentId: "q" }, + }, + }, + }, + }, + }, + }); + + expect(res.success).toBe(true); + if (!res.success) { + console.error(res.error.format()); + return; + } + const topics = res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics; + expect(topics?.["1"]?.agentId).toBe("main"); + expect(topics?.["3"]?.agentId).toBe("zu"); + expect(topics?.["5"]?.agentId).toBe("q"); + }); + + it("rejects unknown fields in topic config (strict schema)", () => { + const res = OpenClawSchema.safeParse({ + channels: { + telegram: { + groups: { + "-1001234567890": { + topics: { + "42": { + agentId: "main", + unknownField: "should fail", + }, + }, + }, + }, + }, + }, + }); + + expect(res.success).toBe(false); + }); +}); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 52fa1bb24cb..a6afe675f83 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -187,6 +187,8 @@ export type TelegramTopicConfig = { systemPrompt?: string; /** If true, skip automatic voice-note transcription for mention detection in this topic. */ disableAudioPreflight?: boolean; + /** Route this topic to a specific agent (overrides group-level and binding routing). */ + agentId?: string; }; export type TelegramGroupConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 14d836e113f..c4de3b4c265 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -68,6 +68,7 @@ export const TelegramTopicSchema = z enabled: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), systemPrompt: z.string().optional(), + agentId: z.string().optional(), }) .strict(); diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/src/telegram/bot-message-context.topic-agentid.test.ts new file mode 100644 index 00000000000..4b983670df2 --- /dev/null +++ b/src/telegram/bot-message-context.topic-agentid.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; + +describe("buildTelegramMessageContext per-topic agentId routing", () => { + it("uses group-level agent when no topic agentId is set", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 3, + from: { id: 42, first_name: "Alice" }, + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { systemPrompt: "Be nice" }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); + }); + + it("routes to topic-specific agent when agentId is set", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 3, + from: { id: 42, first_name: "Alice" }, + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:"); + expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3"); + }); + + it("different topics route to different agents", async () => { + const buildForTopic = async (threadId: number, agentId: string) => + await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId }, + }), + }); + + const ctxA = await buildForTopic(1, "main"); + const ctxB = await buildForTopic(3, "zu"); + const ctxC = await buildForTopic(5, "q"); + + expect(ctxA?.ctxPayload?.SessionKey).toContain("agent:main:"); + expect(ctxB?.ctxPayload?.SessionKey).toContain("agent:zu:"); + expect(ctxC?.ctxPayload?.SessionKey).toContain("agent:q:"); + + expect(ctxA?.ctxPayload?.SessionKey).not.toBe(ctxB?.ctxPayload?.SessionKey); + expect(ctxB?.ctxPayload?.SessionKey).not.toBe(ctxC?.ctxPayload?.SessionKey); + }); + + it("ignores whitespace-only agentId and uses group-level agent", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 3, + from: { id: 42, first_name: "Alice" }, + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: " ", systemPrompt: "Be nice" }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); + }); + + it("routes DM topic to specific agent when agentId is set", async () => { + const ctx = await buildTelegramMessageContextForTest({ + message: { + message_id: 1, + chat: { + id: 123456789, + type: "private", + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: { agentId: "support", systemPrompt: "I am support" }, + }), + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:"); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 7927af7f94d..f5a2b858205 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -38,7 +38,11 @@ import type { } from "../config/types.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; -import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { + buildAgentSessionKey, + resolveAgentRoute, + type ResolvedAgentRoute, +} from "../routing/resolve-route.js"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -199,8 +203,9 @@ export const buildTelegramMessageContext = async ({ : resolveTelegramDirectPeerId({ chatId, senderId }); const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); // Fresh config for bindings lookup; other routing inputs are payload-derived. - const route = resolveAgentRoute({ - cfg: loadConfig(), + const freshCfg = loadConfig(); + let route: ResolvedAgentRoute = resolveAgentRoute({ + cfg: freshCfg, channel: "telegram", accountId: account.accountId, peer: { @@ -209,6 +214,26 @@ export const buildTelegramMessageContext = async ({ }, parentPeer, }); + // Per-topic agentId override: re-derive session key under the topic's agent. + const topicAgentId = topicConfig?.agentId?.trim(); + if (topicAgentId) { + const overrideSessionKey = buildAgentSessionKey({ + agentId: topicAgentId, + channel: "telegram", + accountId: account.accountId, + peer: { kind: isGroup ? "group" : "direct", id: peerId }, + dmScope: freshCfg.session?.dmScope, + identityLinks: freshCfg.session?.identityLinks, + }).toLowerCase(); + route = { + ...route, + agentId: topicAgentId, + sessionKey: overrideSessionKey, + }; + logVerbose( + `telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`, + ); + } // Fail closed for named Telegram accounts when route resolution falls back to // default-agent routing. This prevents cross-account DM/session contamination. if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") {