Skip to content

Commit 58bc9a2

Browse files
kesorobviyus
authored andcommitted
feat(telegram): add per-topic agent routing for forum groups [AI-assisted]
This feature allows different topics within a Telegram forum supergroup to route to different agents, each with isolated workspace, memory, and sessions. Key changes: - Add agentId field to TelegramTopicConfig type for per-topic routing - Add zod validation for agentId in topic config schema - Implement routing logic to re-derive session key with topic's agent - Add debug logging for topic agent overrides - Add unit tests for routing behavior (forum topics + DM topics) - Add config validation tests - Document feature in docs/channels/telegram.md This builds on the approach from PR #31513 by @Sid-Qin with additional fixes for security (preserved account fail-closed guard) and test coverage. Closes #31473
1 parent 7f2708a commit 58bc9a2

6 files changed

Lines changed: 338 additions & 4 deletions

File tree

docs/channels/telegram.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,29 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
444444
- message sends omit `message_thread_id` (Telegram rejects `sendMessage(...thread_id=1)`)
445445
- typing actions still include `message_thread_id`
446446

447-
Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`).
447+
Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`, `agentId`).
448+
449+
**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:
450+
451+
```json5
452+
{
453+
channels: {
454+
telegram: {
455+
groups: {
456+
"-1001234567890": {
457+
topics: {
458+
"1": { agentId: "main" }, // General topic → main agent
459+
"3": { agentId: "zu" }, // Dev topic → zu agent
460+
"5": { agentId: "coder" } // Code review → coder agent
461+
}
462+
}
463+
}
464+
}
465+
}
466+
}
467+
```
468+
469+
Each topic then has its own session key: `agent:main:telegram:group:-1001234567890:topic:3`
448470

449471
Template context includes:
450472

