diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3f74dd278..17a5ee3bee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351) - CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB. - Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682. - Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 98365813be6..6aac6968776 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -25,7 +25,11 @@ import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bo import { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js"; import { resolveMedia } from "./bot/delivery.js"; -import { buildTelegramGroupPeerId, resolveTelegramForumThreadId } from "./bot/helpers.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramForumThreadId, +} from "./bot/helpers.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; import { @@ -149,6 +153,11 @@ export const registerTelegramHandlers = ({ const peerId = params.isGroup ? buildTelegramGroupPeerId(params.chatId, resolvedThreadId) : String(params.chatId); + const parentPeer = buildTelegramParentPeer({ + isGroup: params.isGroup, + resolvedThreadId, + chatId: params.chatId, + }); const route = resolveAgentRoute({ cfg, channel: "telegram", @@ -157,6 +166,7 @@ export const registerTelegramHandlers = ({ kind: params.isGroup ? "group" : "dm", id: peerId, }, + parentPeer, }); const baseSessionKey = route.sessionKey; const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index c09da07748f..745100119b2 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -45,6 +45,7 @@ import { buildSenderName, buildTelegramGroupFrom, buildTelegramGroupPeerId, + buildTelegramParentPeer, buildTypingThreadParams, expandTextLinks, normalizeForwardedContext, @@ -161,6 +162,7 @@ export const buildTelegramMessageContext = async ({ const replyThreadId = threadSpec.id; const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); const route = resolveAgentRoute({ cfg, channel: "telegram", @@ -169,6 +171,7 @@ export const buildTelegramMessageContext = async ({ kind: isGroup ? "group" : "dm", id: peerId, }, + parentPeer, }); const baseSessionKey = route.sessionKey; // DMs: use raw messageThreadId for thread sessions (not forum topic ids) diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 8a7abe7e946..61ed2e535e3 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -50,6 +50,7 @@ import { buildSenderName, buildTelegramGroupFrom, buildTelegramGroupPeerId, + buildTelegramParentPeer, resolveTelegramForumThreadId, resolveTelegramThreadSpec, } from "./bot/helpers.js"; @@ -469,6 +470,7 @@ export const registerTelegramNativeCommands = ({ }); return; } + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); const route = resolveAgentRoute({ cfg, channel: "telegram", @@ -477,6 +479,7 @@ export const registerTelegramNativeCommands = ({ kind: isGroup ? "group" : "dm", id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, + parentPeer, }); const baseSessionKey = route.sessionKey; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 6fad17e7307..a6d9df88cdc 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -331,6 +331,124 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); + it("routes forum topic messages using parent group binding", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + // Binding specifies the base group ID without topic suffix. + // The fix passes parentPeer to resolveAgentRoute so the binding matches + // even when the actual peer id includes the topic suffix. + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + agents: { + list: [{ id: "forum-agent" }], + }, + bindings: [ + { + agentId: "forum-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Message comes from a forum topic (has message_thread_id and is_forum=true) + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello from topic", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + // Should route to forum-agent via parent peer binding inheritance + expect(payload.SessionKey).toContain("agent:forum-agent:"); + }); + + it("prefers specific topic binding over parent group binding", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + // Both a specific topic binding and a parent group binding are configured. + // The specific topic binding should take precedence. + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + agents: { + list: [{ id: "topic-agent" }, { id: "group-agent" }], + }, + bindings: [ + { + agentId: "topic-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890:topic:99" }, + }, + }, + { + agentId: "group-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + // Message from topic 99 - should match the specific topic binding + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello from topic 99", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + // Should route to topic-agent (exact match) not group-agent (parent) + expect(payload.SessionKey).toContain("agent:topic-agent:"); + }); + it("sends GIF replies as animations", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 7144605c6f8..884e222b164 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -40,6 +40,7 @@ import { } from "./bot-updates.js"; import { buildTelegramGroupPeerId, + buildTelegramParentPeer, resolveTelegramForumThreadId, resolveTelegramStreamMode, } from "./bot/helpers.js"; @@ -444,11 +445,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { ? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined }) : undefined; const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: peerId }, + parentPeer, }); const sessionKey = route.sessionKey; diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index c6f69e7fb82..533ab705e68 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -99,6 +99,24 @@ export function buildTelegramGroupFrom(chatId: number | string, messageThreadId? return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`; } +/** + * Build parentPeer for forum topic binding inheritance. + * When a message comes from a forum topic, the peer ID includes the topic suffix + * (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base + * group ID to match, we provide the parent group as `parentPeer` so the routing + * layer can fall back to it when the exact peer doesn't match. + */ +export function buildTelegramParentPeer(params: { + isGroup: boolean; + resolvedThreadId?: number; + chatId: number | string; +}): { kind: "group"; id: string } | undefined { + if (!params.isGroup || params.resolvedThreadId == null) { + return undefined; + } + return { kind: "group", id: String(params.chatId) }; +} + export function buildSenderName(msg: Message) { const name = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||