import { ensureProjectQwenMd, run, runUserMessage } from "../runner"; import { getSettings, loadSettings } from "../config"; import { resetSession } from "../sessions"; import { mkdir } from "node:fs/promises"; import { extname, join } from "node:path"; // --- Markdown → Telegram HTML conversion (ported from nanobot) --- function markdownToTelegramHtml(text: string): string { if (!text) return ""; // 1. Extract and protect code blocks const codeBlocks: string[] = []; text = text.replace(/```[\w]*\n?([\s\S]*?)```/g, (_m, code) => { codeBlocks.push(code); return `\x00CB${codeBlocks.length - 1}\x00`; }); // 2. Extract and protect inline code const inlineCodes: string[] = []; text = text.replace(/`([^`]+)`/g, (_m, code) => { inlineCodes.push(code); return `\x00IC${inlineCodes.length - 1}\x00`; }); // 3. Strip markdown headers text = text.replace(/^#{1,6}\s+(.+)$/gm, "$1"); // 4. Strip blockquotes text = text.replace(/^>\s*(.*)$/gm, "$1"); // 5. Escape HTML special characters text = text.replace(/&/g, "&").replace(//g, ">"); // 6. Links [text](url) — before bold/italic to handle nested cases text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // 7. Bold **text** or __text__ text = text.replace(/\*\*(.+?)\*\*/g, "$1"); text = text.replace(/__(.+?)__/g, "$1"); // 8. Italic _text_ (avoid matching inside words like some_var_name) text = text.replace(/(?$1"); // 9. Strikethrough ~~text~~ text = text.replace(/~~(.+?)~~/g, "$1"); // 10. Bullet lists text = text.replace(/^[-*]\s+/gm, "• "); // 11. Restore inline code with HTML tags for (let i = 0; i < inlineCodes.length; i++) { const escaped = inlineCodes[i].replace(/&/g, "&").replace(//g, ">"); text = text.replace(`\x00IC${i}\x00`, `${escaped}`); } // 12. Restore code blocks with HTML tags for (let i = 0; i < codeBlocks.length; i++) { const escaped = codeBlocks[i].replace(/&/g, "&").replace(//g, ">"); text = text.replace(`\x00CB${i}\x00`, `
${escaped}
`); } return text; } // --- Telegram Bot API (raw fetch, zero deps) --- const API_BASE = "https://api.telegram.org/bot"; const FILE_API_BASE = "https://api.telegram.org/file/bot"; interface TelegramUser { id: number; first_name: string; username?: string; } interface TelegramMessage { message_id: number; from?: TelegramUser; reply_to_message?: { from?: TelegramUser }; chat: { id: number; type: string }; text?: string; caption?: string; photo?: TelegramPhotoSize[]; document?: TelegramDocument; voice?: TelegramVoice; audio?: TelegramAudio; entities?: Array<{ type: "mention" | "bot_command" | string; offset: number; length: number; }>; caption_entities?: Array<{ type: "mention" | "bot_command" | string; offset: number; length: number; }>; } interface TelegramPhotoSize { file_id: string; width: number; height: number; file_size?: number; } interface TelegramDocument { file_id: string; file_name?: string; mime_type?: string; file_size?: number; } interface TelegramVoice { file_id: string; mime_type?: string; duration?: number; file_size?: number; } interface TelegramAudio { file_id: string; mime_type?: string; duration?: number; file_name?: string; file_size?: number; } interface TelegramChatMember { user: TelegramUser; status: "creator" | "administrator" | "member" | "restricted" | "left" | "kicked"; } interface TelegramMyChatMemberUpdate { chat: { id: number; type: string; title?: string }; from: TelegramUser; old_chat_member: TelegramChatMember; new_chat_member: TelegramChatMember; } interface TelegramUpdate { update_id: number; message?: TelegramMessage; edited_message?: TelegramMessage; channel_post?: TelegramMessage; edited_channel_post?: TelegramMessage; my_chat_member?: TelegramMyChatMemberUpdate; } interface TelegramMe { id: number; username?: string; can_read_all_group_messages?: boolean; } interface TelegramFile { file_path?: string; } let telegramDebug = false; function debugLog(message: string): void { if (!telegramDebug) return; console.log(`[Telegram][debug] ${message}`); } function normalizeTelegramText(text: string): string { return text.replace(/[\u2010-\u2015\u2212]/g, "-"); } function getMessageTextAndEntities(message: TelegramMessage): { text: string; entities: TelegramMessage["entities"]; } { if (message.text) { return { text: normalizeTelegramText(message.text), entities: message.entities, }; } if (message.caption) { return { text: normalizeTelegramText(message.caption), entities: message.caption_entities, }; } return { text: "", entities: [] }; } function isImageDocument(document?: TelegramDocument): boolean { return Boolean(document?.mime_type?.startsWith("image/")); } function isAudioDocument(document?: TelegramDocument): boolean { return Boolean(document?.mime_type?.startsWith("audio/")); } function pickLargestPhoto(photo: TelegramPhotoSize[]): TelegramPhotoSize { return [...photo].sort((a, b) => { const sizeA = a.file_size ?? a.width * a.height; const sizeB = b.file_size ?? b.width * b.height; return sizeB - sizeA; })[0]; } function extensionFromMimeType(mimeType?: string): string { switch (mimeType) { case "image/jpeg": return ".jpg"; case "image/png": return ".png"; case "image/webp": return ".webp"; case "image/gif": return ".gif"; case "image/bmp": return ".bmp"; default: return ""; } } function extensionFromAudioMimeType(mimeType?: string): string { switch (mimeType) { case "audio/mpeg": return ".mp3"; case "audio/mp4": case "audio/x-m4a": return ".m4a"; case "audio/ogg": return ".ogg"; case "audio/wav": case "audio/x-wav": return ".wav"; case "audio/webm": return ".webm"; default: return ""; } } function extractTelegramCommand(text: string): string | null { const firstToken = text.trim().split(/\s+/, 1)[0]; if (!firstToken.startsWith("/")) return null; return firstToken.split("@", 1)[0].toLowerCase(); } async function callApi(token: string, method: string, body?: Record): Promise { const res = await fetch(`${API_BASE}${token}/${method}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { throw new Error(`Telegram API ${method}: ${res.status} ${res.statusText}`); } return (await res.json()) as T; } async function sendMessage(token: string, chatId: number, text: string): Promise { const normalized = normalizeTelegramText(text).replace(/\[react:[^\]\r\n]+\]/gi, ""); const html = markdownToTelegramHtml(normalized); const MAX_LEN = 4096; for (let i = 0; i < html.length; i += MAX_LEN) { try { await callApi(token, "sendMessage", { chat_id: chatId, text: html.slice(i, i + MAX_LEN), parse_mode: "HTML", }); } catch { // Fallback to plain text if HTML parsing fails await callApi(token, "sendMessage", { chat_id: chatId, text: normalized.slice(i, i + MAX_LEN), }); } } } async function sendTyping(token: string, chatId: number): Promise { await callApi(token, "sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}); } function extractReactionDirective(text: string): { cleanedText: string; reactionEmoji: string | null } { let reactionEmoji: string | null = null; const cleanedText = text .replace(/\[react:([^\]\r\n]+)\]/gi, (_match, raw) => { const candidate = String(raw).trim(); if (!reactionEmoji && candidate) reactionEmoji = candidate; return ""; }) .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); return { cleanedText, reactionEmoji }; } async function sendReaction(token: string, chatId: number, messageId: number, emoji: string): Promise { await callApi(token, "setMessageReaction", { chat_id: chatId, message_id: messageId, reaction: [{ type: "emoji", emoji }], }); } let botUsername: string | null = null; let botId: number | null = null; function groupTriggerReason(message: TelegramMessage): string | null { if (botId && message.reply_to_message?.from?.id === botId) return "reply_to_bot"; const { text, entities } = getMessageTextAndEntities(message); if (!text) return null; const lowerText = text.toLowerCase(); if (botUsername && lowerText.includes(`@${botUsername.toLowerCase()}`)) return "text_contains_mention"; for (const entity of entities ?? []) { const value = text.slice(entity.offset, entity.offset + entity.length); if (entity.type === "mention" && botUsername && value.toLowerCase() === `@${botUsername.toLowerCase()}`) { return "mention_entity_matches_bot"; } if (entity.type === "mention" && !botUsername) return "mention_entity_before_botname_loaded"; if (entity.type === "bot_command") { if (!value.includes("@")) return "bare_bot_command"; if (!botUsername) return "scoped_command_before_botname_loaded"; if (botUsername && value.toLowerCase().endsWith(`@${botUsername.toLowerCase()}`)) return "scoped_command_matches_bot"; } } return null; } async function downloadImageFromMessage(token: string, message: TelegramMessage): Promise { const photo = message.photo && message.photo.length > 0 ? pickLargestPhoto(message.photo) : null; const imageDocument = isImageDocument(message.document) ? message.document : null; const fileId = photo?.file_id ?? imageDocument?.file_id; if (!fileId) return null; const fileMeta = await callApi<{ ok: boolean; result: TelegramFile }>(token, "getFile", { file_id: fileId }); if (!fileMeta.ok || !fileMeta.result.file_path) return null; const remotePath = fileMeta.result.file_path; const downloadUrl = `${FILE_API_BASE}${token}/${remotePath}`; const response = await fetch(downloadUrl); if (!response.ok) throw new Error(`Telegram file download failed: ${response.status} ${response.statusText}`); const dir = join(process.cwd(), ".claude", "claudeclaw", "inbox", "telegram"); await mkdir(dir, { recursive: true }); const remoteExt = extname(remotePath); const docExt = extname(imageDocument?.file_name ?? ""); const mimeExt = extensionFromMimeType(imageDocument?.mime_type); const ext = remoteExt || docExt || mimeExt || ".jpg"; const filename = `${message.chat.id}-${message.message_id}-${Date.now()}${ext}`; const localPath = join(dir, filename); const bytes = new Uint8Array(await response.arrayBuffer()); await Bun.write(localPath, bytes); return localPath; } async function downloadVoiceFromMessage(token: string, message: TelegramMessage): Promise { const audioDocument = isAudioDocument(message.document) ? message.document : null; const audioLike = message.voice ?? message.audio ?? audioDocument; const fileId = audioLike?.file_id; if (!fileId) return null; const fileMeta = await callApi<{ ok: boolean; result: TelegramFile }>(token, "getFile", { file_id: fileId }); if (!fileMeta.ok || !fileMeta.result.file_path) return null; const remotePath = fileMeta.result.file_path; const downloadUrl = `${FILE_API_BASE}${token}/${remotePath}`; debugLog( `Voice download: fileId=${fileId} remotePath=${remotePath} mime=${audioLike.mime_type ?? "unknown"} expectedSize=${audioLike.file_size ?? "unknown"}` ); const response = await fetch(downloadUrl); if (!response.ok) throw new Error(`Telegram file download failed: ${response.status} ${response.statusText}`); const dir = join(process.cwd(), ".claude", "claudeclaw", "inbox", "telegram"); await mkdir(dir, { recursive: true }); const remoteExt = extname(remotePath); const docExt = extname(message.document?.file_name ?? ""); const audioExt = extname(message.audio?.file_name ?? ""); const mimeExt = extensionFromAudioMimeType(audioLike.mime_type); const ext = remoteExt || docExt || audioExt || mimeExt || ".ogg"; const filename = `${message.chat.id}-${message.message_id}-${Date.now()}${ext}`; const localPath = join(dir, filename); const bytes = new Uint8Array(await response.arrayBuffer()); await Bun.write(localPath, bytes); const header = Array.from(bytes.slice(0, 8)) .map((b) => b.toString(16).padStart(2, "0")) .join(" "); const oggMagic = bytes.length >= 4 && bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53; debugLog( `Voice download: wrote ${bytes.length} bytes to ${localPath} ext=${ext} header=${header || "empty"} oggMagic=${oggMagic}` ); return localPath; } async function handleMyChatMember(update: TelegramMyChatMemberUpdate): Promise { const config = getSettings().telegram; const chat = update.chat; if (!botUsername && update.new_chat_member.user.username) botUsername = update.new_chat_member.user.username; if (!botId) botId = update.new_chat_member.user.id; const oldStatus = update.old_chat_member.status; const newStatus = update.new_chat_member.status; const isGroup = chat.type === "group" || chat.type === "supergroup"; const wasOut = oldStatus === "left" || oldStatus === "kicked"; const isIn = newStatus === "member" || newStatus === "administrator"; if (!isGroup || !wasOut || !isIn) return; const chatName = chat.title ?? String(chat.id); console.log(`[Telegram] Added to ${chat.type}: ${chatName} (${chat.id}) by ${update.from.id}`); const addedBy = update.from.username ?? `${update.from.first_name} (${update.from.id})`; const eventPrompt = `[Telegram system event] I was added to a ${chat.type}.\n` + `Group title: ${chatName}\n` + `Group id: ${chat.id}\n` + `Added by: ${addedBy}\n` + "Write a short first message for the group. It should confirm I was added and explain how to trigger me."; try { const result = await run("telegram", eventPrompt); if (result.exitCode !== 0) { await sendMessage(config.token, chat.id, "I was added to this group. Mention me with a command to start."); return; } await sendMessage(config.token, chat.id, result.stdout || "I was added to this group."); } catch (err) { console.error(`[Telegram] group-added event error: ${err instanceof Error ? err.message : err}`); await sendMessage(config.token, chat.id, "I was added to this group. Mention me with a command to start."); } } async function handleMessage(message: TelegramMessage): Promise { const config = getSettings().telegram; const userId = message.from?.id; const chatId = message.chat.id; const { text } = getMessageTextAndEntities(message); const chatType = message.chat.type; const isPrivate = chatType === "private"; const isGroup = chatType === "group" || chatType === "supergroup"; const hasImage = Boolean((message.photo && message.photo.length > 0) || isImageDocument(message.document)); const hasVoice = Boolean(message.voice || message.audio || isAudioDocument(message.document)); if (!isPrivate && !isGroup) return; const triggerReason = isGroup ? groupTriggerReason(message) : "private_chat"; if (isGroup && !triggerReason) { debugLog( `Skip group message chat=${chatId} from=${userId ?? "unknown"} reason=no_trigger text="${(text ?? "").slice(0, 80)}"` ); return; } debugLog( `Handle message chat=${chatId} type=${chatType} from=${userId ?? "unknown"} reason=${triggerReason} text="${(text ?? "").slice(0, 80)}"` ); if (userId && config.allowedUserIds.length > 0 && !config.allowedUserIds.includes(userId)) { if (isPrivate) { await sendMessage(config.token, chatId, "Unauthorized."); } else { console.log(`[Telegram] Ignored group message from unauthorized user ${userId} in chat ${chatId}`); debugLog(`Skip group message chat=${chatId} from=${userId} reason=unauthorized_user`); } return; } if (!text.trim() && !hasImage && !hasVoice) { debugLog(`Skip message chat=${chatId} from=${userId ?? "unknown"} reason=empty_text`); return; } const command = text ? extractTelegramCommand(text) : null; if (command === "/start") { await sendMessage( config.token, chatId, "Hello! Send me a message and I'll respond using Claude.\nUse /reset to start a fresh session." ); return; } if (command === "/reset") { await resetSession(); await sendMessage(config.token, chatId, "Global session reset. Next message starts fresh."); return; } const label = message.from?.username ?? String(userId ?? "unknown"); const mediaParts = [hasImage ? "image" : "", hasVoice ? "voice" : ""].filter(Boolean); const mediaSuffix = mediaParts.length > 0 ? ` [${mediaParts.join("+")}]` : ""; console.log( `[${new Date().toLocaleTimeString()}] Telegram ${label}${mediaSuffix}: "${text.slice(0, 60)}${text.length > 60 ? "..." : ""}"` ); // Keep typing indicator alive while queued/running const typingInterval = setInterval(() => sendTyping(config.token, chatId), 4000); try { await sendTyping(config.token, chatId); let imagePath: string | null = null; let voicePath: string | null = null; let voiceTranscript: string | null = null; if (hasImage) { try { imagePath = await downloadImageFromMessage(config.token, message); } catch (err) { console.error(`[Telegram] Failed to download image for ${label}: ${err instanceof Error ? err.message : err}`); } } if (hasVoice) { try { voicePath = await downloadVoiceFromMessage(config.token, message); } catch (err) { console.error(`[Telegram] Failed to download voice for ${label}: ${err instanceof Error ? err.message : err}`); } if (voicePath) { try { debugLog(`Voice file saved: path=${voicePath}`); // Voice transcription requires whisper setup - for now, notify user voiceTranscript = "[Voice message received but transcription not configured]"; } catch (err) { console.error(`[Telegram] Failed to transcribe voice for ${label}: ${err instanceof Error ? err.message : err}`); } } } const promptParts = [`[Telegram from ${label}]`]; if (text.trim()) promptParts.push(`Message: ${text}`); if (imagePath) { promptParts.push(`Image path: ${imagePath}`); promptParts.push("The user attached an image. Inspect this image file directly before answering."); } else if (hasImage) { promptParts.push("The user attached an image, but downloading it failed. Respond and ask them to resend."); } if (voiceTranscript) { promptParts.push(`Voice transcript: ${voiceTranscript}`); promptParts.push("The user attached voice audio. Use the transcript as their spoken message."); } else if (hasVoice) { promptParts.push( "The user attached voice audio, but it could not be transcribed. Respond and ask them to resend a clearer clip." ); } const prefixedPrompt = promptParts.join("\n"); const result = await runUserMessage("telegram", prefixedPrompt); if (result.exitCode !== 0) { await sendMessage(config.token, chatId, `Error (exit ${result.exitCode}): ${result.stderr || "Unknown error"}`); } else { const { cleanedText, reactionEmoji } = extractReactionDirective(result.stdout || ""); if (reactionEmoji) { await sendReaction(config.token, chatId, message.message_id, reactionEmoji).catch((err) => { console.error(`[Telegram] Failed to send reaction for ${label}: ${err instanceof Error ? err.message : err}`); }); } await sendMessage(config.token, chatId, cleanedText || "(empty response)"); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); console.error(`[Telegram] Error for ${label}: ${errMsg}`); await sendMessage(config.token, chatId, `Error: ${errMsg}`); } finally { clearInterval(typingInterval); } } // --- Polling loop --- let running = true; async function poll(): Promise { const config = getSettings().telegram; let offset = 0; try { const me = await callApi<{ ok: boolean; result: TelegramMe }>(config.token, "getMe"); if (me.ok) { botUsername = me.result.username ?? null; botId = me.result.id; console.log(` Bot: ${botUsername ? `@${botUsername}` : botId}`); console.log(` Group privacy: ${me.result.can_read_all_group_messages ? "disabled (reads all messages)" : "enabled (commands & mentions only)"}`); } } catch (err) { console.error(`[Telegram] getMe failed: ${err instanceof Error ? err.message : err}`); } console.log("Telegram bot started (long polling)"); console.log(` Allowed users: ${config.allowedUserIds.length === 0 ? "all" : config.allowedUserIds.join(", ")}`); if (telegramDebug) console.log(" Debug: enabled"); while (running) { try { const data = await callApi<{ ok: boolean; result: TelegramUpdate[] }>( config.token, "getUpdates", { offset, timeout: 30, allowed_updates: ["message", "my_chat_member"] } ); if (!data.ok || !data.result.length) continue; for (const update of data.result) { debugLog( `Update ${update.update_id} keys=${Object.keys(update).join(",")}` ); offset = update.update_id + 1; const incomingMessages = [ update.message, update.edited_message, update.channel_post, update.edited_channel_post, ].filter((m): m is TelegramMessage => Boolean(m)); for (const incoming of incomingMessages) { handleMessage(incoming).catch((err) => { console.error(`[Telegram] Unhandled: ${err}`); }); } if (update.my_chat_member) { handleMyChatMember(update.my_chat_member).catch((err) => { console.error(`[Telegram] my_chat_member unhandled: ${err}`); }); } } } catch (err) { if (!running) break; console.error(`[Telegram] Poll error: ${err instanceof Error ? err.message : err}`); await Bun.sleep(5000); } } } // --- Exports --- /** Send a message to a specific chat (used by heartbeat forwarding) */ export { sendMessage }; process.on("SIGTERM", () => { running = false; }); process.on("SIGINT", () => { running = false; }); /** Start polling in-process (called by start.ts when token is configured) */ export function startPolling(debug = false): void { telegramDebug = debug; (async () => { await ensureProjectQwenMd(); await poll(); })().catch((err) => { console.error(`[Telegram] Fatal: ${err}`); }); } /** Standalone entry point (bun run src/index.ts telegram) */ export async function telegram() { await loadSettings(); await ensureProjectQwenMd(); await poll(); }