@@ -752,8 +774,10 @@ Primary reference:
752774
- `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
753775
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
754776
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
777+
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
755778
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
756779
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
780+
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
757781
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
758782
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
759783
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from "vitest";
2+
import { OpenClawSchema } from "./zod-schema.js";
3+
4+
describe("telegram topic agentId schema", () => {
5+
it("accepts valid agentId in forum group topic config", () => {
6+
const res = OpenClawSchema.safeParse({
7+
channels: {
8+
telegram: {
9+
groups: {
10+
"-1001234567890": {
11+
topics: {
12+
"42": {
13+
agentId: "main",
14+
},
15+
},
16+
},
17+
},
18+
},
19+
},
20+
});
21+
22+
expect(res.success).toBe(true);
23+
if (!res.success) {
24+
console.error(res.error.format());
25+
return;
26+
}
27+
expect(res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics?.["42"]?.agentId).toBe(
28+
"main",
29+
);
30+
});
31+
32+
it("accepts valid agentId in DM topic config", () => {
33+
const res = OpenClawSchema.safeParse({
34+
channels: {
35+
telegram: {
36+
direct: {
37+
"123456789": {
38+
topics: {
39+
"99": {
40+
agentId: "support",
41+
systemPrompt: "You are support",
42+
},
43+
},
44+
},
45+
},
46+
},
47+
},
48+
});
49+
50+
expect(res.success).toBe(true);
51+
if (!res.success) {
52+
console.error(res.error.format());
53+
return;
54+
}
55+
expect(res.data.channels?.telegram?.direct?.["123456789"]?.topics?.["99"]?.agentId).toBe(
56+
"support",
57+
);
58+
});
59+
60+
it("accepts empty config without agentId (backward compatible)", () => {
61+
const res = OpenClawSchema.safeParse({
62+
channels: {
63+
telegram: {
64+
groups: {
65+
"-1001234567890": {
66+
topics: {
67+
"42": {
68+
systemPrompt: "Be helpful",
69+
},
70+
},
71+
},
72+
},
73+
},
74+
},
75+
});
76+
77+
expect(res.success).toBe(true);
78+
if (!res.success) {
79+
console.error(res.error.format());
80+
return;
81+
}
82+
expect(res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics?.["42"]).toEqual({
83+
systemPrompt: "Be helpful",
84+
});
85+
});
86+
87+
it("accepts multiple topics with different agentIds", () => {
88+
const res = OpenClawSchema.safeParse({
89+
channels: {
90+
telegram: {
91+
groups: {
92+
"-1001234567890": {
93+
topics: {
94+
"1": { agentId: "main" },
95+
"3": { agentId: "zu" },
96+
"5": { agentId: "q" },
97+
},
98+
},
99+
},
100+
},
101+
},
102+
});
103+
104+
expect(res.success).toBe(true);
105+
if (!res.success) {
106+
console.error(res.error.format());
107+
return;
108+
}
109+
const topics = res.data.channels?.telegram?.groups?.["-1001234567890"]?.topics;
110+
expect(topics?.["1"]?.agentId).toBe("main");
111+
expect(topics?.["3"]?.agentId).toBe("zu");
112+
expect(topics?.["5"]?.agentId).toBe("q");
113+
});
114+
115+
it("rejects unknown fields in topic config (strict schema)", () => {
116+
const res = OpenClawSchema.safeParse({
117+
channels: {
118+
telegram: {
119+
groups: {
120+
"-1001234567890": {
121+
topics: {
122+
"42": {
123+
agentId: "main",
124+
unknownField: "should fail",
125+
},
126+
},
127+
},
128+
},
129+
},
130+
},
131+
});
132+
133+
expect(res.success).toBe(false);
134+
});
135+
});

src/config/types.telegram.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export type TelegramTopicConfig = {
187187
systemPrompt?: string;
188188
/** If true, skip automatic voice-note transcription for mention detection in this topic. */
189189
disableAudioPreflight?: boolean;
190+
/** Route this topic to a specific agent (overrides group-level and binding routing). */
191+
agentId?: string;
190192
};
191193

192194
export type TelegramGroupConfig = {

src/config/zod-schema.providers-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const TelegramTopicSchema = z
6868
enabled: z.boolean().optional(),
6969
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
7070
systemPrompt: z.string().optional(),
71+
agentId: z.string().optional(),
7172
})
7273
.strict();
7374

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
3+
4+
describe("buildTelegramMessageContext per-topic agentId routing", () => {
5+
it("uses group-level agent when no topic agentId is set", async () => {
6+
const ctx = await buildTelegramMessageContextForTest({
7+
message: {
8+
message_id: 1,
9+
chat: {
10+
id: -1001234567890,
11+
type: "supergroup",
12+
title: "Forum",
13+
is_forum: true,
14+
},
15+
date: 1700000000,
16+
text: "@bot hello",
17+
message_thread_id: 3,
18+
from: { id: 42, first_name: "Alice" },
19+
},
20+
options: { forceWasMentioned: true },
21+
resolveGroupActivation: () => true,
22+
resolveTelegramGroupConfig: () => ({
23+
groupConfig: { requireMention: false },
24+
topicConfig: { systemPrompt: "Be nice" },
25+
}),
26+
});
27+
28+
expect(ctx).not.toBeNull();
29+
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3");
30+
});
31+
32+
it("routes to topic-specific agent when agentId is set", async () => {
33+
const ctx = await buildTelegramMessageContextForTest({
34+
message: {
35+
message_id: 1,
36+
chat: {
37+
id: -1001234567890,
38+
type: "supergroup",
39+
title: "Forum",
40+
is_forum: true,
41+
},
42+
date: 1700000000,
43+
text: "@bot hello",
44+
message_thread_id: 3,
45+
from: { id: 42, first_name: "Alice" },
46+
},
47+
options: { forceWasMentioned: true },
48+
resolveGroupActivation: () => true,
49+
resolveTelegramGroupConfig: () => ({
50+
groupConfig: { requireMention: false },
51+
topicConfig: { agentId: "zu", systemPrompt: "I am Zu" },
52+
}),
53+
});
54+
55+
expect(ctx).not.toBeNull();
56+
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:");
57+
expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3");
58+
});
59+
60+
it("different topics route to different agents", async () => {
61+
const buildForTopic = async (threadId: number, agentId: string) =>
62+
await buildTelegramMessageContextForTest({
63+
message: {
64+
message_id: 1,
65+
chat: {
66+
id: -1001234567890,
67+
type: "supergroup",
68+
title: "Forum",
69+
is_forum: true,
70+
},
71+
date: 1700000000,
72+
text: "@bot hello",
73+
message_thread_id: threadId,
74+
from: { id: 42, first_name: "Alice" },
75+
},
76+
options: { forceWasMentioned: true },
77+
resolveGroupActivation: () => true,
78+
resolveTelegramGroupConfig: () => ({
79+
groupConfig: { requireMention: false },
80+
topicConfig: { agentId },
81+
}),
82+
});
83+
84+
const ctxA = await buildForTopic(1, "main");
85+
const ctxB = await buildForTopic(3, "zu");
86+
const ctxC = await buildForTopic(5, "q");
87+
88+
expect(ctxA?.ctxPayload?.SessionKey).toContain("agent:main:");
89+
expect(ctxB?.ctxPayload?.SessionKey).toContain("agent:zu:");
90+
expect(ctxC?.ctxPayload?.SessionKey).toContain("agent:q:");
91+
92+
expect(ctxA?.ctxPayload?.SessionKey).not.toBe(ctxB?.ctxPayload?.SessionKey);
93+
expect(ctxB?.ctxPayload?.SessionKey).not.toBe(ctxC?.ctxPayload?.SessionKey);
94+
});
95+
96+
it("ignores whitespace-only agentId and uses group-level agent", async () => {
97+
const ctx = await buildTelegramMessageContextForTest({
98+
message: {
99+
message_id: 1,
100+
chat: {
101+
id: -1001234567890,
102+
type: "supergroup",
103+
title: "Forum",
104+
is_forum: true,
105+
},
106+
date: 1700000000,
107+
text: "@bot hello",
108+
message_thread_id: 3,
109+
from: { id: 42, first_name: "Alice" },
110+
},
111+
options: { forceWasMentioned: true },
112+
resolveGroupActivation: () => true,
113+
resolveTelegramGroupConfig: () => ({
114+
groupConfig: { requireMention: false },
115+
topicConfig: { agentId: " ", systemPrompt: "Be nice" },
116+
}),
117+
});
118+
119+
expect(ctx).not.toBeNull();
120+
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
121+
});
122+
123+
it("routes DM topic to specific agent when agentId is set", async () => {
124+
const ctx = await buildTelegramMessageContextForTest({
125+
message: {
126+
message_id: 1,
127+
chat: {
128+
id: 123456789,
129+
type: "private",
130+
},
131+
date: 1700000000,
132+
text: "@bot hello",
133+
message_thread_id: 99,
134+
from: { id: 42, first_name: "Alice" },
135+
},
136+
options: { forceWasMentioned: true },
137+
resolveGroupActivation: () => true,
138+
resolveTelegramGroupConfig: () => ({
139+
groupConfig: { requireMention: false },
140+
topicConfig: { agentId: "support", systemPrompt: "I am support" },
141+
}),
142+
});
143+
144+
expect(ctx).not.toBeNull();
145+
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:");
146+
});
147+
});

src/telegram/bot-message-context.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ import type {
3838
} from "../config/types.js";
3939
import { logVerbose, shouldLogVerbose } from "../globals.js";
4040
import { recordChannelActivity } from "../infra/channel-activity.js";
41-
import { resolveAgentRoute } from "../routing/resolve-route.js";
41+
import {
42+
buildAgentSessionKey,
43+
resolveAgentRoute,
44+
type ResolvedAgentRoute,
45+
} from "../routing/resolve-route.js";
4246
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js";
4347
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
4448
import { withTelegramApiErrorLogging } from "./api-logging.js";
@@ -199,8 +203,9 @@ export const buildTelegramMessageContext = async ({
199203
: resolveTelegramDirectPeerId({ chatId, senderId });
200204
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
201205
// Fresh config for bindings lookup; other routing inputs are payload-derived.
202-
const route = resolveAgentRoute({
203-
cfg: loadConfig(),
206+
const freshCfg = loadConfig();
207+
let route: ResolvedAgentRoute = resolveAgentRoute({
208+
cfg: freshCfg,
204209
channel: "telegram",
205210
accountId: account.accountId,
206211
peer: {
@@ -209,6 +214,26 @@ export const buildTelegramMessageContext = async ({
209214
},
210215
parentPeer,
211216
});
217+
// Per-topic agentId override: re-derive session key under the topic's agent.
218+
const topicAgentId = topicConfig?.agentId?.trim();
219+
if (topicAgentId) {
220+
const overrideSessionKey = buildAgentSessionKey({
221+
agentId: topicAgentId,
222+
channel: "telegram",
223+
accountId: account.accountId,
224+
peer: { kind: isGroup ? "group" : "direct", id: peerId },
225+
dmScope: freshCfg.session?.dmScope,
226+
identityLinks: freshCfg.session?.identityLinks,
227+
}).toLowerCase();
228+
route = {
229+
...route,
230+
agentId: topicAgentId,
231+
sessionKey: overrideSessionKey,
232+
};
233+
logVerbose(
234+
`telegram: per-topic agent override: topic=${resolvedThreadId ?? dmThreadId} agent=${topicAgentId} sessionKey=${overrideSessionKey}`,
235+
);
236+
}
212237
// Fail closed for named Telegram accounts when route resolution falls back to
213238
// default-agent routing. This prevents cross-account DM/session contamination.
214239
if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") {

0 commit comments

Comments
 (0)