repositories / will
will
owned by admin
src/agent/telegram-client.ts
Rawimport 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<T>(method: string, body: Record<string, unknown>): Promise<T> {
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<TgUpdateRaw[]> {
return await this.#call('getUpdates', {
offset,
timeout: timeoutSec,
allowed_updates: ['message'],
})
}
async sendMessage(
chatId: number,
text: string,
replyToMessageId?: number,
): Promise<TgSentMessage> {
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<TgSentMessage> {
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<TgFile> {
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<Uint8Array> {
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}`
}