diff --git a/adapters/telegram/__tests__/media.test.ts b/adapters/telegram/__tests__/media.test.ts new file mode 100644 index 00000000..8a0886a2 --- /dev/null +++ b/adapters/telegram/__tests__/media.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import { TelegramMediaService } from '../media.js' +import { AttachmentStore } from '../../common/attachment/attachment-store.js' + +let tmpRoot: string +let originalFetch: typeof fetch + +function makeMockBot() { + const fetchMock = mock(async (url: string | URL) => { + const u = typeof url === 'string' ? url : url.toString() + expect(u).toContain('/file/botFAKE_TOKEN/photos/abc.jpg') + return new Response(Buffer.from('PHOTODATA'), { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }) + }) + ;(globalThis as any).fetch = fetchMock + return { + token: 'FAKE_TOKEN', + api: { + getFile: mock(async (fileId: string) => ({ + file_id: fileId, + file_unique_id: 'unique', + file_path: 'photos/abc.jpg', + })), + sendPhoto: mock(async () => ({ message_id: 1 })), + sendDocument: mock(async () => ({ message_id: 2 })), + }, + fetchMock, + } +} + +beforeEach(async () => { + originalFetch = globalThis.fetch + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'tg-media-test-')) +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('TelegramMediaService', () => { + it('downloadFile fetches the real URL and stores a LocalAttachment', async () => { + const bot = makeMockBot() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new TelegramMediaService(bot as any, store) + const local = await svc.downloadFile('fid_123', 'sess-1', { + fileName: 'abc.jpg', + mimeType: 'image/jpeg', + }) + expect(local.kind).toBe('image') + expect(local.name).toBe('abc.jpg') + expect(local.size).toBe('PHOTODATA'.length) + expect(local.buffer.toString()).toBe('PHOTODATA') + const onDisk = await fs.readFile(local.path) + expect(onDisk.toString()).toBe('PHOTODATA') + }) + + it('sendPhoto calls bot.api.sendPhoto with InputFile-like payload', async () => { + const bot = makeMockBot() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new TelegramMediaService(bot as any, store) + await svc.sendPhoto(42, Buffer.from('IMG'), 'caption text') + expect(bot.api.sendPhoto).toHaveBeenCalledTimes(1) + const args = (bot.api.sendPhoto as any).mock.calls[0] + expect(args[0]).toBe(42) + // grammY InputFile wraps the buffer; just verify it's an object. + expect(args[1]).toBeDefined() + expect(args[2]?.caption).toBe('caption text') + }) + + it('sendDocument calls bot.api.sendDocument', async () => { + const bot = makeMockBot() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new TelegramMediaService(bot as any, store) + await svc.sendDocument(42, Buffer.from('DOC'), 'spec.pdf') + expect(bot.api.sendDocument).toHaveBeenCalledTimes(1) + const args = (bot.api.sendDocument as any).mock.calls[0] + expect(args[0]).toBe(42) + expect(args[1]).toBeDefined() + }) +}) diff --git a/adapters/telegram/media.ts b/adapters/telegram/media.ts new file mode 100644 index 00000000..d99ad2f5 --- /dev/null +++ b/adapters/telegram/media.ts @@ -0,0 +1,89 @@ +/** + * Telegram media service — wraps grammY download/upload helpers. + * + * Telegram file download flow: + * 1. bot.api.getFile(file_id) → { file_path } + * 2. GET https://api.telegram.org/file/bot/ + */ + +import { InputFile, type Bot } from 'grammy' +import { AttachmentStore } from '../common/attachment/attachment-store.js' +import type { LocalAttachment } from '../common/attachment/attachment-types.js' + +function extOf(fileName?: string): string { + if (!fileName) return '' + const m = /\.([^./\\]+)$/.exec(fileName) + return m ? m[1]!.toLowerCase() : '' +} + +function classifyKind(mime: string | undefined, fileName: string): 'image' | 'file' { + if (mime?.startsWith('image/')) return 'image' + const ext = extOf(fileName) + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'].includes(ext)) return 'image' + return 'file' +} + +export interface DownloadHint { + fileName?: string + mimeType?: string +} + +export class TelegramMediaService { + constructor( + private readonly bot: Bot, + private readonly store: AttachmentStore, + ) {} + + async downloadFile( + fileId: string, + sessionId: string, + hint: DownloadHint = {}, + ): Promise { + const file = await this.bot.api.getFile(fileId) + if (!file.file_path) { + throw new Error(`[TelegramMedia] getFile returned no file_path for ${fileId}`) + } + const token = (this.bot as unknown as { token: string }).token + const url = `https://api.telegram.org/file/bot${token}/${file.file_path}` + const resp = await fetch(url) + if (!resp.ok) { + throw new Error(`[TelegramMedia] fetch failed: ${resp.status} ${resp.statusText}`) + } + const buffer = Buffer.from(await resp.arrayBuffer()) + const mime = hint.mimeType ?? resp.headers.get('content-type') ?? undefined + const fallbackName = file.file_path.split('/').pop() || fileId + const name = hint.fileName ?? fallbackName + const kind = classifyKind(mime, name) + const target = this.store.resolvePath('telegram', sessionId, name) + await this.store.write(target, buffer) + return { + kind, + name, + path: target, + size: buffer.length, + mimeType: mime ?? (kind === 'image' ? 'image/png' : 'application/octet-stream'), + buffer, + } + } + + async sendPhoto(chatId: number, buffer: Buffer, caption?: string): Promise { + await this.bot.api.sendPhoto( + chatId, + new InputFile(buffer), + caption ? { caption } : undefined, + ) + } + + async sendDocument( + chatId: number, + buffer: Buffer, + fileName: string, + caption?: string, + ): Promise { + await this.bot.api.sendDocument( + chatId, + new InputFile(buffer, fileName), + caption ? { caption } : undefined, + ) + } +}