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;
+}