Telegram: keep forum topic thread ids in replies

Closes #727
This commit is contained in:
Shadow
2026-01-12 22:06:03 -06:00
parent 50260fd385
commit c9fdd68232
3 changed files with 100 additions and 28 deletions

View File

@@ -8,6 +8,7 @@
### Fixes ### Fixes
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight) - Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow) - Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow) - Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow) - System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)

View File

@@ -211,6 +211,11 @@ describe("createTelegramBot", () => {
message: { chat: { id: 123 }, message_thread_id: 9 }, message: { chat: { id: 123 }, message_thread_id: 9 },
}), }),
).toBe("telegram:123:topic:9"); ).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect( expect(
getTelegramSequentialKey({ getTelegramSequentialKey({
update: { message: { chat: { id: 555 } } }, update: { message: { chat: { id: 555 } } },
@@ -1766,6 +1771,51 @@ describe("createTelegramBot", () => {
}); });
}); });
it("routes General topic replies using thread id 1", 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: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") 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,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledWith(
"-1001234567890",
expect.any(String),
expect.objectContaining({ message_thread_id: 1 }),
);
});
it("applies topic skill filters and system prompts", async () => { it("applies topic skill filters and system prompts", async () => {
onSpy.mockReset(); onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType< const replySpy = replyModule.__replySpy as unknown as ReturnType<

View File

@@ -196,7 +196,11 @@ export function getTelegramSequentialKey(ctx: {
ctx.update?.edited_message ?? ctx.update?.edited_message ??
ctx.update?.callback_query?.message; ctx.update?.callback_query?.message;
const chatId = msg?.chat?.id ?? ctx.chat?.id; const chatId = msg?.chat?.id ?? ctx.chat?.id;
const threadId = msg?.message_thread_id; const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
const threadId = resolveTelegramForumThreadId({
isForum,
messageThreadId: msg?.message_thread_id,
});
if (typeof chatId === "number") { if (typeof chatId === "number") {
return threadId != null return threadId != null
? `telegram:${chatId}:topic:${threadId}` ? `telegram:${chatId}:topic:${threadId}`
@@ -205,6 +209,16 @@ export function getTelegramSequentialKey(ctx: {
return "telegram:unknown"; return "telegram:unknown";
} }
function resolveTelegramForumThreadId(params: {
isForum?: boolean;
messageThreadId?: number | null;
}) {
if (params.isForum && params.messageThreadId == null) {
return TELEGRAM_GENERAL_TOPIC_ID;
}
return params.messageThreadId ?? undefined;
}
export function createTelegramBot(opts: TelegramBotOptions) { export function createTelegramBot(opts: TelegramBotOptions) {
const runtime: RuntimeEnv = opts.runtime ?? { const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log, log: console.log,
@@ -423,12 +437,16 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const messageThreadId = (msg as { message_thread_id?: number }) const messageThreadId = (msg as { message_thread_id?: number })
.message_thread_id; .message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const { groupConfig, topicConfig } = resolveTelegramGroupConfig( const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId, chatId,
messageThreadId, resolvedThreadId,
); );
const peerId = isGroup const peerId = isGroup
? buildTelegramGroupPeerId(chatId, messageThreadId) ? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId); : String(chatId);
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
@@ -460,23 +478,17 @@ export function createTelegramBot(opts: TelegramBotOptions) {
} }
if (isGroup && topicConfig?.enabled === false) { if (isGroup && topicConfig?.enabled === false) {
logVerbose( logVerbose(
`Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
); );
return; return;
} }
const sendTyping = async () => { const sendTyping = async () => {
try { try {
// In forums, the General topic has no message_thread_id in updates,
// but sendChatAction requires one to show typing.
const typingThreadId =
isForum && messageThreadId == null
? TELEGRAM_GENERAL_TOPIC_ID
: messageThreadId;
await bot.api.sendChatAction( await bot.api.sendChatAction(
chatId, chatId,
"typing", "typing",
buildTelegramThreadParams(typingThreadId), buildTelegramThreadParams(resolvedThreadId),
); );
} catch (err) { } catch (err) {
logVerbose( logVerbose(
@@ -588,7 +600,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
); );
const activationOverride = resolveGroupActivation({ const activationOverride = resolveGroupActivation({
chatId, chatId,
messageThreadId, messageThreadId: resolvedThreadId,
sessionKey: route.sessionKey, sessionKey: route.sessionKey,
agentId: route.agentId, agentId: route.agentId,
}); });
@@ -684,19 +696,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}]\n${replyTarget.body}\n[/Replying]` }]\n${replyTarget.body}\n[/Replying]`
: ""; : "";
const groupLabel = isGroup const groupLabel = isGroup
? buildGroupLabel(msg, chatId, messageThreadId) ? buildGroupLabel(msg, chatId, resolvedThreadId)
: undefined; : undefined;
const body = formatAgentEnvelope({ const body = formatAgentEnvelope({
provider: "Telegram", provider: "Telegram",
from: isGroup from: isGroup
? buildGroupFromLabel(msg, chatId, senderId, messageThreadId) ? buildGroupFromLabel(msg, chatId, senderId, resolvedThreadId)
: buildSenderLabel(msg, senderId || chatId), : buildSenderLabel(msg, senderId || chatId),
timestamp: msg.date ? msg.date * 1000 : undefined, timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${bodyText}${replySuffix}`, body: `${bodyText}${replySuffix}`,
}); });
let combinedBody = body; let combinedBody = body;
const historyKey = isGroup const historyKey = isGroup
? buildTelegramGroupPeerId(chatId, messageThreadId) ? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: undefined; : undefined;
if (isGroup && historyKey && historyLimit > 0) { if (isGroup && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({ combinedBody = buildHistoryContextFromMap({
@@ -736,7 +748,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
RawBody: rawBody, RawBody: rawBody,
CommandBody: commandBody, CommandBody: commandBody,
From: isGroup From: isGroup
? buildTelegramGroupFrom(chatId, messageThreadId) ? buildTelegramGroupFrom(chatId, resolvedThreadId)
: `telegram:${chatId}`, : `telegram:${chatId}`,
To: `telegram:${chatId}`, To: `telegram:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
@@ -766,7 +778,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
: undefined, : undefined,
...(locationData ? toLocationContext(locationData) : undefined), ...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
MessageThreadId: messageThreadId, MessageThreadId: resolvedThreadId,
IsForum: isForum, IsForum: isForum,
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "telegram" as const, OriginatingChannel: "telegram" as const,
@@ -799,7 +811,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaInfo = const mediaInfo =
allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
const topicInfo = const topicInfo =
messageThreadId != null ? ` topic=${messageThreadId}` : ""; resolvedThreadId != null ? ` topic=${resolvedThreadId}` : "";
logVerbose( logVerbose(
`telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`, `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`,
); );
@@ -810,7 +822,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const canStreamDraft = const canStreamDraft =
streamMode !== "off" && streamMode !== "off" &&
isPrivateChat && isPrivateChat &&
typeof messageThreadId === "number" && typeof resolvedThreadId === "number" &&
(await resolveBotTopicsEnabled(primaryCtx)); (await resolveBotTopicsEnabled(primaryCtx));
const draftStream = canStreamDraft const draftStream = canStreamDraft
? createTelegramDraftStream({ ? createTelegramDraftStream({
@@ -818,7 +830,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
chatId, chatId,
draftId: msg.message_id || Date.now(), draftId: msg.message_id || Date.now(),
maxChars: draftMaxChars, maxChars: draftMaxChars,
messageThreadId, messageThreadId: resolvedThreadId,
log: logVerbose, log: logVerbose,
warn: logVerbose, warn: logVerbose,
}) })
@@ -905,7 +917,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
bot, bot,
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId, messageThreadId: resolvedThreadId,
}); });
didSendReply = true; didSendReply = true;
}, },
@@ -999,12 +1011,16 @@ export function createTelegramBot(opts: TelegramBotOptions) {
.message_thread_id; .message_thread_id;
const isForum = const isForum =
(msg.chat as { is_forum?: boolean }).is_forum === true; (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch( const storeAllowFrom = await readTelegramAllowFromStore().catch(
() => [], () => [],
); );
const { groupConfig, topicConfig } = resolveTelegramGroupConfig( const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId, chatId,
messageThreadId, resolvedThreadId,
); );
const groupAllowOverride = firstDefined( const groupAllowOverride = firstDefined(
topicConfig?.allowFrom, topicConfig?.allowFrom,
@@ -1116,7 +1132,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
peer: { peer: {
kind: isGroup ? "group" : "dm", kind: isGroup ? "group" : "dm",
id: isGroup id: isGroup
? buildTelegramGroupPeerId(chatId, messageThreadId) ? buildTelegramGroupPeerId(chatId, resolvedThreadId)
: String(chatId), : String(chatId),
}, },
}); });
@@ -1135,7 +1151,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const ctxPayload = { const ctxPayload = {
Body: prompt, Body: prompt,
From: isGroup From: isGroup
? buildTelegramGroupFrom(chatId, messageThreadId) ? buildTelegramGroupFrom(chatId, resolvedThreadId)
: `telegram:${chatId}`, : `telegram:${chatId}`,
To: `slash:${senderId || chatId}`, To: `slash:${senderId || chatId}`,
ChatType: isGroup ? "group" : "direct", ChatType: isGroup ? "group" : "direct",
@@ -1152,7 +1168,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
CommandSource: "native" as const, CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`, SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: route.sessionKey, CommandTargetSessionKey: route.sessionKey,
MessageThreadId: messageThreadId, MessageThreadId: resolvedThreadId,
IsForum: isForum, IsForum: isForum,
}; };
@@ -1176,7 +1192,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
bot, bot,
replyToMode, replyToMode,
textLimit, textLimit,
messageThreadId, messageThreadId: resolvedThreadId,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -1256,10 +1272,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
msg.chat.type === "group" || msg.chat.type === "supergroup"; msg.chat.type === "group" || msg.chat.type === "supergroup";
const messageThreadId = (msg as { message_thread_id?: number }) const messageThreadId = (msg as { message_thread_id?: number })
.message_thread_id; .message_thread_id;
const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
const resolvedThreadId = resolveTelegramForumThreadId({
isForum,
messageThreadId,
});
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig( const { groupConfig, topicConfig } = resolveTelegramGroupConfig(
chatId, chatId,
messageThreadId, resolvedThreadId,
); );
const groupAllowOverride = firstDefined( const groupAllowOverride = firstDefined(
topicConfig?.allowFrom, topicConfig?.allowFrom,
@@ -1278,7 +1299,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
} }
if (topicConfig?.enabled === false) { if (topicConfig?.enabled === false) {
logVerbose( logVerbose(
`Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
); );
return; return;
} }