diff --git a/packages/bridges/telegram/src/index.test.ts b/packages/bridges/telegram/src/index.test.ts index 38889343..2af36b91 100644 --- a/packages/bridges/telegram/src/index.test.ts +++ b/packages/bridges/telegram/src/index.test.ts @@ -1,4 +1,136 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { contractTestBridge } from '@profullstack/sh1pt-core/testing'; import adapter from './index.js'; -smokeTest(adapter, { idPrefix: 'bridge' }); +contractTestBridge(adapter, { + sampleConfig: {}, + sampleChannel: '-1001234567890', +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('bridge-telegram Bot API integration', () => { + it('sends relayed messages through sendMessage', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, result: { message_id: 42 } }), + } as any); + + const result = await adapter.send(ctx({ TELEGRAM_BRIDGE_BOT_TOKEN: '123:abc' }), '-1001234567890', { + id: 'src-1', + channel: 'discord-1', + identity: { network: 'discord', username: 'alice' }, + text: 'release shipped', + attachments: [{ kind: 'file', url: 'https://example.com/log.txt', filename: 'log.txt' }], + timestamp: '2026-05-21T00:00:00.000Z', + originalNetwork: 'discord', + }, { + baseUrl: 'https://telegram.test', + parseMode: 'HTML', + disableNotification: true, + }); + + expect(result).toEqual({ id: '42' }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://telegram.test/bot123:abc/sendMessage'); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ 'content-type': 'application/json' }); + expect(JSON.parse(String(init.body))).toEqual({ + chat_id: '-1001234567890', + text: 'alice [discord]: release shipped\nfile: https://example.com/log.txt', + parse_mode: 'HTML', + disable_notification: true, + }); + }); + + it('surfaces Telegram sendMessage errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ ok: false, description: 'Bad Request: chat not found' }), + } as any); + + await expect(adapter.send(ctx({ TELEGRAM_BRIDGE_BOT_TOKEN: '123:abc' }), '-100missing', { + id: 'src-1', + channel: 'src', + identity: { network: 'slack', username: 'bob' }, + text: 'hello', + timestamp: '2026-05-21T00:00:00.000Z', + }, { baseUrl: 'https://telegram.test' })).rejects.toThrow('Bad Request: chat not found'); + }); + + it('long-polls updates and maps Telegram messages into bridge messages', async () => { + const abort = new AbortController(); + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: [{ + update_id: 100, + message: { + message_id: 7, + date: 1_779_321_600, + chat: { id: -1001234567890, type: 'supergroup', title: 'Launch Room' }, + from: { id: 5, is_bot: false, first_name: 'Ada', last_name: 'Lovelace', username: 'ada' }, + text: 'ship it', + photo: [{ file_id: 'small' }, { file_id: 'large' }], + }, + }], + }), + } as any) + .mockImplementationOnce(async () => { + abort.abort(); + return { + ok: true, + status: 200, + json: async () => ({ ok: true, result: [] }), + } as any; + }); + + const onMessage = vi.fn(); + const subscription = await adapter.subscribe({ + secret: (key: string) => ({ TELEGRAM_BRIDGE_BOT_TOKEN: '123:abc' })[key], + log: vi.fn(), + signal: abort.signal, + }, ['-1001234567890'], onMessage, { + baseUrl: 'https://telegram.test', + pollTimeoutSeconds: 1, + pollLimit: 10, + }); + + await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1)); + await subscription.close(); + + expect(fetchMock).toHaveBeenCalled(); + const firstUrl = fetchMock.mock.calls[0]?.[0] as URL; + expect(String(firstUrl)).toContain('https://telegram.test/bot123:abc/getUpdates'); + expect(firstUrl.searchParams.get('timeout')).toBe('1'); + expect(firstUrl.searchParams.get('limit')).toBe('10'); + expect(onMessage).toHaveBeenCalledWith({ + id: '7', + channel: '-1001234567890', + identity: { network: 'telegram', username: 'ada', isBot: false }, + text: 'ship it', + attachments: [{ url: 'telegram:file/large', kind: 'image' }], + timestamp: '2026-05-21T00:00:00.000Z', + originalNetwork: 'telegram', + }); + }); +}); + +function ctx(secrets: Record) { + return { + secret(key: string) { + return secrets[key]; + }, + log: vi.fn(), + dryRun: false, + }; +} diff --git a/packages/bridges/telegram/src/index.ts b/packages/bridges/telegram/src/index.ts index 95c4b6ce..db750828 100644 --- a/packages/bridges/telegram/src/index.ts +++ b/packages/bridges/telegram/src/index.ts @@ -1,4 +1,4 @@ -import { defineBridge, tokenSetup } from '@profullstack/sh1pt-core'; +import { defineBridge, tokenSetup, type BridgeMessage } from '@profullstack/sh1pt-core'; // Telegram bridge — Bot API (getUpdates long-polling or webhook) // receive, sendMessage send. "Channels" are chat_ids (channel = -100…, @@ -8,33 +8,101 @@ import { defineBridge, tokenSetup } from '@profullstack/sh1pt-core'; interface Config { useWebhook?: boolean; // if true, receive via webhook instead of long-poll webhookUrl?: string; + tokenKey?: string; // default TELEGRAM_BRIDGE_BOT_TOKEN, falls back to TELEGRAM_BOT_TOKEN + baseUrl?: string; // test/self-hosted Bot API base + pollTimeoutSeconds?: number; + pollLimit?: number; + parseMode?: 'MarkdownV2' | 'HTML'; + disableNotification?: boolean; } +const DEFAULT_API = 'https://api.telegram.org'; +const DEFAULT_TOKEN_KEY = 'TELEGRAM_BRIDGE_BOT_TOKEN'; + export default defineBridge({ id: 'bridge-telegram', label: 'Telegram', async subscribe(ctx, channels, onMessage, config) { - if (!ctx.secret('TELEGRAM_BOT_TOKEN')) throw new Error('TELEGRAM_BOT_TOKEN not in vault'); - ctx.log(`telegram bridge · chats=${channels.length} · mode=${config.useWebhook ? 'webhook' : 'long-poll'}`); - // TODO: - // long-poll: loop GET /getUpdates?offset=&timeout=30 - // webhook: setWebhook once → receive on config.webhookUrl - // Filter updates to chat_ids in `channels`, map to BridgeMessage. - return { async close() {} }; + const token = telegramToken(ctx, config); + if (config.useWebhook) { + throw new Error('bridge-telegram subscribe() currently supports long polling only; unset useWebhook'); + } + + const api = apiBase(config); + const channelSet = new Set(channels.map(String)); + let closed = false; + let offset = 0; + + ctx.log(`telegram bridge · chats=${channels.length} · mode=long-poll`); + + const poll = async () => { + while (!closed && !ctx.signal?.aborted) { + const url = new URL(`${api}/bot${token}/getUpdates`); + url.searchParams.set('timeout', String(config.pollTimeoutSeconds ?? 30)); + url.searchParams.set('limit', String(config.pollLimit ?? 100)); + url.searchParams.set('allowed_updates', JSON.stringify([ + 'message', + 'edited_message', + 'channel_post', + 'edited_channel_post', + ])); + if (offset > 0) url.searchParams.set('offset', String(offset)); + + const res = await fetch(url, { signal: ctx.signal }); + const body = await readTelegramJson(res); + if (!res.ok || !body.ok) { + throw new Error(body.description ?? `Telegram getUpdates failed with ${res.status}`); + } + + for (const update of body.result ?? []) { + offset = Math.max(offset, update.update_id + 1); + const msg = update.message ?? update.edited_message ?? update.channel_post ?? update.edited_channel_post; + const bridged = msg ? toBridgeMessage(msg) : undefined; + if (bridged && channelSet.has(bridged.channel)) { + await onMessage(bridged); + } + } + } + }; + + void poll().catch((err) => { + if (!closed && !ctx.signal?.aborted) ctx.log(`telegram bridge polling stopped: ${err instanceof Error ? err.message : String(err)}`); + }); + + return { + async close() { + closed = true; + }, + }; }, - async send(ctx, channel, msg) { - if (!ctx.secret('TELEGRAM_BOT_TOKEN')) throw new Error('TELEGRAM_BOT_TOKEN not in vault'); + async send(ctx, channel, msg, config) { + const token = telegramToken(ctx, config); ctx.log(`telegram bridge · sendMessage chat=${channel}`); if (ctx.dryRun) return { id: 'dry-run' }; - // TODO: POST /sendMessage { chat_id, text: " []: ", parse_mode } - // For media use sendPhoto / sendVideo / sendDocument with file_id or URL. - return { id: `tg_${Date.now()}` }; + + const res = await fetch(`${apiBase(config)}/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + chat_id: channel, + text: renderTelegramText(msg, config).slice(0, 4096), + parse_mode: config.parseMode, + disable_notification: config.disableNotification, + reply_to_message_id: msg.replyToId ? Number(msg.replyToId) : undefined, + }), + }); + const body = await readTelegramJson(res); + if (!res.ok || !body.ok) { + throw new Error(body.description ?? `Telegram sendMessage failed with ${res.status}`); + } + + return { id: String(body.result?.message_id ?? 'telegram-message') }; }, setup: tokenSetup({ - secretKey: 'TELEGRAM_BRIDGE_BOT_TOKEN', + secretKey: DEFAULT_TOKEN_KEY, label: 'Telegram bridge', vendorDocUrl: 'https://core.telegram.org/bots', steps: [ @@ -44,3 +112,150 @@ export default defineBridge({ ], }), }); + +function telegramToken( + ctx: { secret(k: string): string | undefined }, + config: Config, +): string { + const key = config.tokenKey ?? DEFAULT_TOKEN_KEY; + const token = ctx.secret(key) ?? (key === DEFAULT_TOKEN_KEY ? ctx.secret('TELEGRAM_BOT_TOKEN') : undefined); + if (!token) throw new Error(`${key} not in vault — run: sh1pt secret set ${key} `); + return token; +} + +function apiBase(config: Config): string { + return (config.baseUrl ?? DEFAULT_API).replace(/\/$/, ''); +} + +function renderTelegramText(msg: BridgeMessage, config: Config): string { + const source = `${msg.identity.username} [${msg.originalNetwork ?? msg.identity.network}]`; + const attachmentLines = (msg.attachments ?? []).map((a) => `${a.kind}: ${a.url}`); + const text = [msg.text, ...attachmentLines].filter(Boolean).join('\n'); + + if (config.parseMode === 'MarkdownV2') { + return `*${escapeMarkdown(source)}*: ${escapeMarkdown(text)}`; + } + if (config.parseMode === 'HTML') { + return `${escapeHtml(source)}: ${escapeHtml(text)}`; + } + return `${source}: ${text}`; +} + +function toBridgeMessage(message: TelegramMessage): BridgeMessage { + const author = message.from ?? message.sender_chat ?? { id: message.chat.id, first_name: message.chat.title ?? String(message.chat.id) }; + const username = telegramUsername(author); + return { + id: String(message.message_id), + channel: String(message.chat.id), + identity: { + network: 'telegram', + username, + isBot: 'is_bot' in author ? author.is_bot : undefined, + }, + text: message.text ?? message.caption ?? '', + replyToId: message.reply_to_message?.message_id ? String(message.reply_to_message.message_id) : undefined, + attachments: telegramAttachments(message), + timestamp: new Date(message.date * 1000).toISOString(), + originalNetwork: 'telegram', + }; +} + +function telegramUsername(user: TelegramUser | TelegramChat): string { + if ('username' in user && user.username) return user.username; + if ('first_name' in user) return [user.first_name, user.last_name].filter(Boolean).join(' '); + return user.title ?? String(user.id); +} + +function telegramAttachments(message: TelegramMessage): BridgeMessage['attachments'] { + const attachments: NonNullable = []; + const photo = message.photo?.at(-1); + if (photo?.file_id) attachments.push({ url: `telegram:file/${photo.file_id}`, kind: 'image' }); + if (message.video?.file_id) attachments.push({ url: `telegram:file/${message.video.file_id}`, kind: 'video', mimeType: message.video.mime_type }); + if (message.audio?.file_id) attachments.push({ url: `telegram:file/${message.audio.file_id}`, kind: 'audio', mimeType: message.audio.mime_type }); + if (message.document?.file_id) { + attachments.push({ + url: `telegram:file/${message.document.file_id}`, + kind: 'file', + filename: message.document.file_name, + mimeType: message.document.mime_type, + }); + } + return attachments.length > 0 ? attachments : undefined; +} + +async function readTelegramJson(res: Response): Promise { + try { + return await res.json() as T & TelegramApiResponse; + } catch { + return { ok: res.ok, description: res.statusText } as T & TelegramApiResponse; + } +} + +function escapeMarkdown(value: string): string { + return value.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, (char) => `\\${char}`); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +interface TelegramApiResponse { + ok?: boolean; + description?: string; +} + +interface TelegramUpdatesResponse extends TelegramApiResponse { + result?: TelegramUpdate[]; +} + +interface TelegramSendResponse extends TelegramApiResponse { + result?: { + message_id?: number; + }; +} + +interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + edited_message?: TelegramMessage; + channel_post?: TelegramMessage; + edited_channel_post?: TelegramMessage; +} + +interface TelegramMessage { + message_id: number; + date: number; + chat: TelegramChat; + from?: TelegramUser; + sender_chat?: TelegramChat; + text?: string; + caption?: string; + reply_to_message?: { message_id: number }; + photo?: Array<{ file_id: string }>; + video?: TelegramFile; + audio?: TelegramFile; + document?: TelegramFile & { file_name?: string }; +} + +interface TelegramUser { + id: number; + is_bot?: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +interface TelegramChat { + id: number; + type?: string; + title?: string; + username?: string; +} + +interface TelegramFile { + file_id: string; + mime_type?: string; +}