mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(telegram): add TelegramMediaService for download/upload
Implements TDD Task 9: wraps grammY bot.api.getFile/sendPhoto/sendDocument with local attachment staging via AttachmentStore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61657d46d1
commit
2e78d3f885
86
adapters/telegram/__tests__/media.test.ts
Normal file
86
adapters/telegram/__tests__/media.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
89
adapters/telegram/media.ts
Normal file
89
adapters/telegram/media.ts
Normal file
@ -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<token>/<file_path>
|
||||
*/
|
||||
|
||||
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<LocalAttachment> {
|
||||
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<void> {
|
||||
await this.bot.api.sendPhoto(
|
||||
chatId,
|
||||
new InputFile(buffer),
|
||||
caption ? { caption } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async sendDocument(
|
||||
chatId: number,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
caption?: string,
|
||||
): Promise<void> {
|
||||
await this.bot.api.sendDocument(
|
||||
chatId,
|
||||
new InputFile(buffer, fileName),
|
||||
caption ? { caption } : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user