/** * Telegram channel provider — DIRECT Bot API, no relay in the message path. * * Unlike Alexa (relay-mediated, degenerate provider), this provider holds the * user's OWN @BotFather bot token (pasted at connect time) and long-polls * `getUpdates` straight against api.telegram.org — no relay anywhere. Every * inbound/outbound message (including media) flows Bloby ↔ Telegram directly. * Outbound HTTPS only: works behind NAT, no public URL, no relay egress. * * It is lighter than the WhatsApp/Baileys provider (no reverse-engineered * protocol, no QR, no auth-state files) — just a token string and a poll loop. */ import { loadConfig } from '../../shared/config.js'; import { log } from '../../shared/logger.js'; import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js'; const TG_API = 'https://api.telegram.org'; const POLL_TIMEOUT_S = 25; // long-poll hold time const MAX_MESSAGE_CHARS = 4096; // Telegram hard limit per sendMessage const TYPING_REFRESH_MS = 5_000; // Telegram "typing" expires ~5s /** Media attachment extracted from an inbound Telegram message. * `type: 'image'` → inline vision; `type: 'file'` → a document the agent reads from disk. */ export interface TelegramMediaAttachment { type: 'image' | 'file'; mediaType: string; data: string; // base64 /** Original filename — present for documents, absent for photos. */ name?: string; } /** Normalized inbound message handed to the ChannelManager. */ export interface TelegramInbound { /** Chat id (string form of the numeric Telegram chat.id). Reply target. */ chatId: string; /** Sender's numeric Telegram user id (string). */ fromUserId: string; /** Display name (first name / @username) if available. */ senderName?: string; text: string; isGroup: boolean; messageId?: number; attachments?: TelegramMediaAttachment[]; } export type OnTelegramMessage = (msg: TelegramInbound) => void; export type TranscribeFn = (audioBase64: string) => Promise; export class TelegramChannel implements ChannelProvider { readonly type: ChannelType = 'telegram'; private token: string | null = null; private botUsername: string | null = null; private connected = false; private offset = 0; private intentionalDisconnect = false; private pollAbort: AbortController | null = null; private reconnectTimer: ReturnType | null = null; private typingIntervals = new Map>(); private onMessage: OnTelegramMessage; private onStatusChange: (status: ChannelStatus) => void; private transcribe: TranscribeFn | null; constructor( onMessage: OnTelegramMessage, onStatusChange: (status: ChannelStatus) => void, transcribe?: TranscribeFn, ) { this.onMessage = onMessage; this.onStatusChange = onStatusChange; this.transcribe = transcribe || null; } private api(method: string): string { return `${TG_API}/bot${this.token}/${method}`; } hasCredentials(): boolean { return !!loadConfig().channels?.telegram?.botToken; } getQrCode(): string | null { return null; } getStatus(): ChannelStatus { return { channel: 'telegram', connected: this.connected, info: { botUsername: this.botUsername || loadConfig().channels?.telegram?.botUsername || null, linked: this.hasCredentials(), hasCredentials: this.hasCredentials(), }, }; } async connect(): Promise { this.intentionalDisconnect = false; const cfg = loadConfig().channels?.telegram; if (!cfg?.botToken) { log.warn('[telegram] No bot token configured — not connecting'); return; } this.token = cfg.botToken; this.botUsername = cfg.botUsername || null; // getMe confirms the token and learns the @username. try { const me = await this.call('getMe', {}); if (me?.username) this.botUsername = me.username; log.ok(`[telegram] Connected as @${this.botUsername || 'unknown'} (id=${me?.id || '?'})`); } catch (err: any) { log.warn(`[telegram] getMe failed: ${err.message} — will retry in poll loop`); } this.connected = true; this.emitStatus(); this.pollLoop(); // fire and forget } async disconnect(): Promise { this.intentionalDisconnect = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.pollAbort) { try { this.pollAbort.abort(); } catch {} this.pollAbort = null; } for (const interval of this.typingIntervals.values()) clearInterval(interval); this.typingIntervals.clear(); this.connected = false; this.emitStatus(); } // ── Outbound ────────────────────────────────────────────────────────────── async sendMessage(to: string, text: string): Promise { if (!this.token) { log.warn('[telegram] Cannot send — no token'); return; } this.stopTyping(to); // Telegram caps messages at 4096 chars — split on the limit. for (const chunk of splitMessage(text, MAX_MESSAGE_CHARS)) { try { await this.call('sendMessage', { chat_id: to, text: chunk, link_preview_options: { is_disabled: true } }); } catch (err: any) { log.warn(`[telegram] sendMessage to ${to} failed: ${err.message}`); return; } } log.info(`[telegram] Sent message to ${to} (${text.length} chars)`); } /** Send an image natively (used by ChannelManager for tags). */ async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise { if (!this.token) { log.warn('[telegram] Cannot send image — no token'); return; } this.stopTyping(to); try { const form = new FormData(); form.append('chat_id', to); if (caption) form.append('caption', caption.slice(0, 1024)); const ext = (mimetype?.split('/')[1] || 'png').replace('jpeg', 'jpg'); form.append('photo', new Blob([new Uint8Array(image)], { type: mimetype || 'image/png' }), `image.${ext}`); const r = await fetch(this.api('sendPhoto'), { method: 'POST', body: form }); if (!r.ok) throw new Error(`HTTP ${r.status}`); log.info(`[telegram] Sent image to ${to}`); } catch (err: any) { log.warn(`[telegram] sendImage to ${to} failed: ${err.message}`); } } /** Show "typing…" — refreshes every 5s since Telegram's indicator expires. */ startTyping(to: string): void { if (!this.token || !this.connected) return; this.stopTyping(to); let ticks = 0; const send = () => { // Cap the refresh (~2 min) so a turn that ends without a reply can't leave a stuck indicator. if (++ticks > 24) { this.stopTyping(to); return; } this.call('sendChatAction', { chat_id: to, action: 'typing' }).catch(() => {}); }; send(); this.typingIntervals.set(to, setInterval(send, TYPING_REFRESH_MS)); } stopTyping(to: string): void { const interval = this.typingIntervals.get(to); if (interval) { clearInterval(interval); this.typingIntervals.delete(to); } } // ── Inbound poll loop ───────────────────────────────────────────────────── private async pollLoop(): Promise { while (!this.intentionalDisconnect) { this.pollAbort = new AbortController(); try { const updates = await this.call('getUpdates', { offset: this.offset, timeout: POLL_TIMEOUT_S, allowed_updates: ['message'], }, this.pollAbort.signal, (POLL_TIMEOUT_S + 10) * 1000); if (Array.isArray(updates)) { for (const update of updates) { this.offset = Math.max(this.offset, (update.update_id || 0) + 1); try { await this.handleUpdate(update); } catch (err: any) { log.warn(`[telegram] handleUpdate error: ${err.message}`); } } } } catch (err: any) { if (this.intentionalDisconnect) break; // Watchdog timeout (the long-poll exceeded its deadline without a response — // e.g. a silently dropped connection). A disconnect-driven abort is caught by // the intentionalDisconnect check above, so any AbortError here is the timeout: // re-open a fresh long-poll immediately rather than treating it as fatal. if (err?.name === 'AbortError') continue; // 409 = another getUpdates/webhook is consuming this bot — do not hot-loop. if (String(err.message).includes('409')) { log.warn('[telegram] getUpdates conflict (409) — another consumer holds this bot. Stopping poll.'); this.connected = false; this.emitStatus(); return; } log.warn(`[telegram] getUpdates error: ${err.message} — retrying in 5s`); await sleep(5000); } } } private async handleUpdate(update: any): Promise { const message = update.message; if (!message) return; const from = message.from || {}; if (from.is_bot) return; // ignore other bots (our own sends never come back here anyway) const chat = message.chat || {}; const chatId = String(chat.id); const fromUserId = String(from.id ?? ''); const isGroup = chat.type === 'group' || chat.type === 'supergroup'; const senderName = from.first_name ? (from.last_name ? `${from.first_name} ${from.last_name}` : from.first_name) : (from.username || undefined); let rawText: string = message.text || message.caption || ''; const attachments: TelegramMediaAttachment[] = []; // Photo: download the largest available size. Derive the real mediaType from the CDN // file extension (Telegram stores PNG/JPEG/WebP as-is) — default to image/jpeg only when unknown. if (Array.isArray(message.photo) && message.photo.length > 0) { const largest = message.photo[message.photo.length - 1]; const img = await this.downloadFile(largest.file_id).catch(() => null); if (img) attachments.push({ type: 'image', mediaType: mimeFromPath(img.filePath, 'image/jpeg'), data: img.buffer.toString('base64') }); } // Document: download the binary and forward as a file the agent reads from disk. if (message.document?.file_id) { const doc = await this.downloadFile(message.document.file_id).catch(() => null); if (doc) { attachments.push({ type: 'file', mediaType: message.document.mime_type || mimeFromPath(doc.filePath, 'application/octet-stream'), data: doc.buffer.toString('base64'), name: message.document.file_name || undefined, }); } } // Voice note / audio: download + transcribe. const voice = message.voice || message.audio; if (!rawText && voice?.file_id) { if (!this.transcribe) { await this.sendMessage(chatId, 'Voice transcription is off — add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable it.'); return; } const got = await this.downloadFile(voice.file_id).catch(() => null); if (got) { const transcript = await this.transcribe(got.buffer.toString('base64')).catch(() => null); if (transcript) { rawText = transcript; log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`); } else { await this.sendMessage(chatId, "I couldn't transcribe that voice message — if this keeps happening, add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable voice transcription."); return; } } } // Nothing usable extracted. If the message DID carry media we couldn't handle // (sticker, video, location, contact, …), tell the user instead of dropping it silently. if (!rawText && attachments.length === 0) { const hadUnsupportedMedia = !!(message.sticker || message.video || message.video_note || message.animation || message.location || message.contact || message.poll || message.dice); if (hadUnsupportedMedia) { await this.sendMessage(chatId, "Sorry, I can't read that type of message yet — try sending text, a photo, or a document."); } return; } if (!rawText && attachments.length > 0) { rawText = attachments.some((a) => a.type === 'image') ? '(image)' : '(document)'; } const text = escapeMessageText(rawText); log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, media=${attachments.length}): ${text.slice(0, 80)}`); this.onMessage({ chatId, fromUserId, senderName, text, isGroup, messageId: message.message_id, attachments: attachments.length > 0 ? attachments : undefined, }); } /** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). * Also returns the CDN file_path so callers can derive an extension/mediaType. */ private async downloadFile(fileId: string): Promise<{ buffer: Buffer; filePath: string } | null> { const file = await this.call('getFile', { file_id: fileId }); const filePath = file?.file_path; if (!filePath) return null; const r = await fetch(`${TG_API}/file/bot${this.token}/${filePath}`); if (!r.ok) throw new Error(`file download HTTP ${r.status}`); const buf = Buffer.from(await r.arrayBuffer()); log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`); return { buffer: buf, filePath }; } /** Call a Bot API method, returning `result` or throwing on `ok:false`. */ private async call(method: string, params: Record, signal?: AbortSignal, timeoutMs = 30_000): Promise { // Always apply the watchdog timeout, AND honor the caller's abort signal (disconnect) // when present — abort whichever fires first. The previous version dropped the timeout // whenever a signal was passed, leaving the long-poll with no deadline. const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); const onExternalAbort = () => ctrl.abort(); if (signal) { if (signal.aborted) ctrl.abort(); else signal.addEventListener('abort', onExternalAbort, { once: true }); } try { const r = await fetch(this.api(method), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), signal: ctrl.signal, }); const data = await r.json().catch(() => ({})); if (!r.ok || !data.ok) { throw new Error(`${method} → ${r.status} ${data.description || ''}`.trim()); } return data.result; } finally { clearTimeout(timer); if (signal) signal.removeEventListener('abort', onExternalAbort); } } private emitStatus() { this.onStatusChange(this.getStatus()); } } // ── helpers ────────────────────────────────────────────────────────────────── function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Best-effort mime type from a file path's extension; returns `fallback` when unknown. * Used for Telegram photos/documents where the API doesn't always supply a mime_type. */ function mimeFromPath(filePath: string | undefined, fallback: string): string { const ext = (filePath?.split('.').pop() || '').toLowerCase(); const map: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain', csv: 'text/csv', json: 'application/json', }; return map[ext] || fallback; } /** Split a long message into <=limit-char chunks, preferring newline boundaries. */ function splitMessage(text: string, limit: number): string[] { if (text.length <= limit) return [text]; const chunks: string[] = []; let rest = text; while (rest.length > limit) { let cut = rest.lastIndexOf('\n', limit); if (cut < limit * 0.5) cut = limit; // no good newline — hard cut chunks.push(rest.slice(0, cut)); rest = rest.slice(cut).replace(/^\n/, ''); } if (rest) chunks.push(rest); return chunks; } /** Mirror WhatsApp's anti-injection escaping so message content can't fake channel/role tags. */ function escapeMessageText(text: string): string { return text .replace(/\[Telegram\s*\|/gi, '(Telegram|') .replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|') .replace(/\[\s*(admin|customer)\s*\]/gi, '($1)'); }