Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions connectors/telegram/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ bun run dev --help
bun run dev me
bun run dev send <chatId> <text>
bun run dev updates
bun run dev get-file <fileId>
```

## Code Style
Expand Down Expand Up @@ -105,6 +106,8 @@ connect-telegram send-document <chatId> <path> # 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 <fileId> # Download using Telegram's file name
connect-telegram get-file <fileId> -o ./image.jpg # Download to an explicit path
```

### Chats
Expand Down Expand Up @@ -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' });
```
Expand Down
20 changes: 20 additions & 0 deletions connectors/telegram/src/api/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export interface GetFileOptions {
fileId: string;
}

export interface DownloadFileResult {
file: TelegramFile;
data: Uint8Array;
}

export interface BotName {
name: string;
}
Expand Down Expand Up @@ -220,4 +225,19 @@ export class BotApi {
},
});
}

/**
* Resolve and download a Telegram file
*/
async downloadFile(options: GetFileOptions): Promise<DownloadFileResult> {
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),
};
}
}
32 changes: 32 additions & 0 deletions connectors/telegram/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,38 @@ export class TelegramClient {
return this.request<T>(method, { body: formData });
}

/**
* Download a file returned by the Telegram Bot API
*/
async downloadFile(filePath: string): Promise<Uint8Array> {
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)
*/
Expand Down
103 changes: 103 additions & 0 deletions connectors/telegram/src/api/files.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
1 change: 1 addition & 0 deletions connectors/telegram/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
10 changes: 8 additions & 2 deletions connectors/telegram/src/api/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
40 changes: 40 additions & 0 deletions connectors/telegram/src/cli/files.test.ts
Original file line number Diff line number Diff line change
@@ -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])
);
});
});
38 changes: 38 additions & 0 deletions connectors/telegram/src/cli/files.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
57 changes: 27 additions & 30 deletions connectors/telegram/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -455,36 +457,7 @@ program
return;
}

// Format updates for display
const formatted = updates.map(u => {
const result: Record<string, unknown> = {
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}`);
Expand All @@ -494,6 +467,30 @@ program
}
});

program
.command('get-file <fileId>')
.description('Download a Telegram file by file ID')
.option('-o, --output <path>', '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
// ============================================
Expand Down
Loading
Loading