diff --git a/CHANGELOG.md b/CHANGELOG.md index 535a46edb..917530a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Gmail: include tailscale command exit codes/output when hook setup fails (easier debugging). - Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322. - Telegram: include sender identity in group envelope headers. (#336) +- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334. - Messages: stop defaulting ack reactions to 👀 when identity emoji is missing. - Auto-reply: require slash for control commands to avoid false triggers in normal text. - Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331. diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index 0409f19cb..cf387729d 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -9,6 +9,7 @@ Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Di ## Session keys - Group sessions use `agent:::group:` session keys (rooms/channels use `agent:::channel:`). +- Telegram forum topics add `:topic:` to the group id so each topic has its own session. - Direct chats use the main session (or per-sender if configured). - Heartbeats are skipped for group sessions. @@ -118,6 +119,7 @@ Group inbound payloads set: - `GroupSubject` (if known) - `GroupMembers` (if known) - `WasMentioned` (mention gating result) +- Telegram forum topics also include `MessageThreadId` and `IsForum`. The agent system prompt includes a group intro on the first turn of a new group session. diff --git a/docs/concepts/provider-routing.md b/docs/concepts/provider-routing.md index d0d7f76a3..44667456b 100644 --- a/docs/concepts/provider-routing.md +++ b/docs/concepts/provider-routing.md @@ -16,6 +16,7 @@ Goal: deterministic replies per provider, while supporting multi-agent + multi-a - **Canonical direct session (per agent):** direct chats collapse to `agent::` (default `main`). Groups/channels stay isolated per agent: - group: `agent:::group:` - channel/room: `agent:::channel:` + - Telegram forum topics: `agent::telegram:group::topic:` - **Session store:** per-agent store lives under `~/.clawdbot/agents//sessions/sessions.json` (override via `session.store` with `{agentId}` templating). JSONL transcripts live next to it. - **WebChat:** attaches to the selected agent’s main session (so desktop reflects cross-provider history for that agent). - **Implementation hints:** diff --git a/docs/concepts/session.md b/docs/concepts/session.md index aa97abd30..0456229ef 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl - Direct chats collapse to the per-agent primary key: `agent::`. - Multiple phone numbers and providers can map to the same agent main key; they act as transports into one conversation. - 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. - Other sources: - Cron jobs: `cron:` diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index ab38c9e96..93f6203bf 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -38,6 +38,12 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul - Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`). - Replies always route back to the same Telegram chat. +## Topics (forum supergroups) +Telegram forum topics include a `message_thread_id` per message. Clawdbot: +- Appends `:topic:` to the Telegram group session key so each topic is isolated. +- Sends typing indicators and replies with `message_thread_id` so responses stay in the topic. +- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. + ## Access control (DMs + groups) - Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved. - Approve via: diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 65e90f319..1ba411358 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -34,6 +34,10 @@ export type MsgContext = { CommandAuthorized?: boolean; CommandSource?: "text" | "native"; CommandTargetSessionKey?: string; + /** Telegram forum topic thread ID. */ + MessageThreadId?: number; + /** Telegram forum supergroup marker. */ + IsForum?: boolean; }; export type TemplateContext = MsgContext & { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 99e6634c1..6b57cfbf7 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -205,7 +205,7 @@ describe("createTelegramBot", () => { getFile: async () => ({ download: async () => new Uint8Array() }), }); - expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing"); + expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); }); it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { @@ -1315,4 +1315,95 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); + + it("isolates forum topic sessions and carries thread metadata", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + telegram: { groups: { "*": { requireMention: false } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.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: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain( + "telegram:group:-1001234567890:topic:99", + ); + expect(payload.From).toBe("group:-1001234567890:topic:99"); + expect(payload.MessageThreadId).toBe(99); + expect(payload.IsForum).toBe(true); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 99, + }); + }); + + it("passes message_thread_id to topic replies", 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: { groups: { "*": { requireMention: false } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.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: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + 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 daa87403a..b60c35940 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -195,6 +195,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { const msg = primaryCtx.message; const chatId = msg.chat.id; const isGroup = 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 effectiveDmAllow = normalizeAllowFrom([ ...(allowFrom ?? []), ...storeAllowFrom, @@ -206,7 +209,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { const sendTyping = async () => { try { - await bot.api.sendChatAction(chatId, "typing"); + await bot.api.sendChatAction( + chatId, + "typing", + messageThreadId != null + ? { message_thread_id: messageThreadId } + : undefined, + ); } catch (err) { logVerbose( `telegram typing cue failed for chat ${chatId}: ${String(err)}`, @@ -375,7 +384,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const body = formatAgentEnvelope({ provider: "Telegram", from: isGroup - ? buildGroupFromLabel(msg, chatId, senderId) + ? buildGroupFromLabel(msg, chatId, senderId, messageThreadId) : buildSenderLabel(msg, senderId || chatId), timestamp: msg.date ? msg.date * 1000 : undefined, body: `${bodyText}${replySuffix}`, @@ -386,12 +395,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { provider: "telegram", peer: { kind: isGroup ? "group" : "dm", - id: String(chatId), + id: isGroup + ? messageThreadId != null + ? `${chatId}:topic:${messageThreadId}` + : String(chatId) + : String(chatId), }, }); const ctxPayload = { Body: body, - From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + From: isGroup + ? messageThreadId != null + ? `group:${chatId}:topic:${messageThreadId}` + : `group:${chatId}` + : `telegram:${chatId}`, To: `telegram:${chatId}`, SessionKey: route.sessionKey, AccountId: route.accountId, @@ -418,6 +435,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { : undefined, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, + MessageThreadId: messageThreadId, + IsForum: isForum, }; if (replyTarget && shouldLogVerbose()) { @@ -445,8 +464,10 @@ export function createTelegramBot(opts: TelegramBotOptions) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; + const topicInfo = + messageThreadId != null ? ` topic=${messageThreadId}` : ""; logVerbose( - `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`, + `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`, ); } @@ -462,6 +483,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot, replyToMode, textLimit, + messageThreadId, }); }, onError: (err, info) => { @@ -504,6 +526,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { const chatId = msg.chat.id; const isGroup = 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; if (isGroup && useAccessGroups) { const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; @@ -575,12 +600,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { provider: "telegram", peer: { kind: isGroup ? "group" : "dm", - id: String(chatId), + id: isGroup + ? messageThreadId != null + ? `${chatId}:topic:${messageThreadId}` + : String(chatId) + : String(chatId), }, }); const ctxPayload = { Body: prompt, - From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + From: isGroup + ? messageThreadId != null + ? `group:${chatId}:topic:${messageThreadId}` + : `group:${chatId}` + : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, @@ -595,6 +628,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, CommandTargetSessionKey: route.sessionKey, + MessageThreadId: messageThreadId, + IsForum: isForum, }; const replyResult = await getReplyFromConfig( @@ -615,6 +650,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot, replyToMode, textLimit, + messageThreadId, }); }); } @@ -793,8 +829,17 @@ async function deliverReplies(params: { bot: Bot; replyToMode: ReplyToMode; textLimit: number; + messageThreadId?: number; }) { - const { replies, chatId, runtime, bot, replyToMode, textLimit } = params; + const { + replies, + chatId, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId, + } = params; let hasReplied = false; for (const reply of replies) { if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { @@ -817,6 +862,7 @@ async function deliverReplies(params: { replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, + messageThreadId, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -841,30 +887,37 @@ async function deliverReplies(params: { replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; + const threadParams = + messageThreadId != null ? { message_thread_id: messageThreadId } : {}; if (isGif) { await bot.api.sendAnimation(chatId, file, { caption, reply_to_message_id: replyToMessageId, + ...threadParams, }); } else if (kind === "image") { await bot.api.sendPhoto(chatId, file, { caption, reply_to_message_id: replyToMessageId, + ...threadParams, }); } else if (kind === "video") { await bot.api.sendVideo(chatId, file, { caption, reply_to_message_id: replyToMessageId, + ...threadParams, }); } else if (kind === "audio") { await bot.api.sendAudio(chatId, file, { caption, reply_to_message_id: replyToMessageId, + ...threadParams, }); } else { await bot.api.sendDocument(chatId, file, { caption, reply_to_message_id: replyToMessageId, + ...threadParams, }); } if (replyToId && !hasReplied) { @@ -903,18 +956,25 @@ function buildSenderLabel(msg: TelegramMessage, senderId?: number | string) { return idPart ?? "id:unknown"; } -function buildGroupLabel(msg: TelegramMessage, chatId: number | string) { +function buildGroupLabel( + msg: TelegramMessage, + chatId: number | string, + messageThreadId?: number, +) { const title = msg.chat?.title; - if (title) return `${title} id:${chatId}`; - return `group:${chatId}`; + const topicSuffix = + messageThreadId != null ? ` topic:${messageThreadId}` : ""; + if (title) return `${title} id:${chatId}${topicSuffix}`; + return `group:${chatId}${topicSuffix}`; } function buildGroupFromLabel( msg: TelegramMessage, chatId: number | string, senderId?: number | string, + messageThreadId?: number, ) { - const groupLabel = buildGroupLabel(msg, chatId); + const groupLabel = buildGroupLabel(msg, chatId, messageThreadId); const senderLabel = buildSenderLabel(msg, senderId); return `${groupLabel} from ${senderLabel}`; } @@ -989,12 +1049,13 @@ async function sendTelegramText( chatId: string, text: string, runtime: RuntimeEnv, - opts?: { replyToMessageId?: number }, + opts?: { replyToMessageId?: number; messageThreadId?: number }, ): Promise { try { const res = await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown", reply_to_message_id: opts?.replyToMessageId, + message_thread_id: opts?.messageThreadId, }); return res.message_id; } catch (err) { @@ -1005,6 +1066,7 @@ async function sendTelegramText( ); const res = await bot.api.sendMessage(chatId, text, { reply_to_message_id: opts?.replyToMessageId, + message_thread_id: opts?.messageThreadId, }); return res.message_id; }