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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 agent’s main session (so desktop reflects cross-provider history for that agent).
|
||||
- **Implementation hints:**
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user