import type { TgUpdateRaw } from '../shared/telegram.ts' /** * Telegram Bot API client with an injectable base URL so tests run against a * fake server. Long polling only; webhooks are out of scope. */ export interface TgSentMessage { messageId: number } export interface TgFile { fileId: string filePath: string } export class TelegramClient { readonly #base: string readonly #token: string readonly #fileBase: string constructor(token: string, opts: { apiBase?: string; fileBase?: string } = {}) { this.#token = token this.#base = opts.apiBase ?? 'https://api.telegram.org' this.#fileBase = opts.fileBase ?? this.#base } async #call(method: string, body: Record): Promise { const res = await fetch(`${this.#base}/bot${this.#token}/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) const payload = (await res.json().catch(() => ({}))) as { ok?: boolean result?: T description?: string } if (!res.ok || payload.ok !== true) { throw new Error(`telegram ${method} failed: ${res.status} ${payload.description ?? ''}`) } return payload.result as T } getMe(): Promise<{ id: number; username: string }> { return this.#call('getMe', {}) } async getUpdates(offset: number, timeoutSec = 25): Promise { return await this.#call('getUpdates', { offset, timeout: timeoutSec, allowed_updates: ['message'], }) } async sendMessage( chatId: number, text: string, replyToMessageId?: number, ): Promise { const raw = await this.#call<{ message_id: number }>('sendMessage', { chat_id: chatId, text, ...(replyToMessageId !== undefined ? { reply_to_message_id: replyToMessageId } : {}), }) return { messageId: raw.message_id } } async sendMedia( chatId: number, media: { bytes: Uint8Array; type: string; fileName?: string }, ): Promise { const method = mediaTypeToMethod(media.type) const form = new FormData() form.append('chat_id', String(chatId)) const blob = new Blob([media.bytes.slice().buffer as ArrayBuffer], { type: media.type }) form.append(mediaTypeToField(method), blob, media.fileName ?? filenameFor(media.type)) const res = await fetch(`${this.#base}/bot${this.#token}/${method}`, { method: 'POST', body: form, }) const payload = (await res.json().catch(() => ({}))) as { ok?: boolean result?: { message_id: number } description?: string } if (!res.ok || payload.ok !== true || !payload.result) { throw new Error(`telegram ${method} failed: ${res.status} ${payload.description ?? ''}`) } return { messageId: payload.result.message_id } } async getFile(fileId: string): Promise { const raw = await this.#call<{ file_id: string; file_path?: string }>('getFile', { file_id: fileId, }) if (!raw.file_path) throw new Error(`telegram getFile returned no file_path for ${fileId}`) return { fileId: raw.file_id, filePath: raw.file_path } } async downloadFile(filePath: string): Promise { const res = await fetch(`${this.#fileBase}/file/bot${this.#token}/${filePath}`) if (!res.ok) throw new Error(`telegram file download failed: ${res.status}`) return new Uint8Array(await res.arrayBuffer()) } } function mediaTypeToMethod(type: string): string { if (type.startsWith('image/')) return 'sendPhoto' if (type.startsWith('video/')) return 'sendVideo' if (type.startsWith('audio/')) return 'sendAudio' if (type.startsWith('audio')) return 'sendVoice' return 'sendDocument' } function mediaTypeToField(method: string): string { switch (method) { case 'sendPhoto': return 'photo' case 'sendVideo': return 'video' case 'sendAudio': return 'audio' case 'sendVoice': return 'voice' default: return 'document' } } function filenameFor(type: string): string { const ext = type.split('/')[1] ?? 'bin' return `will-media.${ext}` }