Luigit
repositories / will

will

owned by admin

src/agent/telegram-client.ts

Raw
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<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}`
}