// @ts-nocheck import { Bot, InputFile } from "grammy"; import { formatErrorMessage } from "../infra/errors.js"; import { mediaKindFromMime } from "../media/constants.js"; import { loadWebMedia } from "../web/media.js"; type TelegramSendOpts = { token?: string; verbose?: boolean; mediaUrl?: string; maxBytes?: number; api?: Bot["api"]; }; type TelegramSendResult = { messageId: string; chatId: string; }; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; function resolveToken(explicit?: string): string { const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN; if (!token) { throw new Error( "TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)", ); } return token.trim(); } function normalizeChatId(to: string): string { const trimmed = to.trim(); if (!trimmed) throw new Error("Recipient is required for Telegram sends"); // Common internal prefixes that sometimes leak into outbound sends. // - ctx.To uses `telegram:` // - group sessions often use `telegram: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( to: string, text: string, opts: TelegramSendOpts = {}, ): Promise { const token = resolveToken(opts.token); const chatId = normalizeChatId(to); const bot = opts.api ? null : new Bot(token); const api = opts.api ?? bot?.api; const mediaUrl = opts.mediaUrl?.trim(); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sendWithRetry = async (fn: () => Promise, label: string) => { let lastErr: unknown; for (let attempt = 1; attempt <= 3; attempt++) { try { return await fn(); } catch (err) { lastErr = err; const errText = formatErrorMessage(err); const terminal = attempt === 3 || !/429|timeout|connect|reset|closed|unavailable|temporarily/i.test( errText, ); if (terminal) break; const backoff = 400 * attempt; if (opts.verbose) { console.warn( `telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${errText}`, ); } await sleep(backoff); } } throw lastErr ?? new Error(`Telegram send failed (${label})`); }; const wrapChatNotFound = (err: unknown) => { if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(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); const file = new InputFile( media.buffer, media.fileName ?? inferFilename(kind) ?? "file", ); const caption = text?.trim() || undefined; let result: | Awaited> | Awaited> | Awaited> | Awaited>; if (kind === "image") { 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) }; } if (!text || !text.trim()) { throw new Error("Message must be non-empty for Telegram sends"); } const res = await sendWithRetry( () => api.sendMessage(chatId, text, { parse_mode: "Markdown" }), "message", ).catch(async (err) => { // Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*'). // When that happens, fall back to plain text so the message still delivers. const errText = formatErrorMessage(err); if (PARSE_ERR_RE.test(errText)) { if (opts.verbose) { console.warn( `telegram markdown parse failed, retrying as plain text: ${errText}`, ); } return await sendWithRetry( () => api.sendMessage(chatId, text), "message-plain", ).catch((err2) => { throw wrapChatNotFound(err2); }); } throw wrapChatNotFound(err); }); const messageId = String(res?.message_id ?? "unknown"); return { messageId, chatId: String(res?.chat?.id ?? chatId) }; } function inferFilename(kind: ReturnType) { switch (kind) { case "image": return "image.jpg"; case "video": return "video.mp4"; case "audio": return "audio.ogg"; default: return "file.bin"; } } // @ts-nocheck