/** * Telegram connector — Bot API via a @BotFather bot token. * * Auth: no OAuth. A connection is a bot token (secret field `botToken`) plus * the target channel chat id (field `channelId`, numeric id or @username). * Liveness check is GET /bot/getMe. * * Honest capability limits (verified against the Bot API): * - Bots cannot back-read channel history → `feed` is false; getUpdates only * sees updates that arrive after the bot was added, so we never fake a feed. * - Channel-post comment threads are not reachable through the Bot API → * `comments` is false. * - Bots cannot read arbitrary DM conversations → `directMessages` is false. * - sendVideo exists, but Telegram exposes no upload/processing lifecycle a * connector could poll, so `publishVideo` stays false rather than reporting * a fake state machine. Photos are supported (publishImages) via * sendPhoto/sendMediaGroup. * - Amplification maps to forwarding: `amplifyShare` = forwardMessage, or * copyMessage when commentary is supplied (a forward cannot carry a caption). * has_protected_content surfaces as a ConnectorError, never a silent no-op. * - Webhooks: setWebhook with a secret_token; handleWebhook verifies the * X-Telegram-Bot-Api-Secret-Token header before trusting the payload. * * Rate limits: ~30 messages/s globally, ~20/min per group. We run the SDK's * RateLimitedHttpClient at 1 request/s by default — well inside both bounds. * * Delivery safety: Bot API send methods take no idempotency key and offer no * lookup, so a send is never resent automatically. 429 surfaces as * RateLimitError (retry_after from the body), other refusals as * ProviderRejectedError, 5xx/transport loss as unknown-delivery SDK errors. * An injected providerMetadata.idempotencyKey is echoed into the returned * Publication for receipt correlation only. The Publication's id and * draftId ("direct") are local placeholders, never authoritative. */ import { randomBytes } from "node:crypto"; import type { ConnectorCapabilities, ConnectorConnection, MediaAsset, NormalizedIdentity, NormalizedPost, NormalizedWebhookEvent, Publication, RemoteMedia, } from "@tactical-media-broadcast-hub/core"; import { assertValidManifest, AuthError, ConnectorError, InMemoryCredentials, ProviderRejectedError, RateLimitError, RateLimitedHttpClient, type HttpRequestOptions, type ConnectorManifest, type CredentialAccessor, type PlatformConnector, } from "@tactical-media-broadcast-hub/connector-sdk"; const PROVIDER = "telegram"; const API_BASE = "https://api.telegram.org"; const TEXT_LIMIT = 4096; const CAPTION_LIMIT = 1024; const WEBHOOK_SECRET_HEADER = "x-telegram-bot-api-secret-token"; const WEBHOOK_SECRET_KEY = "telegramWebhookSecret"; const CAPABILITIES: ConnectorCapabilities = { publishText: true, publishImages: true, publishVideo: false, feed: false, directMessages: false, comments: false, amplifyShare: true, webhooks: true, deletePublication: true, }; export interface TelegramConnectorOptions { /** fetch override for tests (same pattern as the other connectors). */ fetchImpl?: typeof fetch; /** * Requests per second handed to the rate limiter. Default 1 — Telegram * allows ~30 msg/s globally and ~20/min per group, so 1/s is safe for * publishing. Tests raise this to run unthrottled. */ requestsPerSecond?: number; /** Sleep override for the rate limiter (tests). */ sleep?: (ms: number) => Promise; /** Secret store. Default: in-memory (dev + contract tests). */ credentials?: CredentialAccessor; /** Environment read for TELEGRAM_WEBHOOK_SECRET / TELEGRAM_WEBHOOK_URL. */ env?: Record; /** Public https base the platform serves webhooks under. */ webhookPublicUrl?: string; /** Resolve a media asset id to its metadata (needed by publish with media). */ loadAsset?: (assetId: string) => Promise; /** Read asset bytes for multipart upload (needed by publish/uploadMedia). */ openAsset?: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }>; } interface TelegramUser { id: number; is_bot: boolean; first_name: string; username?: string; } interface TelegramChat { id: number; type: string; title?: string; username?: string; } interface TelegramPhotoSize { file_id: string; file_unique_id: string; width: number; height: number; } interface TelegramMessage { message_id: number; date: number; chat: TelegramChat; text?: string; photo?: TelegramPhotoSize[]; } /** Typed hand-off for WP15 archive consumers; webhook behavior stays unchanged. */ export interface TelegramArchiveWebhookUpdate { update_id: number; channel_post?: Record; edited_channel_post?: Record; my_chat_member?: Record; } export function archiveUpdateFromWebhook(event: NormalizedWebhookEvent): TelegramArchiveWebhookUpdate | null { if (event.provider !== PROVIDER || !["channel_post", "edited_channel_post", "my_chat_member"].includes(event.eventType)) return null; const payload = event.payload; if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null; const update = payload as Record; if (!Number.isSafeInteger(update["update_id"])) return null; return update as unknown as TelegramArchiveWebhookUpdate; } interface TelegramWebhookInfo { url: string; has_custom_certificate: boolean; pending_update_count: number; } type TelegramResponse = | { ok: true; result: T } | { ok: false; description: string; error_code?: number; parameters?: { retry_after?: number } }; const UPDATE_EVENT_KEYS = [ "message", "edited_message", "channel_post", "edited_channel_post", "callback_query", "my_chat_member", ] as const; function escapeHtml(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } function jsonPost(payload: Record): RequestInit { return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }; } /** Largest photo variant is the source of truth for dimensions/file ids. */ function largestPhoto(photo: TelegramPhotoSize[]): TelegramPhotoSize | undefined { return photo.reduce( (best, size) => (best === undefined || size.width > best.width ? size : best), undefined, ); } export function createTelegramConnector(options: TelegramConnectorOptions = {}): PlatformConnector { const client = new RateLimitedHttpClient({ requestsPerSecond: options.requestsPerSecond ?? 1, fetchImpl: options.fetchImpl, sleep: options.sleep, }); const credentials = options.credentials ?? new InMemoryCredentials(); const env = options.env ?? process.env; /** Generated webhook secrets seen by this instance: secret → connection id. */ const webhookSecrets = new Map(); /** Resolved public usernames per chat target, so deep links cost one getChat. */ const chatUsernames = new Map(); let publicationSeq = 0; const manifest: ConnectorManifest = { id: PROVIDER, displayName: "Telegram", capabilities: CAPABILITIES, connectionFields: [ { name: "botToken", label: "Bot token (@BotFather)", required: true, secret: true, placeholder: "123456789:AA…", }, { name: "channelId", label: "Channel chat id or @username", required: true, secret: false, placeholder: "@yourchannel or -1001234567890", pattern: "^@[A-Za-z0-9_]{4,}$|^-?\\d{3,}$", }, ], oauth: { kind: "token", targetField: "channelId", connectNote: "Link a bot token from BotFather; select a chat or channel to publish to. Revoke the bot token in BotFather." }, webhookRoute: "/webhooks/telegram", docsUrl: "https://core.telegram.org/bots/api", }; async function botToken(connection: ConnectorConnection): Promise { const field = connection.fields["botToken"]; if (field !== undefined && field.length > 0) return field; try { return await credentials.getSecret(connection, "botToken"); } catch { throw new AuthError( `${PROVIDER}: missing bot token — set the botToken connection field or vault secret`, PROVIDER, false, ); } } function chatTarget(connection: ConnectorConnection): string { const field = connection.fields["channelId"]; const target = field !== undefined && field.length > 0 ? field : connection.channelId; if (target.length === 0) { throw new ConnectorError(`${PROVIDER}: connection has no channelId`, PROVIDER); } return target; } function toError(body: { description: string; error_code?: number; parameters?: { retry_after?: number }; }): Error { const code = body.error_code ?? 0; const description = body.description ?? "unknown Telegram error"; // 404 = unknown bot token path, 401 = revoked token. if (code === 401 || code === 404 || /unauthorized/i.test(description)) { return new AuthError(`${PROVIDER}: ${description}`, PROVIDER, false); } if (code === 429) { const retryAfter = body.parameters?.retry_after; return new RateLimitError(PROVIDER, retryAfter === undefined ? undefined : retryAfter * 1000); } // The Bot API answered ok:false, so the request was refused, not applied. return new ProviderRejectedError(`${PROVIDER}: ${description}`, PROVIDER, code >= 400 ? code : 400); } async function call( token: string, method: string, init: RequestInit = {}, requestOptions: HttpRequestOptions = {}, ): Promise { const response = await client.request(PROVIDER, `${API_BASE}/bot${token}/${method}`, init, requestOptions); let body: TelegramResponse; try { body = (await response.json()) as TelegramResponse; } catch (cause) { throw new ConnectorError( `${PROVIDER}: non-JSON reply from ${method} (HTTP ${response.status})`, PROVIDER, cause, ); } if (!body.ok) throw toError(body); return body.result; } /** * Public username of the chat, when it has one — needed for t.me deep links. * Numeric channel ids are resolved once via getChat; private chats have none. */ async function resolveChatUsername(token: string, target: string): Promise { const cached = chatUsernames.get(target); if (cached !== undefined) return cached; if (target.startsWith("@")) { const username = target.slice(1); chatUsernames.set(target, username); return username; } try { // getChat is a read-only POST: safe to retry on 429/5xx. const chat = await call(token, "getChat", jsonPost({ chat_id: target }), { retry: true }); if (chat.username === undefined) return undefined; chatUsernames.set(target, chat.username); return chat.username; } catch { // Private or inaccessible chat: no deep link, publishing still fine. return undefined; } } function toPublication( connection: ConnectorConnection, draftId: string, message: { messageId: number; at: Date }, providerMetadata: Record, username: string | undefined, ): Publication { const remoteId = String(message.messageId); return { id: `tg-${(publicationSeq += 1)}-${remoteId}`, workspaceId: connection.workspaceId, draftId, connectionId: connection.id, remoteId, remoteUrl: username === undefined ? undefined : `https://t.me/${username}/${remoteId}`, publishedAt: message.at, providerMetadata, }; } /** * `chatId:messageId` references carry the source chat (forwards live in the * destination chat, so bare ids always mean this connection's channel). */ function parseTargetReference( connection: ConnectorConnection, remoteId: string, ): { chatId: string; messageId: number } { const separator = remoteId.indexOf(":"); if (separator === -1) { const messageId = Number(remoteId); if (!Number.isInteger(messageId)) { throw new ConnectorError(`${PROVIDER}: "${remoteId}" is not a message id`, PROVIDER); } return { chatId: chatTarget(connection), messageId }; } const chatId = remoteId.slice(0, separator); const messageId = Number(remoteId.slice(separator + 1)); if (chatId.length === 0 || !Number.isInteger(messageId)) { throw new ConnectorError(`${PROVIDER}: malformed reference "${remoteId}"`, PROVIDER); } return { chatId, messageId }; } function requireAssetLoader(): (assetId: string) => Promise { const loader = options.loadAsset; if (loader === undefined) { throw new ConnectorError( `${PROVIDER}: publishing media requires the loadAsset option (asset id → MediaAsset)`, PROVIDER, ); } return loader; } function requireAssetReader(): ( media: MediaAsset, ) => Promise<{ bytes: Uint8Array; filename: string }> { const reader = options.openAsset; if (reader === undefined) { throw new ConnectorError( `${PROVIDER}: media upload requires the openAsset option (asset → bytes)`, PROVIDER, ); } return reader; } function assertTextLimit(text: string, limit: number, what: string): void { if (text.length > limit) { throw new ConnectorError( `${PROVIDER}: ${what} is ${text.length} characters, over the ${limit} limit`, PROVIDER, ); } } async function sendPhoto( token: string, target: string, media: MediaAsset, bytes: Uint8Array, filename: string, caption?: string, ): Promise { const form = new FormData(); form.append("chat_id", target); const photo = new Blob([bytes], { type: media.mimeType }); form.append("photo", photo, filename); if (caption !== undefined && caption.length > 0) { form.append("caption", caption); form.append("parse_mode", "HTML"); } return call(token, "sendPhoto", { method: "POST", body: form }); } async function sendMediaGroup( token: string, target: string, assets: { media: MediaAsset; bytes: Uint8Array; filename: string }[], caption?: string, ): Promise { const form = new FormData(); form.append("chat_id", target); const inputMedia = assets.map((asset, index) => ({ type: "photo", media: `attach://file${index}`, ...(index === 0 && caption !== undefined && caption.length > 0 ? { caption, parse_mode: "HTML" } : {}), })); form.append("media", JSON.stringify(inputMedia)); assets.forEach((asset, index) => { const file = new Blob([asset.bytes], { type: asset.media.mimeType }); form.append(`file${index}`, file, asset.filename); }); return call(token, "sendMediaGroup", { method: "POST", body: form }); } async function deleteMessage(connection: ConnectorConnection, remoteId: string): Promise { const token = await botToken(connection); const { chatId, messageId } = parseTargetReference(connection, remoteId); await call(token, "deleteMessage", jsonPost({ chat_id: chatId, message_id: messageId })); } async function ensureWebhookSecret( connection: ConnectorConnection, ): Promise<{ secret: string; fromEnv: boolean }> { const fromEnv = env["TELEGRAM_WEBHOOK_SECRET"]; if (fromEnv !== undefined && fromEnv.length > 0) return { secret: fromEnv, fromEnv: true }; const secret = randomBytes(32).toString("hex"); await credentials.put(connection, WEBHOOK_SECRET_KEY, secret); return { secret, fromEnv: false }; } return { manifest: () => { assertValidManifest(manifest); return manifest; }, async authorize() { throw new ConnectorError( `${PROVIDER}: bot-token auth only — create a bot with @BotFather and connect its token, no OAuth round-trip`, PROVIDER, ); }, async validateToken(input) { if (!/^\d+:[A-Za-z0-9_-]+$/.test(input.token)) throw new AuthError("Invalid bot token", PROVIDER, false); const me = await call(input.token, "getMe"); if (!me.is_bot || !Number.isSafeInteger(me.id) || me.id <= 0) throw new AuthError("Invalid bot identity", PROVIDER, false); const connection: ConnectorConnection = { id: input.connectionId, workspaceId: input.workspaceId, provider: PROVIDER, channelId: input.fields.channelId || String(me.id), credentialId: { kind: "credential-ref", id: input.connectionId }, fields: { ...(input.fields.channelId ? { channelId: input.fields.channelId } : {}) }, createdAt: new Date(), }; await credentials.put(connection, "botToken", input.token); return { connection, identity: { provider: PROVIDER, remoteId: String(me.id), handle: me.username ? `@${me.username}` : me.first_name, displayName: me.first_name, } }; }, async getIdentity(connection: ConnectorConnection): Promise { const token = await botToken(connection); const me = await call(token, "getMe"); return { provider: PROVIDER, // The identity is the bot itself; the channel it posts to is the // connection's channelId field. remoteId: String(me.id), handle: me.username === undefined ? me.first_name : `@${me.username}`, displayName: me.first_name, }; }, async publish(connection: ConnectorConnection, post: NormalizedPost): Promise { const token = await botToken(connection); const target = chatTarget(connection); const text = escapeHtml(post.text); const idempotencyKey = post.providerMetadata["idempotencyKey"]; const receipt = typeof idempotencyKey === "string" && idempotencyKey.length > 0 ? { idempotencyKey } : {}; if (post.mediaAssetIds.length === 0) { assertTextLimit(text, TEXT_LIMIT, "message text"); const message = await call( token, "sendMessage", jsonPost({ chat_id: target, text, parse_mode: "HTML" }), ); const username = await resolveChatUsername(token, target); return toPublication( connection, "direct", { messageId: message.message_id, at: new Date(message.date * 1000) }, { method: "sendMessage", chatId: target, messageId: message.message_id, ...receipt }, username, ); } const load = requireAssetLoader(); const read = requireAssetReader(); const assets = await Promise.all( post.mediaAssetIds.map(async (assetId) => { const media = await load(assetId); if (!media.mimeType.startsWith("image/")) { throw new ConnectorError( `${PROVIDER}: media ${assetId} is "${media.mimeType}" — only image publishing is supported (publishVideo is not a Telegram capability)`, PROVIDER, ); } const { bytes, filename } = await read(media); return { media, bytes, filename }; }), ); const username = await resolveChatUsername(token, target); if (assets.length === 1) { assertTextLimit(text, CAPTION_LIMIT, "photo caption"); const asset = assets[0]; if (asset === undefined) throw new ConnectorError(`${PROVIDER}: no media resolved`, PROVIDER); const message = await sendPhoto(token, target, asset.media, asset.bytes, asset.filename, text); return toPublication( connection, "direct", { messageId: message.message_id, at: new Date(message.date * 1000) }, { method: "sendPhoto", chatId: target, messageId: message.message_id, mediaAssetIds: post.mediaAssetIds, ...receipt, }, username, ); } assertTextLimit(text, CAPTION_LIMIT, "media group caption"); const messages = await sendMediaGroup(token, target, assets, text); const first = messages[0]; if (first === undefined) { throw new ConnectorError(`${PROVIDER}: sendMediaGroup returned nothing`, PROVIDER); } return toPublication( connection, "direct", { messageId: first.message_id, at: new Date(first.date * 1000) }, { method: "sendMediaGroup", chatId: target, messageId: first.message_id, messageIds: messages.map((message) => message.message_id), mediaAssetIds: post.mediaAssetIds, ...receipt, }, username, ); }, async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise { if (!media.mimeType.startsWith("image/")) { throw new ConnectorError( `${PROVIDER}: uploadMedia accepts images only ("${media.mimeType}")`, PROVIDER, ); } const token = await botToken(connection); const target = chatTarget(connection); const { bytes, filename } = await requireAssetReader()(media); // Telegram has no media library: "upload" = sending the photo once and // keeping the returned file_id, which later sends can reference. const message = await sendPhoto(token, target, media, bytes, filename); const photo = largestPhoto(message.photo ?? []); if (photo === undefined) { throw new ConnectorError(`${PROVIDER}: sendPhoto returned no file id`, PROVIDER); } return { remoteId: photo.file_id, // No url on purpose: fetching file bytes requires the bot token, so // file_id is the only safe long-lived reference. mimeType: media.mimeType, width: photo.width, height: photo.height, }; }, async amplify( connection: ConnectorConnection, request: { kind: "repost" | "quote" | "share"; targetRemoteId: string; commentary?: string; }, ): Promise { if (request.kind !== "share") { throw new ConnectorError( `${PROVIDER}: amplification is forwarding only — "${request.kind}" is not a capability`, PROVIDER, ); } const token = await botToken(connection); const target = chatTarget(connection); const { chatId, messageId } = parseTargetReference(connection, request.targetRemoteId); // forwardMessage cannot attach text; a share with commentary becomes a // copyMessage carrying a fresh caption instead. const hasCommentary = request.commentary !== undefined && request.commentary.length > 0; const method = hasCommentary ? "copyMessage" : "forwardMessage"; const payload = hasCommentary ? { chat_id: target, from_chat_id: chatId, message_id: messageId, caption: escapeHtml(request.commentary ?? ""), parse_mode: "HTML", } : { chat_id: target, from_chat_id: chatId, message_id: messageId }; const result = await call( token, method, jsonPost(payload), ); const username = await resolveChatUsername(token, target); return toPublication( connection, `amplify:${request.kind}`, { messageId: result.message_id, // forwardMessage returns the full message; copyMessage only a // MessageId wrapper, so the local receive time stands in for it. at: "date" in result ? new Date(result.date * 1000) : new Date(), }, { amplifies: request.targetRemoteId, kind: request.kind, method, fromChatId: chatId, chatId: target, messageId: result.message_id }, username, ); }, async unamplify(connection: ConnectorConnection, remoteId: string): Promise { await deleteMessage(connection, remoteId); }, async deletePublication(connection: ConnectorConnection, remoteId: string): Promise { await deleteMessage(connection, remoteId); }, async subscribeWebhooks( connection: ConnectorConnection, request: { topics: string[] }, ): Promise<{ subscriptionId: string }> { const url = options.webhookPublicUrl ?? env["TELEGRAM_WEBHOOK_URL"]; if (url === undefined || url.length === 0) { throw new ConnectorError( `${PROVIDER}: pass webhookPublicUrl or set TELEGRAM_WEBHOOK_URL before subscribing`, PROVIDER, ); } const token = await botToken(connection); const { secret, fromEnv } = await ensureWebhookSecret(connection); if (!fromEnv) webhookSecrets.set(secret, connection.id); const info = await call(token, "getWebhookInfo"); const result = await call( token, "setWebhook", jsonPost({ url, secret_token: secret, allowed_updates: request.topics.length > 0 ? request.topics : undefined, // Drop a pre-existing backlog so a re-subscribe does not replay old // updates (getUpdates never yields history, and neither should we). drop_pending_updates: info.pending_update_count > 0, }), ); if (!result) throw new ConnectorError(`${PROVIDER}: setWebhook rejected`, PROVIDER); return { subscriptionId: `telegram-webhook:${connection.id}` }; }, async handleWebhook(request: { headers: Record; body: unknown; }): Promise { const header = Object.entries(request.headers).find( ([name]) => name.toLowerCase() === WEBHOOK_SECRET_HEADER, )?.[1]; if (header === undefined || header.length === 0) { throw new ConnectorError( `${PROVIDER}: webhook missing ${WEBHOOK_SECRET_HEADER} header`, PROVIDER, ); } const expected = env["TELEGRAM_WEBHOOK_SECRET"]; const matches = expected !== undefined && expected.length > 0 ? header === expected : // Secrets generated per connection live in this instance (single // process); multi-instance deployments must set the env secret. webhookSecrets.has(header); if (!matches) throw new ConnectorError(`${PROVIDER}: webhook secret mismatch`, PROVIDER); const update = request.body as Record | null; if (update === null || typeof update !== "object" || typeof update["update_id"] !== "number") { throw new ConnectorError( `${PROVIDER}: malformed webhook payload — expected a Telegram Update`, PROVIDER, ); } const eventType = UPDATE_EVENT_KEYS.find((key) => update[key] !== undefined) ?? "update"; return { provider: PROVIDER, eventType, connectionKey: webhookSecrets.get(header), payload: update, receivedAt: new Date(), }; }, }; }