From 6ca897e055c36d40ba9ad940d7afaa24deaf8637 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 14:21:49 +0000 Subject: [PATCH] fix(telegram): normalize chat ids and improve errors --- src/commands/agent.ts | 8 ++++- src/infra/widearea-dns.test.ts | 4 +-- src/telegram/send.test.ts | 35 +++++++++++++++++++++ src/telegram/send.ts | 57 +++++++++++++++++++++++++++++----- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/commands/agent.ts b/src/commands/agent.ts index c1382b5e8..b973fd707 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -360,7 +360,13 @@ export async function agentCommand( const telegramTarget = opts.to?.trim() || undefined; const logDeliveryError = (err: unknown) => { - const message = `Delivery failed (${deliveryProvider}): ${String(err)}`; + const deliveryTarget = + deliveryProvider === "telegram" + ? telegramTarget + : deliveryProvider === "whatsapp" + ? whatsappTarget + : undefined; + const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; runtime.error?.(message); if (!runtime.error) runtime.log(message); }; diff --git a/src/infra/widearea-dns.test.ts b/src/infra/widearea-dns.test.ts index 1afbefa66..8b398b6ba 100644 --- a/src/infra/widearea-dns.test.ts +++ b/src/infra/widearea-dns.test.ts @@ -40,8 +40,6 @@ describe("wide-area DNS-SD zone rendering", () => { instanceLabel: "studio-london", }); - expect(txt).toContain( - `tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`, - ); + expect(txt).toContain(`tailnetDns=peters-mac-studio-1.sheep-coho.ts.net`); }); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 7c8edc0ce..04a068cd7 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -32,4 +32,39 @@ describe("sendMessageTelegram", () => { expect(res.chatId).toBe(chatId); expect(res.messageId).toBe("42"); }); + + it("normalizes chat ids with internal prefixes", async () => { + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 1, + chat: { id: "123" }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram("telegram:123", "hi", { + token: "tok", + api, + }); + + expect(sendMessage).toHaveBeenCalledWith("123", "hi", { + parse_mode: "Markdown", + }); + }); + + it("wraps chat-not-found with actionable context", async () => { + const chatId = "123"; + const err = new Error("400: Bad Request: chat not found"); + const sendMessage = vi.fn().mockRejectedValue(err); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await expect( + sendMessageTelegram(chatId, "hi", { token: "tok", api }), + ).rejects.toThrow(/chat not found/i); + await expect( + sendMessageTelegram(chatId, "hi", { token: "tok", api }), + ).rejects.toThrow(/chat_id=123/); + }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index c362e871d..1557a795e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -33,8 +33,27 @@ function resolveToken(explicit?: string): string { function normalizeChatId(to: string): string { const trimmed = to.trim(); if (!trimmed) throw new Error("Recipient is required for Telegram sends"); - if (trimmed.startsWith("@")) return trimmed; - return trimmed; + + // Common internal prefixes that sometimes leak into outbound sends. + // - ctx.To uses `telegram:` + // - group sessions often use `group:` + let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); + + // Accept t.me links for public chats/channels. + // (Invite links like `t.me/+...` are not resolvable via Bot API.) + const m = + /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? + /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); + if (m?.[1]) normalized = `@${m[1]}`; + + if (!normalized) throw new Error("Recipient is required for Telegram sends"); + if (normalized.startsWith("@")) return normalized; + if (/^-?\d+$/.test(normalized)) return normalized; + + // If the user passed a username without `@`, assume they meant a public chat/channel. + if (/^[A-Za-z0-9_]{5,}$/i.test(normalized)) return `@${normalized}`; + + return normalized; } export async function sendMessageTelegram( @@ -75,6 +94,18 @@ export async function sendMessageTelegram( throw lastErr ?? new Error(`Telegram send failed (${label})`); }; + const wrapChatNotFound = (err: unknown) => { + if (!/400: Bad Request: chat not found/i.test(String(err ?? ""))) + return err; + return new Error( + [ + `Telegram send failed: chat not found (chat_id=${chatId}).`, + "Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.", + `Input was: ${JSON.stringify(to)}.`, + ].join(" "), + ); + }; + if (mediaUrl) { const media = await loadWebMedia(mediaUrl, opts.maxBytes); const kind = mediaKindFromMime(media.contentType ?? undefined); @@ -92,22 +123,30 @@ export async function sendMessageTelegram( result = await sendWithRetry( () => api.sendPhoto(chatId, file, { caption }), "photo", - ); + ).catch((err) => { + throw wrapChatNotFound(err); + }); } else if (kind === "video") { result = await sendWithRetry( () => api.sendVideo(chatId, file, { caption }), "video", - ); + ).catch((err) => { + throw wrapChatNotFound(err); + }); } else if (kind === "audio") { result = await sendWithRetry( () => api.sendAudio(chatId, file, { caption }), "audio", - ); + ).catch((err) => { + throw wrapChatNotFound(err); + }); } else { result = await sendWithRetry( () => api.sendDocument(chatId, file, { caption }), "document", - ); + ).catch((err) => { + throw wrapChatNotFound(err); + }); } const messageId = String(result?.message_id ?? "unknown"); return { messageId, chatId: String(result?.chat?.id ?? chatId) }; @@ -131,9 +170,11 @@ export async function sendMessageTelegram( return await sendWithRetry( () => api.sendMessage(chatId, text), "message-plain", - ); + ).catch((err2) => { + throw wrapChatNotFound(err2); + }); } - throw err; + throw wrapChatNotFound(err); }); const messageId = String(res?.message_id ?? "unknown"); return { messageId, chatId: String(res?.chat?.id ?? chatId) };