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
- 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)
- 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)

View File

@@ -211,6 +211,11 @@ describe("createTelegramBot", () => {
message: { chat: { id: 123 }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: { chat: { id: 123, is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(
getTelegramSequentialKey({
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 () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<

View File

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