From 9d9630c83a41adb82632e13d5349fff3330a3e29 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 17 Feb 2026 13:03:20 +0530 Subject: [PATCH] fix: preserve telegram dm topic thread ids --- src/telegram/send.test.ts | 59 ++++++++++++++++++++++++++++++++++++--- src/telegram/send.ts | 38 +++++++++++-------------- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 6c710925e14..6a7f972f695 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -850,8 +850,7 @@ describe("sendMessageTelegram", () => { }); }); - it("suppresses message_thread_id for private chat sends (#17242)", async () => { - // Private chats have positive numeric IDs; they never support forum topics. + it("keeps message_thread_id for private chat topic sends (#18974)", async () => { const chatId = "123456789"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 56, @@ -867,10 +866,9 @@ describe("sendMessageTelegram", () => { messageThreadId: 271, }); - // message_thread_id must NOT appear in private chats -- Telegram rejects it - // with "400: Bad Request: message thread not found". expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", { parse_mode: "HTML", + message_thread_id: 271, }); }); @@ -927,6 +925,36 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("58"); }); + it("retries private chat sends without message_thread_id on thread-not-found", async () => { + const chatId = "123456789"; + const threadErr = new Error("400: Bad Request: message thread not found"); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(threadErr) + .mockResolvedValueOnce({ + message_id: 59, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + const res = await sendMessageTelegram(chatId, "hello private", { + token: "tok", + api, + messageThreadId: 271, + }); + + expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello private", { + parse_mode: "HTML", + message_thread_id: 271, + }); + expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello private", { + parse_mode: "HTML", + }); + expect(res.messageId).toBe("59"); + }); + it("does not retry thread-not-found when no message_thread_id was provided", async () => { const chatId = "123"; const threadErr = new Error("400: Bad Request: message thread not found"); @@ -944,6 +972,29 @@ describe("sendMessageTelegram", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("does not retry without message_thread_id on chat-not-found", async () => { + const chatId = "123456789"; + const chatErr = new Error("400: Bad Request: chat not found"); + const sendMessage = vi.fn().mockRejectedValueOnce(chatErr); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await expect( + sendMessageTelegram(chatId, "hello private", { + token: "tok", + api, + messageThreadId: 271, + }), + ).rejects.toThrow(/chat not found/i); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", { + parse_mode: "HTML", + message_thread_id: 271, + }); + }); + it("sets disable_notification when silent is true", async () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 23e841f1a68..f4ae4de24bb 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -198,17 +198,6 @@ function isTelegramMessageNotModifiedError(err: unknown): boolean { return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err)); } -/** - * Telegram private chats have positive numeric IDs. - * Groups and supergroups have negative IDs (typically -100… for supergroups). - * Private chats never support forum topics, so `message_thread_id` must - * not be included in API calls targeting them (#17242). - */ -function isTelegramPrivateChat(chatId: string): boolean { - const n = Number(chatId); - return Number.isFinite(n) && n > 0; -} - function hasMessageThreadIdParam(params?: Record): boolean { if (!params) { return false; @@ -241,13 +230,18 @@ function isTelegramHtmlParseError(err: unknown): boolean { function buildTelegramThreadReplyParams(params: { targetMessageThreadId?: number; messageThreadId?: number; + chatType?: "direct" | "group" | "unknown"; replyToMessageId?: number; quoteText?: string; }): Record { const messageThreadId = params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId; + const threadScope = params.chatType === "direct" ? ("dm" as const) : ("forum" as const); + // Never blanket-strip DM message_thread_id by chat-id sign. + // Telegram supports DM topics; stripping silently misroutes topic replies. + // Keep thread id and rely on thread-not-found retry fallback for plain DMs. const threadSpec = - messageThreadId != null ? { id: messageThreadId, scope: "forum" as const } : undefined; + messageThreadId != null ? { id: messageThreadId, scope: threadScope } : undefined; const threadIdParams = buildTelegramThreadParams(threadSpec); const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; @@ -378,6 +372,8 @@ async function withTelegramThreadFallback( try { return await attempt(params, label); } catch (err) { + // AIDEV-NOTE: Do not widen this fallback to cover "chat not found". + // chat-not-found is routing/auth/membership/token; stripping thread IDs hides root cause. if (!hasMessageThreadIdParam(params) || !isTelegramThreadNotFoundError(err)) { throw err; } @@ -441,10 +437,10 @@ export async function sendMessageTelegram( const mediaUrl = opts.mediaUrl?.trim(); const replyMarkup = buildInlineKeyboard(opts.buttons); - const isPrivate = isTelegramPrivateChat(chatId); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: isPrivate ? undefined : target.messageThreadId, - messageThreadId: isPrivate ? undefined : opts.messageThreadId, + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, replyToMessageId: opts.replyToMessageId, quoteText: opts.quoteText, }); @@ -933,10 +929,10 @@ export async function sendStickerTelegram( const target = parseTelegramTarget(to); const chatId = normalizeChatId(target.chatId); - const isPrivate = isTelegramPrivateChat(chatId); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: isPrivate ? undefined : target.messageThreadId, - messageThreadId: isPrivate ? undefined : opts.messageThreadId, + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, replyToMessageId: opts.replyToMessageId, }); const hasThreadParams = Object.keys(threadParams).length > 0; @@ -1012,10 +1008,10 @@ export async function sendPollTelegram( // Normalize the poll input (validates question, options, maxSelections) const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); - const isPrivate = isTelegramPrivateChat(chatId); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: isPrivate ? undefined : target.messageThreadId, - messageThreadId: isPrivate ? undefined : opts.messageThreadId, + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, replyToMessageId: opts.replyToMessageId, });