// @ts-nocheck import { Bot, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RetryConfig } from "../infra/retry.js"; import { createTelegramRetryRunner } from "../infra/retry-policy.js"; import { mediaKindFromMime } from "../media/constants.js"; import { isGifMedia } from "../media/mime.js"; import { loadWebMedia } from "../web/media.js"; import { resolveTelegramToken } from "./token.js"; type TelegramSendOpts = { token?: string; verbose?: boolean; mediaUrl?: string; maxBytes?: number; messageThreadId?: number; api?: Bot["api"]; retry?: RetryConfig; }; type TelegramSendResult = { messageId: string; chatId: string; }; type TelegramReactionOpts = { token?: string; api?: Bot["api"]; remove?: boolean; verbose?: boolean; retry?: RetryConfig; }; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; function resolveToken(explicit?: string, cfg?: ClawdbotConfig): string { if (explicit?.trim()) return explicit.trim(); const { token } = resolveTelegramToken(cfg); if (!token) { throw new Error( "TELEGRAM_BOT_TOKEN (or telegram.botToken/tokenFile) 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; } function normalizeMessageId(raw: string | number): number { if (typeof raw === "number" && Number.isFinite(raw)) { return Math.trunc(raw); } if (typeof raw === "string") { const value = raw.trim(); if (!value) { throw new Error("Message id is required for Telegram reactions"); } const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) return parsed; } throw new Error("Message id is required for Telegram reactions"); } export async function sendMessageTelegram( to: string, text: string, opts: TelegramSendOpts = {}, ): Promise { const cfg = loadConfig(); const token = resolveToken(opts.token, cfg); const chatId = normalizeChatId(to); const bot = opts.api ? null : new Bot(token); const api = opts.api ?? bot?.api; const mediaUrl = opts.mediaUrl?.trim(); const threadParams = typeof opts.messageThreadId === "number" ? { message_thread_id: Math.trunc(opts.messageThreadId) } : undefined; const request = createTelegramRetryRunner({ retry: opts.retry, configRetry: cfg.telegram?.retry, verbose: opts.verbose, }); 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 isGif = isGifMedia({ contentType: media.contentType, fileName: media.fileName, }); const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file"; const file = new InputFile(media.buffer, fileName); const caption = text?.trim() || undefined; let result: | Awaited> | Awaited> | Awaited> | Awaited> | Awaited>; if (isGif) { result = await request( () => api.sendAnimation(chatId, file, { caption, ...threadParams }), "animation", ).catch((err) => { throw wrapChatNotFound(err); }); } else if (kind === "image") { result = await request( () => api.sendPhoto(chatId, file, { caption, ...threadParams }), "photo", ).catch((err) => { throw wrapChatNotFound(err); }); } else if (kind === "video") { result = await request( () => api.sendVideo(chatId, file, { caption, ...threadParams }), "video", ).catch((err) => { throw wrapChatNotFound(err); }); } else if (kind === "audio") { result = await request( () => api.sendAudio(chatId, file, { caption, ...threadParams }), "audio", ).catch((err) => { throw wrapChatNotFound(err); }); } else { result = await request( () => api.sendDocument(chatId, file, { caption, ...threadParams }), "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 request( () => api.sendMessage(chatId, text, { parse_mode: "Markdown", ...threadParams, }), "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 request( () => threadParams ? api.sendMessage(chatId, text, threadParams) : 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) }; } export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, emoji: string, opts: TelegramReactionOpts = {}, ): Promise<{ ok: true }> { const cfg = loadConfig(); const token = resolveToken(opts.token, cfg); const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const bot = opts.api ? null : new Bot(token); const api = opts.api ?? bot?.api; const request = createTelegramRetryRunner({ retry: opts.retry, configRetry: cfg.telegram?.retry, verbose: opts.verbose, }); const remove = opts.remove === true; const trimmedEmoji = emoji.trim(); const reactions = remove || !trimmedEmoji ? [] : [{ type: "emoji", emoji: trimmedEmoji }]; if (typeof api.setMessageReaction !== "function") { throw new Error("Telegram reactions are unavailable in this bot API."); } await request( () => api.setMessageReaction(chatId, messageId, reactions), "reaction", ); return { ok: true }; } 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