diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 0456229ef..8cd144201 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -27,6 +27,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. - Legacy `group:` keys are still recognized for migration. + - Inbound contexts may still use `group:`; the provider is inferred from `Provider` and normalized to the canonical `agent:::group:` form. - Other sources: - Cron jobs: `cron:` - Webhooks: `hook:` (unless explicitly set by the hook) diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 6b57cfbf7..5663dc61e 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -39,6 +39,7 @@ vi.mock("./pairing-store.js", () => ({ const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); +const commandSpy = vi.fn(); const sendChatActionSpy = vi.fn(); const setMessageReactionSpy = vi.fn(async () => undefined); const setMyCommandsSpy = vi.fn(async () => undefined); @@ -69,6 +70,7 @@ vi.mock("grammy", () => ({ api = apiStub; on = onSpy; stop = stopSpy; + command = commandSpy; constructor(public token: string) {} }, InputFile: class {}, @@ -1367,6 +1369,7 @@ describe("createTelegramBot", () => { it("passes message_thread_id to topic replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); + commandSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< typeof vi.fn >; @@ -1406,4 +1409,53 @@ describe("createTelegramBot", () => { expect.objectContaining({ message_thread_id: 99 }), ); }); + + it("threads native command replies inside topics", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + expect(commandSpy).toHaveBeenCalled(); + const handler = commandSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "/status", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + match: "", + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b60c35940..d2e27f3bf 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -212,9 +212,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { await bot.api.sendChatAction( chatId, "typing", - messageThreadId != null - ? { message_thread_id: messageThreadId } - : undefined, + buildTelegramThreadParams(messageThreadId), ); } catch (err) { logVerbose( @@ -396,18 +394,14 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: isGroup - ? messageThreadId != null - ? `${chatId}:topic:${messageThreadId}` - : String(chatId) + ? buildTelegramGroupPeerId(chatId, messageThreadId) : String(chatId), }, }); const ctxPayload = { Body: body, From: isGroup - ? messageThreadId != null - ? `group:${chatId}:topic:${messageThreadId}` - : `group:${chatId}` + ? buildTelegramGroupFrom(chatId, messageThreadId) : `telegram:${chatId}`, To: `telegram:${chatId}`, SessionKey: route.sessionKey, @@ -601,18 +595,14 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: isGroup - ? messageThreadId != null - ? `${chatId}:topic:${messageThreadId}` - : String(chatId) + ? buildTelegramGroupPeerId(chatId, messageThreadId) : String(chatId), }, }); const ctxPayload = { Body: prompt, From: isGroup - ? messageThreadId != null - ? `group:${chatId}:topic:${messageThreadId}` - : `group:${chatId}` + ? buildTelegramGroupFrom(chatId, messageThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", @@ -840,6 +830,7 @@ async function deliverReplies(params: { textLimit, messageThreadId, } = params; + const threadParams = buildTelegramThreadParams(messageThreadId); let hasReplied = false; for (const reply of replies) { if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { @@ -887,37 +878,32 @@ async function deliverReplies(params: { replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; - const threadParams = - messageThreadId != null ? { message_thread_id: messageThreadId } : {}; + const mediaParams: Record = { + caption, + reply_to_message_id: replyToMessageId, + }; + if (threadParams) { + mediaParams.message_thread_id = threadParams.message_thread_id; + } if (isGif) { await bot.api.sendAnimation(chatId, file, { - caption, - reply_to_message_id: replyToMessageId, - ...threadParams, + ...mediaParams, }); } else if (kind === "image") { await bot.api.sendPhoto(chatId, file, { - caption, - reply_to_message_id: replyToMessageId, - ...threadParams, + ...mediaParams, }); } else if (kind === "video") { await bot.api.sendVideo(chatId, file, { - caption, - reply_to_message_id: replyToMessageId, - ...threadParams, + ...mediaParams, }); } else if (kind === "audio") { await bot.api.sendAudio(chatId, file, { - caption, - reply_to_message_id: replyToMessageId, - ...threadParams, + ...mediaParams, }); } else { await bot.api.sendDocument(chatId, file, { - caption, - reply_to_message_id: replyToMessageId, - ...threadParams, + ...mediaParams, }); } if (replyToId && !hasReplied) { @@ -927,6 +913,30 @@ async function deliverReplies(params: { } } +function buildTelegramThreadParams(messageThreadId?: number) { + return messageThreadId != null + ? { message_thread_id: messageThreadId } + : undefined; +} + +function buildTelegramGroupPeerId( + chatId: number | string, + messageThreadId?: number, +) { + return messageThreadId != null + ? `${chatId}:topic:${messageThreadId}` + : String(chatId); +} + +function buildTelegramGroupFrom( + chatId: number | string, + messageThreadId?: number, +) { + return messageThreadId != null + ? `group:${chatId}:topic:${messageThreadId}` + : `group:${chatId}`; +} + function buildSenderName(msg: TelegramMessage) { const name = [msg.from?.first_name, msg.from?.last_name] @@ -1051,11 +1061,17 @@ async function sendTelegramText( runtime: RuntimeEnv, opts?: { replyToMessageId?: number; messageThreadId?: number }, ): Promise { + const threadParams = buildTelegramThreadParams(opts?.messageThreadId); + const baseParams: Record = { + reply_to_message_id: opts?.replyToMessageId, + }; + if (threadParams) { + baseParams.message_thread_id = threadParams.message_thread_id; + } try { const res = await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown", - reply_to_message_id: opts?.replyToMessageId, - message_thread_id: opts?.messageThreadId, + ...baseParams, }); return res.message_id; } catch (err) { @@ -1065,8 +1081,7 @@ async function sendTelegramText( `telegram markdown parse failed; retrying without formatting: ${errText}`, ); const res = await bot.api.sendMessage(chatId, text, { - reply_to_message_id: opts?.replyToMessageId, - message_thread_id: opts?.messageThreadId, + ...baseParams, }); return res.message_id; }