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:
committed by
Ayaan Zaidi
parent
7f2708a8c3
commit
58bc9a241b
@@ -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.
|
||||
|
||||
135
src/config/config.telegram-topic-agentid.test.ts
Normal file
135
src/config/config.telegram-topic-agentid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
147
src/telegram/bot-message-context.topic-agentid.test.ts
Normal file
147
src/telegram/bot-message-context.topic-agentid.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user