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
This commit is contained in:
Evgeny Zislis
2026-03-04 01:12:46 +02:00
committed by Ayaan Zaidi
parent 7f2708a8c3
commit 58bc9a241b
6 changed files with 338 additions and 4 deletions

View File

@@ -444,7 +444,29 @@ curl "https://api.telegram.org/bot<bot_token>/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.<id>.systemPrompt`: extra system prompt for the group.
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.direct.<id>.topics.<threadId>.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.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.

View File

@@ -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);
});
});

View File

@@ -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 = {

View File

@@ -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();

View File

@@ -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:");
});
});

View File

@@ -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") {