From 915497114e3a96f98ee92d4d53dc911552195868 Mon Sep 17 00:00:00 2001 From: Dylan Neve Date: Tue, 27 Jan 2026 11:17:31 +0000 Subject: [PATCH] fix(telegram): ignore message_thread_id for non-forum group sessions Regular Telegram groups (without Topics/Forums enabled) can send message_thread_id when users reply to messages. This was incorrectly being used to create separate session keys like '-123:topic:42', causing each reply chain to get its own conversation context. Now resolveTelegramForumThreadId only returns a thread ID when the chat is actually a forum (is_forum=true). For regular groups, the thread ID is ignored, ensuring all messages share the same session. DMs continue to use messageThreadId for thread sessions as before. --- .../bot-message-context.dm-threads.test.ts | 99 +++++++++++++++++++ src/telegram/bot-message-context.ts | 6 +- src/telegram/bot-native-commands.ts | 3 +- src/telegram/bot.ts | 3 +- src/telegram/bot/helpers.test.ts | 22 +++++ src/telegram/bot/helpers.ts | 15 ++- 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts index ff6a8a837..d710e0b1b 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/src/telegram/bot-message-context.dm-threads.test.ts @@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); }); + +describe("buildTelegramMessageContext group sessions without forum", () => { + const baseConfig = { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never; + + const buildContext = async (message: Record) => + await buildTelegramMessageContext({ + primaryCtx: { + message, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: { forceWasMentioned: true }, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: baseConfig, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => true, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + it("ignores message_thread_id for regular groups (not forums)", async () => { + // When someone replies to a message in a non-forum group, Telegram sends + // message_thread_id but this should NOT create a separate session + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, // This is a reply thread, NOT a forum topic + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key should NOT include :topic:42 + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); + // MessageThreadId should be undefined (not a forum) + expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); + }); + + it("keeps same session for regular group with and without message_thread_id", async () => { + const ctxWithThread = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, + from: { id: 42, first_name: "Alice" }, + }); + + const ctxWithoutThread = await buildContext({ + message_id: 2, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000001, + text: "@bot world", + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctxWithThread).not.toBeNull(); + expect(ctxWithoutThread).not.toBeNull(); + // Both messages should use the same session key + expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey); + }); + + it("uses topic session for forum groups with message_thread_id", async () => { + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key SHOULD include :topic:99 for forums + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); + expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index aa6dcd88b..832a4413d 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) @@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({ Sticker: allMedia[0]?.stickerMetadata, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, - MessageThreadId: resolvedThreadId, + // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId + MessageThreadId: isGroup ? resolvedThreadId : messageThreadId, IsForum: isForum, // Originating channel for reply routing. OriginatingChannel: "telegram" as const, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0dd372c3e..6b8bfba01 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -421,7 +421,8 @@ export const registerTelegramNativeCommands = ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 655e1b427..c41abb34b 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -427,7 +427,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index 60fbba0dc..6b363933d 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -3,8 +3,30 @@ import { buildTelegramThreadParams, buildTypingThreadParams, normalizeForwardedContext, + resolveTelegramForumThreadId, } from "./helpers.js"; +describe("resolveTelegramForumThreadId", () => { + it("returns undefined for non-forum groups even with messageThreadId", () => { + // Reply threads in regular groups should not create separate sessions + expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined(); + }); + + it("returns undefined for non-forum groups without messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined(); + expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined(); + }); + + it("returns General topic (1) for forum groups without messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1); + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1); + }); + + it("returns the topic id for forum groups with messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99); + }); +}); + describe("buildTelegramThreadParams", () => { it("omits General topic thread id for message sends", () => { expect(buildTelegramThreadParams(1)).toBeUndefined(); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 19b8e76c0..cd57392c0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -13,14 +13,25 @@ import type { const TELEGRAM_GENERAL_TOPIC_ID = 1; +/** + * Resolve the thread ID for Telegram forum topics. + * For non-forum groups, returns undefined even if messageThreadId is present + * (reply threads in regular groups should not create separate sessions). + * For forum groups, returns the topic ID (or General topic ID=1 if unspecified). + */ export function resolveTelegramForumThreadId(params: { isForum?: boolean; messageThreadId?: number | null; }) { - if (params.isForum && params.messageThreadId == null) { + // Non-forum groups: ignore message_thread_id (reply threads are not real topics) + if (!params.isForum) { + return undefined; + } + // Forum groups: use the topic ID, defaulting to General topic + if (params.messageThreadId == null) { return TELEGRAM_GENERAL_TOPIC_ID; } - return params.messageThreadId ?? undefined; + return params.messageThreadId; } /**