mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
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
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