| 12 | const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); |
| 13 | |
| 14 | export class TelegramTransport implements Transport { |
| 15 | readonly platform = 'telegram' as const; |
| 16 | readonly maxLen = 4096; |
| 17 | readonly minEditIntervalMs = 1100; // Telegram allows ~1 edit/s per chat |
| 18 | private offset = 0; |
| 19 | private running = false; |
| 20 | |
| 21 | constructor(private token: string) {} |
| 22 | |
| 23 | private async api(method: string, body: unknown): Promise<any> { |
| 24 | const res = await fetch(`https://api.telegram.org/bot${this.token}/${method}`, { |
| 25 | method: 'POST', |
| 26 | headers: { 'content-type': 'application/json' }, |
| 27 | body: JSON.stringify(body), |
| 28 | }); |
| 29 | return res.json(); |
| 30 | } |
| 31 | |
| 32 | private keyboard(buttons?: Button[][]) { |
| 33 | if (!buttons) return {}; |
| 34 | return { reply_markup: { inline_keyboard: buttons.map(row => row.map(b => ({ text: b.label, callback_data: b.data }))) } }; |
| 35 | } |
| 36 | |
| 37 | async start(onMessage: (m: Incoming) => void): Promise<void> { |
| 38 | const me = await this.api('getMe', {}); |
| 39 | if (!me.ok) throw new Error(`Telegram getMe failed: ${me.description ?? 'bad token'}`); |
| 40 | logger.info('telegram bot online', { username: me.result?.username }); |
| 41 | this.running = true; |
| 42 | void this.loop(onMessage); |
| 43 | } |
| 44 | |
| 45 | async stop(): Promise<void> { this.running = false; } |
| 46 | |
| 47 | private async loop(onMessage: (m: Incoming) => void): Promise<void> { |
| 48 | while (this.running) { |
| 49 | try { |
| 50 | const res = await this.api('getUpdates', { offset: this.offset, timeout: 30, allowed_updates: ['message', 'callback_query'] }); |
| 51 | if (!res.ok) { await sleep(2000); continue; } |
| 52 | for (const u of res.result) { |
| 53 | this.offset = u.update_id + 1; |
| 54 | if (u.message?.text) { |
| 55 | onMessage({ platform: 'telegram', chatId: String(u.message.chat.id), userId: String(u.message.from?.id), userName: u.message.from?.username, text: u.message.text }); |
| 56 | } else if (u.message?.voice || u.message?.audio) { |
| 57 | // Voice memo → transcribe (local-first) → feed as text. Fire-and-forget so the |
| 58 | // poll loop keeps flowing; failures reply with a note instead of crashing. |
| 59 | void this.handleVoice(u.message, onMessage); |
| 60 | } else if (u.callback_query) { |
| 61 | const cq = u.callback_query; |
| 62 | onMessage({ platform: 'telegram', chatId: String(cq.message?.chat?.id), userId: String(cq.from?.id), text: '', callbackData: cq.data, callbackId: cq.id }); |
| 63 | } |
| 64 | } |
| 65 | } catch (e: any) { |
| 66 | logger.warn('telegram poll error', { err: e?.message }); |
| 67 | await sleep(2000); |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 |
nothing calls this directly
no outgoing calls
no test coverage detected