fix(telegram): support forum topics

Co-authored-by: Daniel Griesser <HazAT@users.noreply.github.com>
Co-authored-by: Nacho Iacovino <nachoiacovino@users.noreply.github.com>
Co-authored-by: Randy Ventures <RandyVentures@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-07 02:10:56 +00:00
parent 023a124312
commit 80112433a5
8 changed files with 182 additions and 14 deletions

View File

@@ -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.

View File

@@ -9,6 +9,7 @@ Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Di
## Session keys
- Group sessions use `agent:<agentId>:<provider>:group:<id>` session keys (rooms/channels use `agent:<agentId>:<provider>:channel:<id>`).
- Telegram forum topics add `:topic:<threadId>` 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.

View File

@@ -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:<agentId>:<mainKey>` (default `main`). Groups/channels stay isolated per agent:
- group: `agent:<agentId>:<provider>:group:<id>`
- channel/room: `agent:<agentId>:<provider>:channel:<id>`
- Telegram forum topics: `agent:<agentId>:telegram:group:<chatId>:topic:<threadId>`
- **Session store:** per-agent store lives under `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (override via `session.store` with `{agentId}` templating). JSONL transcripts live next to it.
- **WebChat:** attaches to the selected agents main session (so desktop reflects cross-provider history for that agent).
- **Implementation hints:**

View File

@@ -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:<agentId>:<mainKey>`.
- 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:<agentId>:<provider>:group:<id>` (rooms/channels use `agent:<agentId>:<provider>:channel:<id>`).
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
- Legacy `group:<id>` keys are still recognized for migration.
- Other sources:
- Cron jobs: `cron:<job.id>`

View File

@@ -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:<threadId>` 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:

View File

@@ -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 & {

View File

@@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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 }),
);
});
});

View File

@@ -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<number | undefined> {
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;
}