diff --git a/connectors/telegram/CLAUDE.md b/connectors/telegram/CLAUDE.md index 2be476080..9615c4552 100644 --- a/connectors/telegram/CLAUDE.md +++ b/connectors/telegram/CLAUDE.md @@ -26,6 +26,7 @@ bun run dev --help bun run dev me bun run dev send bun run dev updates +bun run dev get-file ``` ## Code Style @@ -105,6 +106,8 @@ connect-telegram send-document # Send document connect-telegram updates # Get recent updates connect-telegram updates -l 20 # Get 20 updates connect-telegram updates -o 12345 # Start from offset +connect-telegram get-file # Download using Telegram's file name +connect-telegram get-file -o ./image.jpg # Download to an explicit path ``` ### Chats @@ -185,6 +188,10 @@ const message = await telegram.messages.sendMessage({ // Get updates const updates = await telegram.updates.getUpdates({ limit: 10 }); +// Resolve or download incoming media by the file_id shown in updates +const file = await telegram.bot.getFile({ fileId: 'FILE_ID' }); +const downloaded = await telegram.bot.downloadFile({ fileId: 'FILE_ID' }); + // Get chat info const chat = await telegram.chats.getChat({ chatId: '@channelname' }); ``` diff --git a/connectors/telegram/src/api/bot.ts b/connectors/telegram/src/api/bot.ts index 6b95bac31..b639d76d6 100644 --- a/connectors/telegram/src/api/bot.ts +++ b/connectors/telegram/src/api/bot.ts @@ -59,6 +59,11 @@ export interface GetFileOptions { fileId: string; } +export interface DownloadFileResult { + file: TelegramFile; + data: Uint8Array; +} + export interface BotName { name: string; } @@ -220,4 +225,19 @@ export class BotApi { }, }); } + + /** + * Resolve and download a Telegram file + */ + async downloadFile(options: GetFileOptions): Promise { + const file = await this.getFile(options); + if (!file.file_path) { + throw new Error('Telegram did not return a downloadable file path'); + } + + return { + file, + data: await this.client.downloadFile(file.file_path), + }; + } } diff --git a/connectors/telegram/src/api/client.ts b/connectors/telegram/src/api/client.ts index f8e06a449..d342ea806 100644 --- a/connectors/telegram/src/api/client.ts +++ b/connectors/telegram/src/api/client.ts @@ -118,6 +118,38 @@ export class TelegramClient { return this.request(method, { body: formData }); } + /** + * Download a file returned by the Telegram Bot API + */ + async downloadFile(filePath: string): Promise { + const normalizedPath = filePath.replace(/^\/+/, ''); + if (!normalizedPath) { + throw new Error('Telegram file path is required'); + } + + const encodedPath = normalizedPath + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + const url = `${TELEGRAM_API_BASE}/file/bot${this.botToken}/${encodedPath}`; + + let response: Response; + try { + response = await fetch(url); + } catch { + throw new Error('Failed to download Telegram file'); + } + + if (!response.ok) { + throw new TelegramApiError( + `Telegram file download failed with HTTP ${response.status}`, + response.status + ); + } + + return new Uint8Array(await response.arrayBuffer()); + } + /** * Get a preview of the bot token (for display/debugging) */ diff --git a/connectors/telegram/src/api/files.test.ts b/connectors/telegram/src/api/files.test.ts new file mode 100644 index 000000000..aad2246dc --- /dev/null +++ b/connectors/telegram/src/api/files.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { Telegram } from './index'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('Telegram files API', () => { + test('resolves a file ID and downloads its bytes from the authenticated file URL', async () => { + const botToken = '123456:test-token'; + const fetchMock = mock(async ( + input: string | URL | Request, + _init?: RequestInit + ) => { + const url = String(input); + if (url.endsWith('/getFile')) { + return Response.json({ + ok: true, + result: { + file_id: 'incoming-file-id', + file_unique_id: 'unique-file-id', + file_size: 4, + file_path: 'documents/report 1.pdf', + }, + }); + } + + return new Response(new Uint8Array([1, 2, 3, 4])); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const telegram = new Telegram({ botToken }); + const downloaded = await telegram.bot.downloadFile({ + fileId: 'incoming-file-id', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + `https://api.telegram.org/bot${botToken}/getFile` + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + file_id: 'incoming-file-id', + }); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + `https://api.telegram.org/file/bot${botToken}/documents/report%201.pdf` + ); + expect(downloaded.file.file_path).toBe('documents/report 1.pdf'); + expect(downloaded.data).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + test('fails explicitly when Telegram does not return a file path', async () => { + const fetchMock = mock(async () => + Response.json({ + ok: true, + result: { + file_id: 'incoming-file-id', + file_unique_id: 'unique-file-id', + }, + }) + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const telegram = new Telegram({ botToken: '123456:test-token' }); + + expect( + telegram.bot.downloadFile({ fileId: 'incoming-file-id' }) + ).rejects.toThrow('Telegram did not return a downloadable file path'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('does not expose the bot token in download errors', async () => { + const botToken = '123456:must-not-leak'; + const fetchMock = mock(async ( + input: string | URL | Request, + _init?: RequestInit + ) => { + if (String(input).endsWith('/getFile')) { + return Response.json({ + ok: true, + result: { + file_id: 'incoming-file-id', + file_unique_id: 'unique-file-id', + file_path: 'photos/file.jpg', + }, + }); + } + return new Response('not found', { status: 404 }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const telegram = new Telegram({ botToken }); + + try { + await telegram.bot.downloadFile({ fileId: 'incoming-file-id' }); + throw new Error('Expected download to fail'); + } catch (err) { + expect(String(err)).toContain('Telegram file download failed'); + expect(String(err)).not.toContain(botToken); + } + }); +}); diff --git a/connectors/telegram/src/api/index.ts b/connectors/telegram/src/api/index.ts index 31964c7bd..4bf0748fd 100644 --- a/connectors/telegram/src/api/index.ts +++ b/connectors/telegram/src/api/index.ts @@ -64,3 +64,4 @@ export { ChatsApi } from './chats'; export { UpdatesApi } from './updates'; export { InlineApi } from './inline'; export { BotApi } from './bot'; +export type { DownloadFileResult } from './bot'; diff --git a/connectors/telegram/src/api/messages.test.ts b/connectors/telegram/src/api/messages.test.ts index be62daad0..044c026f3 100644 --- a/connectors/telegram/src/api/messages.test.ts +++ b/connectors/telegram/src/api/messages.test.ts @@ -9,7 +9,10 @@ afterEach(() => { describe("Telegram messages API", () => { test("sends raw exclamation marks unless MarkdownV2 parse mode is requested", async () => { - const fetchMock = mock(async () => + const fetchMock = mock(async ( + _input: string | URL | Request, + _init?: RequestInit + ) => Response.json({ ok: true, result: { @@ -37,7 +40,10 @@ describe("Telegram messages API", () => { }); test("preserves raw text when HTML parse mode is explicit", async () => { - const fetchMock = mock(async () => + const fetchMock = mock(async ( + _input: string | URL | Request, + _init?: RequestInit + ) => Response.json({ ok: true, result: { diff --git a/connectors/telegram/src/cli/files.test.ts b/connectors/telegram/src/cli/files.test.ts new file mode 100644 index 000000000..f9ea8928e --- /dev/null +++ b/connectors/telegram/src/cli/files.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { resolveDownloadPath, writeDownloadedFile } from './files'; + +let temporaryDirectory: string | undefined; + +afterEach(() => { + if (temporaryDirectory) { + rmSync(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } +}); + +describe('Telegram CLI file downloads', () => { + test('uses Telegram file name when the output is a directory', () => { + temporaryDirectory = mkdtempSync(join(tmpdir(), 'connect-telegram-')); + + expect(resolveDownloadPath('photos/file_42.jpg', temporaryDirectory)).toBe( + join(temporaryDirectory, 'file_42.jpg') + ); + }); + + test('creates parent directories without overwriting an existing file', () => { + temporaryDirectory = mkdtempSync(join(tmpdir(), 'connect-telegram-')); + const destination = join(temporaryDirectory, 'nested', 'file.bin'); + + writeDownloadedFile(destination, new Uint8Array([1, 2, 3])); + expect(new Uint8Array(readFileSync(destination))).toEqual( + new Uint8Array([1, 2, 3]) + ); + expect(() => + writeDownloadedFile(destination, new Uint8Array([4, 5, 6])) + ).toThrow('Refusing to overwrite existing file'); + expect(new Uint8Array(readFileSync(destination))).toEqual( + new Uint8Array([1, 2, 3]) + ); + }); +}); diff --git a/connectors/telegram/src/cli/files.ts b/connectors/telegram/src/cli/files.ts new file mode 100644 index 000000000..f6490dabe --- /dev/null +++ b/connectors/telegram/src/cli/files.ts @@ -0,0 +1,38 @@ +import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; +import { basename, dirname, join, resolve, sep } from 'path'; + +export function resolveDownloadPath( + telegramFilePath: string, + outputPath?: string +): string { + const defaultName = basename(telegramFilePath); + if (!defaultName || defaultName === '.' || defaultName === sep) { + throw new Error('Telegram did not return a usable file name'); + } + + if (!outputPath) { + return resolve(defaultName); + } + + const requestedPath = resolve(outputPath); + if ( + (existsSync(requestedPath) && statSync(requestedPath).isDirectory()) || + (!existsSync(requestedPath) && outputPath.endsWith(sep)) + ) { + return join(requestedPath, defaultName); + } + + return requestedPath; +} + +export function writeDownloadedFile( + destinationPath: string, + data: Uint8Array +): void { + if (existsSync(destinationPath)) { + throw new Error(`Refusing to overwrite existing file: ${destinationPath}`); + } + + mkdirSync(dirname(destinationPath), { recursive: true }); + writeFileSync(destinationPath, data, { flag: 'wx' }); +} diff --git a/connectors/telegram/src/cli/index.ts b/connectors/telegram/src/cli/index.ts index 208d4d5b0..8584130e4 100644 --- a/connectors/telegram/src/cli/index.ts +++ b/connectors/telegram/src/cli/index.ts @@ -4,6 +4,8 @@ import chalk from 'chalk'; import { readFileSync, existsSync } from 'fs'; import { basename } from 'path'; import { Telegram } from '../api'; +import { resolveDownloadPath, writeDownloadedFile } from './files'; +import { formatUpdate } from './updates'; import { getBotToken, setBotToken, @@ -455,36 +457,7 @@ program return; } - // Format updates for display - const formatted = updates.map(u => { - const result: Record = { - update_id: u.update_id, - }; - - if (u.message) { - result.type = 'message'; - result.from = u.message.from?.username || u.message.from?.first_name || 'unknown'; - result.chat_id = u.message.chat.id; - result.text = u.message.text || '[media]'; - result.date = new Date(u.message.date * 1000).toISOString(); - } else if (u.callback_query) { - result.type = 'callback_query'; - result.from = u.callback_query.from.username || u.callback_query.from.first_name; - result.data = u.callback_query.data; - } else if (u.inline_query) { - result.type = 'inline_query'; - result.from = u.inline_query.from.username || u.inline_query.from.first_name; - result.query = u.inline_query.query; - } else if (u.edited_message) { - result.type = 'edited_message'; - result.chat_id = u.edited_message.chat.id; - } else if (u.channel_post) { - result.type = 'channel_post'; - result.chat_id = u.channel_post.chat.id; - } - - return result; - }); + const formatted = updates.map(formatUpdate); print(formatted, getFormat(program)); info(`Showing ${updates.length} update(s). Last update_id: ${updates[updates.length - 1].update_id}`); @@ -494,6 +467,30 @@ program } }); +program + .command('get-file ') + .description('Download a Telegram file by file ID') + .option('-o, --output ', 'Output file or directory (defaults to the Telegram file name)') + .action(async (fileId: string, opts: { output?: string }) => { + try { + const client = getClient(); + const downloaded = await client.bot.downloadFile({ fileId }); + const destination = resolveDownloadPath(downloaded.file.file_path!, opts.output); + writeDownloadedFile(destination, downloaded.data); + + print({ + file_id: downloaded.file.file_id, + file_unique_id: downloaded.file.file_unique_id, + file_path: downloaded.file.file_path, + output: destination, + bytes: downloaded.data.byteLength, + }, getFormat(program)); + } catch (err) { + error(String(err)); + process.exit(1); + } + }); + // ============================================ // Chat Commands // ============================================ diff --git a/connectors/telegram/src/cli/updates.test.ts b/connectors/telegram/src/cli/updates.test.ts new file mode 100644 index 000000000..893bc545f --- /dev/null +++ b/connectors/telegram/src/cli/updates.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test'; +import type { TelegramMessage, TelegramUpdate } from '../types'; +import { formatUpdate } from './updates'; + +function updateWith(message: Partial): TelegramUpdate { + return { + update_id: 428687256, + message: { + message_id: 42, + date: 1, + chat: { id: 123, type: 'private' }, + from: { + id: 456, + is_bot: false, + first_name: 'Andrei', + username: 'andrei', + }, + ...message, + }, + }; +} + +describe('Telegram update formatting', () => { + test('exposes the largest incoming photo file ID and caption', () => { + const formatted = formatUpdate(updateWith({ + caption: 'Please inspect this screenshot', + photo: [ + { + file_id: 'small-photo-id', + file_unique_id: 'small-unique-id', + width: 90, + height: 90, + file_size: 100, + }, + { + file_id: 'large-photo-id', + file_unique_id: 'large-unique-id', + width: 1280, + height: 720, + file_size: 5000, + }, + ], + })); + + expect(formatted).toMatchObject({ + update_id: 428687256, + type: 'message', + message_id: 42, + caption: 'Please inspect this screenshot', + media: { + type: 'photo', + file_id: 'large-photo-id', + file_unique_id: 'large-unique-id', + width: 1280, + height: 720, + file_size: 5000, + }, + }); + expect(formatted).not.toHaveProperty('text', '[media]'); + }); + + test('exposes actionable metadata for every typed downloadable media kind', () => { + const cases: Array<{ + mediaType: string; + message: Partial; + fileId: string; + expected: Record; + }> = [ + { + mediaType: 'animation', + fileId: 'animation-id', + message: { + animation: { + file_id: 'animation-id', + file_unique_id: 'animation-unique-id', + width: 640, + height: 360, + duration: 4, + file_name: 'instruction.gif', + mime_type: 'image/gif', + file_size: 2500, + }, + }, + expected: { + width: 640, + height: 360, + duration: 4, + file_name: 'instruction.gif', + mime_type: 'image/gif', + }, + }, + { + mediaType: 'document', + fileId: 'document-id', + message: { + document: { + file_id: 'document-id', + file_unique_id: 'document-unique-id', + file_name: 'instructions.pdf', + mime_type: 'application/pdf', + file_size: 1000, + }, + }, + expected: { + file_name: 'instructions.pdf', + mime_type: 'application/pdf', + }, + }, + { + mediaType: 'audio', + fileId: 'audio-id', + message: { + audio: { + file_id: 'audio-id', + file_unique_id: 'audio-unique-id', + duration: 12, + file_name: 'note.mp3', + mime_type: 'audio/mpeg', + }, + }, + expected: { duration: 12, file_name: 'note.mp3', mime_type: 'audio/mpeg' }, + }, + { + mediaType: 'video', + fileId: 'video-id', + message: { + video: { + file_id: 'video-id', + file_unique_id: 'video-unique-id', + width: 1920, + height: 1080, + duration: 8, + file_name: 'screen.mp4', + mime_type: 'video/mp4', + }, + }, + expected: { width: 1920, height: 1080, duration: 8, file_name: 'screen.mp4' }, + }, + { + mediaType: 'voice', + fileId: 'voice-id', + message: { + voice: { + file_id: 'voice-id', + file_unique_id: 'voice-unique-id', + duration: 5, + mime_type: 'audio/ogg', + }, + }, + expected: { duration: 5, mime_type: 'audio/ogg' }, + }, + { + mediaType: 'video_note', + fileId: 'video-note-id', + message: { + video_note: { + file_id: 'video-note-id', + file_unique_id: 'video-note-unique-id', + length: 384, + duration: 6, + }, + }, + expected: { length: 384, duration: 6 }, + }, + ]; + + for (const testCase of cases) { + const formatted = formatUpdate(updateWith(testCase.message)); + expect(formatted.media).toMatchObject({ + type: testCase.mediaType, + file_id: testCase.fileId, + ...testCase.expected, + }); + } + }); +}); diff --git a/connectors/telegram/src/cli/updates.ts b/connectors/telegram/src/cli/updates.ts new file mode 100644 index 000000000..477532e70 --- /dev/null +++ b/connectors/telegram/src/cli/updates.ts @@ -0,0 +1,95 @@ +import type { + TelegramAnimation, + TelegramAudio, + TelegramDocument, + TelegramMessage, + TelegramPhotoSize, + TelegramUpdate, + TelegramVideo, + TelegramVideoNote, + TelegramVoice, +} from '../types'; + +type TelegramMedia = + | ({ type: 'photo' } & TelegramPhotoSize) + | ({ type: 'animation' } & TelegramAnimation) + | ({ type: 'document' } & TelegramDocument) + | ({ type: 'audio' } & TelegramAudio) + | ({ type: 'video' } & TelegramVideo) + | ({ type: 'voice' } & TelegramVoice) + | ({ type: 'video_note' } & TelegramVideoNote); + +export function getMessageMedia(message: TelegramMessage): TelegramMedia | undefined { + const photo = message.photo?.at(-1); + if (photo) { + return { type: 'photo', ...photo }; + } + if (message.animation) { + return { type: 'animation', ...message.animation }; + } + if (message.document) { + return { type: 'document', ...message.document }; + } + if (message.audio) { + return { type: 'audio', ...message.audio }; + } + if (message.video) { + return { type: 'video', ...message.video }; + } + if (message.voice) { + return { type: 'voice', ...message.voice }; + } + if (message.video_note) { + return { type: 'video_note', ...message.video_note }; + } + return undefined; +} + +function formatMessage( + result: Record, + type: string, + message: TelegramMessage +): void { + result.type = type; + result.message_id = message.message_id; + result.from = message.from?.username || message.from?.first_name || 'unknown'; + result.chat_id = message.chat.id; + if (message.text !== undefined) { + result.text = message.text; + } + if (message.caption !== undefined) { + result.caption = message.caption; + } + + const media = getMessageMedia(message); + if (media) { + result.media = media; + } + result.date = new Date(message.date * 1000).toISOString(); +} + +export function formatUpdate(update: TelegramUpdate): Record { + const result: Record = { + update_id: update.update_id, + }; + + if (update.message) { + formatMessage(result, 'message', update.message); + } else if (update.callback_query) { + result.type = 'callback_query'; + result.from = update.callback_query.from.username || update.callback_query.from.first_name; + result.data = update.callback_query.data; + } else if (update.inline_query) { + result.type = 'inline_query'; + result.from = update.inline_query.from.username || update.inline_query.from.first_name; + result.query = update.inline_query.query; + } else if (update.edited_message) { + formatMessage(result, 'edited_message', update.edited_message); + } else if (update.channel_post) { + formatMessage(result, 'channel_post', update.channel_post); + } else if (update.edited_channel_post) { + formatMessage(result, 'edited_channel_post', update.edited_channel_post); + } + + return result; +} diff --git a/connectors/telegram/src/index.ts b/connectors/telegram/src/index.ts index 11b52f59f..dbc60db3c 100644 --- a/connectors/telegram/src/index.ts +++ b/connectors/telegram/src/index.ts @@ -6,6 +6,7 @@ export * from './types'; // Re-export individual API classes for advanced usage export { TelegramClient, MessagesApi, ChatsApi, UpdatesApi, InlineApi, BotApi } from './api'; +export type { DownloadFileResult } from './api'; // Export config utilities export { diff --git a/connectors/telegram/src/types/index.ts b/connectors/telegram/src/types/index.ts index 031854334..1e4fb2021 100644 --- a/connectors/telegram/src/types/index.ts +++ b/connectors/telegram/src/types/index.ts @@ -156,6 +156,7 @@ export interface TelegramMessage { entities?: TelegramMessageEntity[]; caption?: string; caption_entities?: TelegramMessageEntity[]; + animation?: TelegramAnimation; audio?: TelegramAudio; document?: TelegramDocument; photo?: TelegramPhotoSize[]; @@ -203,6 +204,18 @@ export interface TelegramPhotoSize { file_size?: number; } +export interface TelegramAnimation { + file_id: string; + file_unique_id: string; + width: number; + height: number; + duration: number; + thumbnail?: TelegramPhotoSize; + file_name?: string; + mime_type?: string; + file_size?: number; +} + export interface TelegramAudio { file_id: string; file_unique_id: string;