From c9fdd68232e3bbb787b4aca82df60f56c93dfbaf Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 12 Jan 2026 22:06:03 -0600 Subject: [PATCH] Telegram: keep forum topic thread ids in replies Closes #727 --- CHANGELOG.md | 1 + src/telegram/bot.test.ts | 50 ++++++++++++++++++++++++++ src/telegram/bot.ts | 77 +++++++++++++++++++++++++--------------- 3 files changed, 100 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d231035..471ef8d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes - Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight) +- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow) - Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow) - Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow) - System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow) diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 15d095459..f1b6bc6a4 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -211,6 +211,11 @@ describe("createTelegramBot", () => { message: { chat: { id: 123 }, message_thread_id: 9 }, }), ).toBe("telegram:123:topic:9"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, is_forum: true } }, + }), + ).toBe("telegram:123:topic:1"); expect( getTelegramSequentialKey({ update: { message: { chat: { id: 555 } } }, @@ -1766,6 +1771,51 @@ describe("createTelegramBot", () => { }); }); + it("routes General topic replies using thread id 1", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 1 }), + ); + }); + it("applies topic skill filters and system prompts", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index c5135c8f6..275921c8b 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -196,7 +196,11 @@ export function getTelegramSequentialKey(ctx: { ctx.update?.edited_message ?? ctx.update?.callback_query?.message; const chatId = msg?.chat?.id ?? ctx.chat?.id; - const threadId = msg?.message_thread_id; + const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; + const threadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId: msg?.message_thread_id, + }); if (typeof chatId === "number") { return threadId != null ? `telegram:${chatId}:topic:${threadId}` @@ -205,6 +209,16 @@ export function getTelegramSequentialKey(ctx: { return "telegram:unknown"; } +function resolveTelegramForumThreadId(params: { + isForum?: boolean; + messageThreadId?: number | null; +}) { + if (params.isForum && params.messageThreadId == null) { + return TELEGRAM_GENERAL_TOPIC_ID; + } + return params.messageThreadId ?? undefined; +} + export function createTelegramBot(opts: TelegramBotOptions) { const runtime: RuntimeEnv = opts.runtime ?? { log: console.log, @@ -423,12 +437,16 @@ export function createTelegramBot(opts: TelegramBotOptions) { const messageThreadId = (msg as { message_thread_id?: number }) .message_thread_id; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); const { groupConfig, topicConfig } = resolveTelegramGroupConfig( chatId, - messageThreadId, + resolvedThreadId, ); const peerId = isGroup - ? buildTelegramGroupPeerId(chatId, messageThreadId) + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); const route = resolveAgentRoute({ cfg, @@ -460,23 +478,17 @@ export function createTelegramBot(opts: TelegramBotOptions) { } if (isGroup && topicConfig?.enabled === false) { logVerbose( - `Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, ); return; } const sendTyping = async () => { try { - // In forums, the General topic has no message_thread_id in updates, - // but sendChatAction requires one to show typing. - const typingThreadId = - isForum && messageThreadId == null - ? TELEGRAM_GENERAL_TOPIC_ID - : messageThreadId; await bot.api.sendChatAction( chatId, "typing", - buildTelegramThreadParams(typingThreadId), + buildTelegramThreadParams(resolvedThreadId), ); } catch (err) { logVerbose( @@ -588,7 +600,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ); const activationOverride = resolveGroupActivation({ chatId, - messageThreadId, + messageThreadId: resolvedThreadId, sessionKey: route.sessionKey, agentId: route.agentId, }); @@ -684,19 +696,19 @@ export function createTelegramBot(opts: TelegramBotOptions) { }]\n${replyTarget.body}\n[/Replying]` : ""; const groupLabel = isGroup - ? buildGroupLabel(msg, chatId, messageThreadId) + ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined; const body = formatAgentEnvelope({ provider: "Telegram", from: isGroup - ? buildGroupFromLabel(msg, chatId, senderId, messageThreadId) + ? buildGroupFromLabel(msg, chatId, senderId, resolvedThreadId) : buildSenderLabel(msg, senderId || chatId), timestamp: msg.date ? msg.date * 1000 : undefined, body: `${bodyText}${replySuffix}`, }); let combinedBody = body; const historyKey = isGroup - ? buildTelegramGroupPeerId(chatId, messageThreadId) + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined; if (isGroup && historyKey && historyLimit > 0) { combinedBody = buildHistoryContextFromMap({ @@ -736,7 +748,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { RawBody: rawBody, CommandBody: commandBody, From: isGroup - ? buildTelegramGroupFrom(chatId, messageThreadId) + ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `telegram:${chatId}`, SessionKey: route.sessionKey, @@ -766,7 +778,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { : undefined, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, - MessageThreadId: messageThreadId, + MessageThreadId: resolvedThreadId, IsForum: isForum, // Originating channel for reply routing. OriginatingChannel: "telegram" as const, @@ -799,7 +811,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; const topicInfo = - messageThreadId != null ? ` topic=${messageThreadId}` : ""; + resolvedThreadId != null ? ` topic=${resolvedThreadId}` : ""; logVerbose( `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`, ); @@ -810,7 +822,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const canStreamDraft = streamMode !== "off" && isPrivateChat && - typeof messageThreadId === "number" && + typeof resolvedThreadId === "number" && (await resolveBotTopicsEnabled(primaryCtx)); const draftStream = canStreamDraft ? createTelegramDraftStream({ @@ -818,7 +830,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { chatId, draftId: msg.message_id || Date.now(), maxChars: draftMaxChars, - messageThreadId, + messageThreadId: resolvedThreadId, log: logVerbose, warn: logVerbose, }) @@ -905,7 +917,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot, replyToMode, textLimit, - messageThreadId, + messageThreadId: resolvedThreadId, }); didSendReply = true; }, @@ -999,12 +1011,16 @@ export function createTelegramBot(opts: TelegramBotOptions) { .message_thread_id; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); const storeAllowFrom = await readTelegramAllowFromStore().catch( () => [], ); const { groupConfig, topicConfig } = resolveTelegramGroupConfig( chatId, - messageThreadId, + resolvedThreadId, ); const groupAllowOverride = firstDefined( topicConfig?.allowFrom, @@ -1116,7 +1132,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: isGroup - ? buildTelegramGroupPeerId(chatId, messageThreadId) + ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, }); @@ -1135,7 +1151,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const ctxPayload = { Body: prompt, From: isGroup - ? buildTelegramGroupFrom(chatId, messageThreadId) + ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", @@ -1152,7 +1168,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, CommandTargetSessionKey: route.sessionKey, - MessageThreadId: messageThreadId, + MessageThreadId: resolvedThreadId, IsForum: isForum, }; @@ -1176,7 +1192,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot, replyToMode, textLimit, - messageThreadId, + messageThreadId: resolvedThreadId, }); }, onError: (err, info) => { @@ -1256,10 +1272,15 @@ export function createTelegramBot(opts: TelegramBotOptions) { msg.chat.type === "group" || msg.chat.type === "supergroup"; const messageThreadId = (msg as { message_thread_id?: number }) .message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); const { groupConfig, topicConfig } = resolveTelegramGroupConfig( chatId, - messageThreadId, + resolvedThreadId, ); const groupAllowOverride = firstDefined( topicConfig?.allowFrom, @@ -1278,7 +1299,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { } if (topicConfig?.enabled === false) { logVerbose( - `Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, ); return; }