diff --git a/.agent-guidance/features/telegram-integration.md b/.agent-guidance/features/telegram-integration.md index a7423f4b6..c1df27a0d 100644 --- a/.agent-guidance/features/telegram-integration.md +++ b/.agent-guidance/features/telegram-integration.md @@ -102,7 +102,18 @@ otherwise the LLM router and workers silently fall back or fail. account may click; a new task-entry message in the same chat/topic invalidates the previous pending card. Suggestion buttons and other explicit-intent launches skip the card - (`skipRoutingConfirmation`). Bot commands count as invocations — for both + (`skipRoutingConfirmation`). In a Topics-enabled supergroup + (`chat.is_forum`), a task launched outside any topic gets **its own forum + topic** (`launchTelegramTask` in `task-launch.ts`): the bot creates a + topic named after the request (`createForumTopic`, requires admin with + the manage-topics right), the job's `communicationThreadId` points at it, + the started card posts inside it, and a pointer with an "Open topic" deep + link is left as a reply to the launch message. Worker replies re-anchor + to the in-topic started card (`setLatestInboundMessageId`), so the whole + conversation lives in the topic — Telegram's native equivalent of a Slack + thread. Launches from inside an existing topic stay there; + topic-creation failure (plain groups, missing rights) falls back to the + main chat instead of dropping the task. Bot commands count as invocations — for both entry detection and invocation stripping — only when they lead the message (offset 0, or preceded only by this bot's mention); a `/command` mentioned mid-sentence is ordinary message content and survives into queued text. @@ -281,7 +292,9 @@ manual workspace picker (same Yes/Nope-then-picker two-step as Slack, via inline keyboard + `editMessageText`; same 0.95 confidence gate but a ~5s auto-confirm vs Slack's 30s constant; router fallback shows the picker instead of silently launching in all repos), and a "typing…" indicator while -a reply is delivered (`sendChatAction` heartbeat, bounded to the send). +a reply is delivered (`sendChatAction` heartbeat, bounded to the send), and +per-task forum topics in Topics-enabled supergroups (the closest analog to +Slack threads; DMs and plain groups keep chat-scoped continuity). Unlike Slack there is no free-text correction while a confirmation is pending — a new message in the chat starts a fresh routing flow and invalidates the old card. diff --git a/.agents/skills/mock-telegram-testing/references/scenarios.md b/.agents/skills/mock-telegram-testing/references/scenarios.md index 4e9e7401b..0463a3a59 100644 --- a/.agents/skills/mock-telegram-testing/references/scenarios.md +++ b/.agents/skills/mock-telegram-testing/references/scenarios.md @@ -47,11 +47,22 @@ Groups only enter tasks on explicit address. Requires `TELEGRAM_BOT_USERNAME=roo - Follow-ups in the group while that job is active queue to it even without a mention (chat-id continuity), which is intentional but worth observing. - Assert: state message list; DB job payload `communicationChannelId: '-100222000222'`. -## 6. forum-topic-isolation +## 6. forum-topic-per-task (verified live 2026-07-09) + +In a Topics-enabled supergroup (state chat has `"is_forum": true`), a task launched outside any topic gets its own forum topic. + +- Inject: `message` in the forum chat with a bot mention and NO `message_thread_id` (i.e. from General). +- Expect (after routing confirmation resolves): `createForumTopic` named after the request appears in state `.forumTopics`; the started card (Follow/Cancel buttons) posts **inside** the topic (`message_thread_id` set, unanchored); a pointer reply "Started in its own topic: …" with an `Open topic` deep-link button lands next to the launch message; the job payload carries `communicationThreadId` = the topic id. +- Follow-ups typed in the topic queue to the job; worker replies land in the topic (they re-anchor to the in-topic started card). Cancel works from the in-topic card. +- Variants: launch from **inside** an existing topic (`message_thread_id` set) must NOT create a nested topic — the task stays in that topic. Topic-creation failure (chat not a forum, missing manage-topics right) falls back to launching in the main chat. +- Assert: state `.forumTopics`, `.messages[].message_thread_id`; DB `communicationThreadId`. +- Note: the harness enforces forum semantics — `createForumTopic` on a non-forum chat 400s, and posting a `message_thread_id` that references no topic in a forum chat 400s ("message thread not found"). + +## 6b. forum-topic-isolation Forum supergroups scope conversations per topic via `message_thread_id`. -- Inject: task entry with `message_thread_id: 7`; then a follow-up with `message_thread_id: 8`. +- Inject: task entry with `message_thread_id: 7` (create the topic first, or seed `.forumTopics`); then a follow-up with `message_thread_id: 8`. - Expect: the topic-8 message does **not** queue to the topic-7 job (thread id is part of the active-job key) — it is its own task entry. - Assert: DB `communicationThreadId` on each job; bot replies carry the matching `message_thread_id` in state. diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 73ebb3e24..fe130fd38 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -8,6 +8,7 @@ const { buildTelegramRoutingContextMock, cloudJobsFindFirstMock, consumeLinkCodeMock, + createForumTopicMock, restoreLinkCodeMock, editMessageReplyMarkupMock, editMessageTextMock, @@ -39,6 +40,7 @@ const { buildTelegramRoutingContextMock: vi.fn(), cloudJobsFindFirstMock: vi.fn(), consumeLinkCodeMock: vi.fn(), + createForumTopicMock: vi.fn(), restoreLinkCodeMock: vi.fn(), editMessageReplyMarkupMock: vi.fn(), editMessageTextMock: vi.fn(), @@ -191,6 +193,7 @@ vi.mock('@roomote/communication/telegram-provider', () => ({ return { addReaction: addReactionMock, answerCallbackQuery: answerCallbackQueryMock, + createForumTopic: createForumTopicMock, editMessageReplyMarkup: editMessageReplyMarkupMock, editMessageText: editMessageTextMock, postMessage: postMessageMock, @@ -292,6 +295,10 @@ describe('Telegram webhook handler', () => { environmentsFindFirstMock.mockResolvedValue(undefined); getAvailableEnvironmentsMock.mockResolvedValue([]); editMessageTextMock.mockResolvedValue(undefined); + createForumTopicMock.mockResolvedValue({ + threadId: '777', + name: 'fix the flaky login test', + }); setLatestInboundMessageIdMock.mockResolvedValue(undefined); authUsersFindFirstMock.mockResolvedValue(null); usersFindFirstMock.mockResolvedValue(null); @@ -1851,6 +1858,154 @@ describe('Telegram webhook handler', () => { ); }); + it('creates a forum topic per task when launching from a Topics-enabled supergroup', async () => { + mockTelegramLinkedSender('launch-owner-30'); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '@roomote_bot fix the flaky login test', + chat: { + id: -100333000333, + type: 'supergroup', + title: 'roomote-dev', + is_forum: true, + }, + entities: [{ type: 'mention', offset: 0, length: 12 }], + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + started: true, + cloudJobId: 88, + }); + expect(createForumTopicMock).toHaveBeenCalledWith({ + channelId: '-100333000333', + name: 'fix the flaky login test', + }); + // The job is thread-scoped to the new topic, so follow-ups typed in the + // topic queue to it and worker replies land there. + expect(enqueueCloudTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + communicationChannelId: '-100333000333', + communicationThreadId: '777', + }), + }), + { launchClass: 'human' }, + ); + // Started card goes into the topic, unanchored (replies cannot cross topics). + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '-100333000333', + threadId: '777', + text: 'Started a task in all repos.', + }), + ); + const startedCall = postMessageMock.mock.calls.find( + (call) => call[0]?.text === 'Started a task in all repos.', + ); + expect(startedCall?.[0]?.replyToMessageId).toBeUndefined(); + // Worker replies anchor to the in-topic started card, not the launch message. + expect(setLatestInboundMessageIdMock).toHaveBeenCalledWith( + 'telegram', + 88, + 'telegram-response', + ); + // A pointer stays where the user asked, deep-linking to the topic. + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '-100333000333', + replyToMessageId: '456', + text: expect.stringContaining('Started in its own topic'), + buttons: [ + [ + expect.objectContaining({ + text: 'Open topic', + url: 'https://t.me/c/333000333/777', + }), + ], + ], + }), + ); + }); + + it('keeps a task in the topic it was requested from instead of nesting a new one', async () => { + mockTelegramLinkedSender('launch-owner-31'); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '@roomote_bot fix the flaky login test', + message_thread_id: 55, + chat: { + id: -100333000333, + type: 'supergroup', + title: 'roomote-dev', + is_forum: true, + }, + entities: [{ type: 'mention', offset: 0, length: 12 }], + }, + }), + ); + + expect(response.status).toBe(200); + expect(createForumTopicMock).not.toHaveBeenCalled(); + expect(enqueueCloudTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + communicationThreadId: '55', + }), + }), + { launchClass: 'human' }, + ); + }); + + it('falls back to launching in the main chat when topic creation fails', async () => { + mockTelegramLinkedSender('launch-owner-32'); + createForumTopicMock.mockRejectedValueOnce( + new Error('not enough rights to manage topics'), + ); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '@roomote_bot fix the flaky login test', + chat: { + id: -100333000333, + type: 'supergroup', + title: 'roomote-dev', + is_forum: true, + }, + entities: [{ type: 'mention', offset: 0, length: 12 }], + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + started: true, + cloudJobId: 88, + }); + expect(enqueueCloudTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.not.objectContaining({ + communicationThreadId: expect.anything(), + }), + }), + { launchClass: 'human' }, + ); + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: '-100333000333', + replyToMessageId: '456', + text: 'Started a task in all repos.', + }), + ); + }); + it('dismisses the confirmation without launching when Nevermind is clicked', async () => { mockTelegramLinkedSender('launch-owner-25'); const pending = JSON.stringify({ diff --git a/apps/api/src/handlers/telegram/replies.ts b/apps/api/src/handlers/telegram/replies.ts index 2d0cea36d..22ff98d65 100644 --- a/apps/api/src/handlers/telegram/replies.ts +++ b/apps/api/src/handlers/telegram/replies.ts @@ -87,6 +87,36 @@ export async function editTelegramMessageBestEffort(input: { } } +/** + * Create a forum topic for a task in a Topics-enabled supergroup. Best-effort: + * plain groups, missing manage-topics rights, or API errors return null and + * the caller falls back to launching in the main chat. + */ +export async function createTelegramForumTopicBestEffort(input: { + chatId: string; + name: string; +}): Promise<{ threadId: string; name: string } | null> { + const provider = await createTelegramCommunicationProvider(); + + if (!provider) { + return null; + } + + try { + return await provider.createForumTopic({ + channelId: input.chatId, + name: input.name, + }); + } catch (error) { + apiLogger.warn( + `[telegram] Failed to create a task forum topic in chat ${input.chatId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } +} + /** Answer a callback query so the clicked button stops showing a spinner. */ export async function answerTelegramCallbackQueryBestEffort(input: { callbackQueryId: string; diff --git a/apps/api/src/handlers/telegram/routing-confirmation.ts b/apps/api/src/handlers/telegram/routing-confirmation.ts index bb476c615..311fa72b6 100644 --- a/apps/api/src/handlers/telegram/routing-confirmation.ts +++ b/apps/api/src/handlers/telegram/routing-confirmation.ts @@ -71,6 +71,8 @@ type PendingTelegramRoute = { * in picker mode (after Nope, or on router fallback) — never auto-starts. */ suggestedIndex: number | null; + /** Topics-enabled supergroup: launches may create a per-task topic. */ + chatIsForum?: boolean; confirmMessageId?: string; }; @@ -342,6 +344,7 @@ async function autoConfirmTelegramRouting( queuedMessage: pending.queuedMessage, metadata: pending.metadata, workspace, + chatIsForum: pending.chatIsForum ?? false, }); } @@ -363,6 +366,7 @@ export async function maybeRequestTelegramRoutingConfirmation(input: { launchOwnerUserId: string; queuedMessage: QueuedTelegramCommunicationMessage; metadata: TelegramUpdateCommunicationMetadata; + chatIsForum?: boolean; }): Promise<{ pendingRouteId: string } | null> { const routed = input.routingDecision.status === 'routed' @@ -395,6 +399,7 @@ export async function maybeRequestTelegramRoutingConfirmation(input: { metadata: input.metadata, options, suggestedIndex, + ...(input.chatIsForum ? { chatIsForum: true } : {}), }; await invalidatePreviousPendingRoute(input.metadata); @@ -625,6 +630,7 @@ export async function handleTelegramRoutingCallback(params: { queuedMessage: claimed.queuedMessage, metadata: claimed.metadata, workspace, + chatIsForum: claimed.chatIsForum ?? false, }); } catch (error) { apiLogger.warn( diff --git a/apps/api/src/handlers/telegram/task-launch.ts b/apps/api/src/handlers/telegram/task-launch.ts index bbca2d599..fe22d681f 100644 --- a/apps/api/src/handlers/telegram/task-launch.ts +++ b/apps/api/src/handlers/telegram/task-launch.ts @@ -1,4 +1,5 @@ import type { TelegramUpdateCommunicationMetadata } from '@roomote/communication/telegram-update'; +import { setLatestInboundMessageId } from '@roomote/communication/messages'; import { ALL_REPOSITORIES, CloudTaskType, @@ -11,13 +12,20 @@ import { type RoutingWorkspace, } from '@roomote/cloud-agents/server'; +import { apiLogger } from '../../logging.js'; import { buildTelegramCancelTaskCallbackData } from './callback-data.js'; -import { postTelegramMessageBestEffort } from './replies.js'; +import { + createTelegramForumTopicBestEffort, + postTelegramMessageBestEffort, +} from './replies.js'; import type { QueuedTelegramCommunicationMessage, TelegramWorkspaceSelection, } from './types.js'; +/** Telegram caps forum topic names at 128 chars; stay well under it. */ +const MAX_TOPIC_NAME_LENGTH = 64; + export async function resolveTelegramWorkspace( workspace: RoutingWorkspace, ): Promise { @@ -53,18 +61,67 @@ export async function resolveTelegramWorkspace( }; } +function buildTelegramTaskTopicName(taskText: string): string { + const firstLine = + taskText + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .find((line) => line.length > 0) ?? ''; + const name = firstLine || 'Roomote task'; + + return name.length > MAX_TOPIC_NAME_LENGTH + ? `${name.slice(0, MAX_TOPIC_NAME_LENGTH - 1)}…` + : name; +} + +/** + * Deep link to a forum topic. Only derivable for supergroups (ids prefixed + * `-100`); members of the group can open it even when the group is private. + */ +function buildTelegramTopicUrl( + chatId: string, + topicThreadId: string, +): string | null { + return chatId.startsWith('-100') + ? `https://t.me/c/${chatId.slice(4)}/${topicThreadId}` + : null; +} + /** * Enqueue a standard cloud task for a Telegram request and post the * task-started message (with follow/cancel buttons) back to the chat. Shared * by the immediate launch path, the routing-confirmation buttons, and the * confirmation auto-start timer. + * + * In Topics-enabled supergroups, a task launched outside any topic gets its + * own forum topic: the job's thread id points at the new topic, so the + * started card, worker replies, and follow-ups all live there — Telegram's + * native equivalent of a Slack thread. Launches from inside an existing + * topic stay in that topic, and topic-creation failure (bot lacking the + * manage-topics right, plain groups) falls back to the current in-chat + * behavior rather than dropping the task. */ export async function launchTelegramTask(input: { launchOwnerUserId: string; queuedMessage: QueuedTelegramCommunicationMessage; metadata: TelegramUpdateCommunicationMetadata; workspace: TelegramWorkspaceSelection; + chatIsForum?: boolean; }) { + let metadata = input.metadata; + let taskTopic: { threadId: string; name: string } | null = null; + + if (input.chatIsForum && !metadata.communicationThreadId) { + taskTopic = await createTelegramForumTopicBestEffort({ + chatId: metadata.communicationChannelId, + name: buildTelegramTaskTopicName(input.queuedMessage.text), + }); + + if (taskTopic) { + metadata = { ...metadata, communicationThreadId: taskTopic.threadId }; + } + } + const task: Extract = { type: CloudTaskType.StandardTask, userId: input.launchOwnerUserId, @@ -74,7 +131,7 @@ export async function launchTelegramTask(input: { ? { environmentId: input.workspace.environmentId } : {}), description: input.queuedMessage.text, - ...input.metadata, + ...metadata, }, }; const launchResult = await enqueueCloudTask(task, { @@ -86,10 +143,12 @@ export async function launchTelegramTask(input: { utm: { source: 'telegram', campaign: 'telegram.thread_start' }, }); - await postTelegramMessageBestEffort({ - chatId: input.metadata.communicationChannelId, - threadId: input.metadata.communicationThreadId, - replyToMessageId: input.metadata.communicationMessageId, + const startedMessage = await postTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + threadId: metadata.communicationThreadId, + // A reply anchor cannot cross topics, so a task that just got its own + // topic posts the card unanchored there. + replyToMessageId: taskTopic ? undefined : metadata.communicationMessageId, text: taskUrl ? `Started a task in ${input.workspace.workspaceDisplayName}.` : `Queued a task in ${input.workspace.workspaceDisplayName}.`, @@ -104,5 +163,39 @@ export async function launchTelegramTask(input: { ], }); + if (taskTopic) { + // Worker replies quote the latest inbound message; the launch message + // lives outside the topic, so re-anchor to the in-topic started card. + if (startedMessage) { + try { + await setLatestInboundMessageId( + 'telegram', + launchResult.id, + startedMessage.messageId, + ); + } catch (error) { + apiLogger.warn( + `[telegram] Failed to anchor replies to the task topic for cloud job ${launchResult.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + // Leave a pointer where the user asked so the topic is discoverable. + const topicUrl = buildTelegramTopicUrl( + metadata.communicationChannelId, + taskTopic.threadId, + ); + await postTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + replyToMessageId: input.metadata.communicationMessageId, + text: `Started in its own topic: “${taskTopic.name}”`, + ...(topicUrl + ? { buttons: [[{ text: 'Open topic', url: topicUrl }]] } + : {}), + }); + } + return launchResult; } diff --git a/apps/api/src/handlers/telegram/task-orchestration.ts b/apps/api/src/handlers/telegram/task-orchestration.ts index 67e424ece..98a4e49a5 100644 --- a/apps/api/src/handlers/telegram/task-orchestration.ts +++ b/apps/api/src/handlers/telegram/task-orchestration.ts @@ -148,12 +148,15 @@ export async function startNewTelegramTask(input: { }; } + const chatIsForum = input.message.chat.is_forum === true; + if (!input.skipRoutingConfirmation) { const confirmation = await maybeRequestTelegramRoutingConfirmation({ routingDecision, launchOwnerUserId: input.launchOwnerUserId, queuedMessage: input.queuedMessage, metadata: input.metadata, + chatIsForum, }); if (confirmation) { @@ -182,6 +185,7 @@ export async function startNewTelegramTask(input: { queuedMessage: input.queuedMessage, metadata: input.metadata, workspace, + chatIsForum, }); return { diff --git a/packages/communication/src/__tests__/mock-telegram-server.test.ts b/packages/communication/src/__tests__/mock-telegram-server.test.ts index bdcfae1f8..672521aa1 100644 --- a/packages/communication/src/__tests__/mock-telegram-server.test.ts +++ b/packages/communication/src/__tests__/mock-telegram-server.test.ts @@ -290,6 +290,68 @@ describe('MockTelegramServer', () => { ).rejects.toThrow('message to edit not found'); }); + it('creates forum topics and scopes posts to them', async () => { + const state = baseState(); + state.chats = [ + ...state.chats.filter((chat) => chat.id !== -100222000222), + { + id: -100222000222, + type: 'supergroup', + title: 'roomote-dev', + is_forum: true, + }, + ]; + const { server, baseUrl } = await startServer(state); + onCleanup(() => server.stop()); + + const provider = providerFor(baseUrl); + const topic = await provider.createForumTopic({ + channelId: '-100222000222', + name: 'Fix the flaky login test', + }); + + expect(topic.name).toBe('Fix the flaky login test'); + expect(server.getState().forumTopics).toEqual([ + { + chat_id: '-100222000222', + message_thread_id: Number(topic.threadId), + name: 'Fix the flaky login test', + }, + ]); + + const posted = await provider.postMessage({ + channelId: '-100222000222', + threadId: topic.threadId, + text: 'Started a task in web-app.', + }); + const message = (server.getState().messages ?? []).find( + (m) => String(m.message_id) === posted.messageId, + ); + expect(message?.message_thread_id).toBe(Number(topic.threadId)); + + // Posting into a topic that was never created fails like real Telegram. + await expect( + provider.postMessage({ + channelId: '-100222000222', + threadId: '424242', + text: 'lost message', + }), + ).rejects.toThrow('message thread not found'); + }); + + it('rejects createForumTopic outside forum supergroups', async () => { + const { server, baseUrl } = await startServer(); + onCleanup(() => server.stop()); + + const provider = providerFor(baseUrl); + await expect( + provider.createForumTopic({ + channelId: '-100222000222', + name: 'nope', + }), + ).rejects.toThrow('not a forum'); + }); + it('records typing chat actions through sendChatAction', async () => { const { server, baseUrl } = await startServer(); onCleanup(() => server.stop()); diff --git a/packages/communication/src/mock-telegram-server.ts b/packages/communication/src/mock-telegram-server.ts index 6eaa441da..c32bbb19e 100644 --- a/packages/communication/src/mock-telegram-server.ts +++ b/packages/communication/src/mock-telegram-server.ts @@ -66,6 +66,12 @@ export type MockTelegramChatAction = { message_thread_id?: number; }; +export type MockTelegramForumTopic = { + chat_id: string; + message_thread_id: number; + name: string; +}; + /** * Failure-injection knobs. Real Telegram rejects whole requests for these * cases; the flags let tests exercise the provider's fallback paths. @@ -91,6 +97,7 @@ export type MockTelegramState = { webhook?: MockTelegramWebhookRegistration; callbackAnswers?: MockTelegramCallbackAnswer[]; chatActions?: MockTelegramChatAction[]; + forumTopics?: MockTelegramForumTopic[]; behavior?: MockTelegramBehavior; }; @@ -172,6 +179,7 @@ function normalizeState(state: MockTelegramState): MockTelegramState { })), callbackAnswers: [...(state.callbackAnswers ?? [])], chatActions: [...(state.chatActions ?? [])], + forumTopics: [...(state.forumTopics ?? [])], }; } @@ -765,6 +773,46 @@ export class MockTelegramServer { return; } + case 'createForumTopic': { + const chat = this.findChat(body.chat_id); + + if (!chat) { + apiError(response, 400, 'Bad Request: chat not found'); + return; + } + + if (chat.is_forum !== true) { + apiError( + response, + 400, + 'Bad Request: the chat is not a forum supergroup chat', + ); + return; + } + + const name = String(body.name ?? '').trim(); + + if (!name || name.length > 128) { + apiError(response, 400, 'Bad Request: TOPIC_TITLE_INVALID'); + return; + } + + // Real Telegram allocates the topic id from the message-id space + // (it is the id of the topic's service message). + const topic: MockTelegramForumTopic = { + chat_id: String(chat.id), + message_thread_id: this.nextMessageId(), + name, + }; + this.state.forumTopics = [...(this.state.forumTopics ?? []), topic]; + apiResult(response, { + message_thread_id: topic.message_thread_id, + name: topic.name, + icon_color: 7322096, + }); + return; + } + case 'setWebhook': { this.state.webhook = { url: String(body.url ?? ''), @@ -836,6 +884,22 @@ export class MockTelegramServer { return undefined; } + // In forum chats a message_thread_id must reference an existing topic — + // real Telegram rejects unknown threads, so posting into a topic that + // was never created fails loudly here too. + if ( + chat.is_forum === true && + typeof body.message_thread_id === 'number' && + !(this.state.forumTopics ?? []).some( + (topic) => + topic.chat_id === String(chat.id) && + topic.message_thread_id === body.message_thread_id, + ) + ) { + apiError(response, 400, 'Bad Request: message thread not found'); + return undefined; + } + const replyParameters = body.reply_parameters as JsonRecord | undefined; let replyToMessageId: number | undefined; diff --git a/packages/communication/src/telegram-provider.ts b/packages/communication/src/telegram-provider.ts index 7009c170b..25ab05726 100644 --- a/packages/communication/src/telegram-provider.ts +++ b/packages/communication/src/telegram-provider.ts @@ -404,6 +404,32 @@ export class TelegramCommunicationProvider implements CommunicationProviderAdapt }); } + /** + * Create a forum topic in a Topics-enabled supergroup. Requires the bot to + * be an admin with the manage-topics right. The returned thread id is what + * `message_thread_id` / `threadId` refer to when posting into the topic. + */ + async createForumTopic(input: { + channelId: string; + name: string; + }): Promise<{ threadId: string; name: string }> { + const result = (await this.callBotApi('createForumTopic', { + chat_id: input.channelId, + name: input.name, + })) as { message_thread_id?: number; name?: string }; + + if (typeof result.message_thread_id !== 'number') { + throw new Error( + 'Telegram createForumTopic returned no message_thread_id.', + ); + } + + return { + threadId: String(result.message_thread_id), + name: result.name ?? input.name, + }; + } + /** Delete a message the bot sent (Bot API allows this within 48 hours). */ async deleteMessage(input: { channelId: string; diff --git a/packages/communication/src/telegram-update.ts b/packages/communication/src/telegram-update.ts index 5d5aa2d0b..598449ce7 100644 --- a/packages/communication/src/telegram-update.ts +++ b/packages/communication/src/telegram-update.ts @@ -20,6 +20,8 @@ const telegramChatSchema = z username: z.string().optional(), first_name: z.string().optional(), last_name: z.string().optional(), + /** Topics-enabled supergroup — tasks can get a forum topic each. */ + is_forum: z.boolean().optional(), }) .passthrough();