Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .agent-guidance/features/telegram-integration.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions .agents/skills/mock-telegram-testing/references/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
155 changes: 155 additions & 0 deletions apps/api/src/handlers/telegram/__tests__/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions apps/api/src/handlers/telegram/replies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/handlers/telegram/routing-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -342,6 +344,7 @@ async function autoConfirmTelegramRouting(
queuedMessage: pending.queuedMessage,
metadata: pending.metadata,
workspace,
chatIsForum: pending.chatIsForum ?? false,
});
}

Expand All @@ -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'
Expand Down Expand Up @@ -395,6 +399,7 @@ export async function maybeRequestTelegramRoutingConfirmation(input: {
metadata: input.metadata,
options,
suggestedIndex,
...(input.chatIsForum ? { chatIsForum: true } : {}),
};

await invalidatePreviousPendingRoute(input.metadata);
Expand Down Expand Up @@ -625,6 +630,7 @@ export async function handleTelegramRoutingCallback(params: {
queuedMessage: claimed.queuedMessage,
metadata: claimed.metadata,
workspace,
chatIsForum: claimed.chatIsForum ?? false,
});
} catch (error) {
apiLogger.warn(
Expand Down
Loading