import { describe, expect, it } from "vitest"; import type { ConnectorConnection, MediaAsset } from "@tactical-media-broadcast-hub/core"; import { ConnectorError } from "@tactical-media-broadcast-hub/connector-sdk"; import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing"; import { createTelegramConnector } from "./index.js"; /** * Stubbed Telegram Bot API: one in-memory channel + bot identity per test * instance. The contract tests exercise real request/response shapes. */ interface StubMessage { id: number; chatId: string; method: string; caption?: string; } interface StubState { calls: { url: string; init: RequestInit | undefined }[]; messages: StubMessage[]; webhookSecret?: string; } const TOKEN = "123456789:AAstubbed-token"; const CHANNEL = "@anarchoposter_channel"; const CHANNEL_ID = -1001234567890; const BOT = { id: 6262947712, is_bot: true, first_name: "AnarchoPoster Bot", username: "anarchoposter_bot" }; const CHAT = { id: CHANNEL_ID, type: "channel", title: "AnarchoPoster News", username: "anarchoposter_channel" }; const connection: ConnectorConnection = { id: "conn-telegram", workspaceId: "ws-1", provider: "telegram", channelId: CHANNEL, credentialId: { kind: "credential-ref", id: "cred-1" }, fields: { botToken: TOKEN, channelId: CHANNEL }, createdAt: new Date(), }; function apiOk(result: T): Response { return new Response(JSON.stringify({ ok: true, result }), { status: 200 }); } function apiErr( description: string, error_code: number, parameters?: { retry_after: number }, ): Response { // The Bot API mirrors error_code in the HTTP status. return new Response(JSON.stringify({ ok: false, description, error_code, parameters }), { status: error_code, }); } function jsonBody(init: RequestInit | undefined): Record { return JSON.parse(String(init?.body ?? "{}")) as Record; } function stubApi(state: StubState): typeof fetch { return async (input: Parameters[0], init?: RequestInit): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; state.calls.push({ url, init }); const method = url.split("/").pop() ?? ""; const now = Math.floor(Date.now() / 1000); switch (method) { case "getMe": return apiOk(BOT); case "getChat": return apiOk(CHAT); case "sendMessage": { const body = jsonBody(init); const text = String(body["text"] ?? ""); if (text.includes("rate-limit-me")) { return apiErr("Too Many Requests: retry after 5", 429, { retry_after: 5 }); } if (text.length > 4096) return apiErr("Bad Request: message is too long", 400); if (text === "server-error-me") return apiErr("Bad Gateway", 502); if (text === "forbidden-me") return apiErr("Forbidden: bot is not a member of the channel chat", 403); if (text === "revoked-me") return apiErr("Unauthorized", 401); if (text === "drop-response-me") { // Telegram accepted and posted the message; the response is lost. state.messages.push({ id: state.messages.length + 1, chatId: String(body["chat_id"]), method, caption: text }); throw new TypeError("fetch failed: socket hang up"); } const id = state.messages.length + 1; state.messages.push({ id, chatId: String(body["chat_id"]), method, caption: text }); return apiOk({ message_id: id, date: now, chat: CHAT, text }); } case "forwardMessage": case "copyMessage": { const body = jsonBody(init); if (String(body["from_chat_id"]).includes("protected")) { return apiErr("Bad Request: message can't be forwarded because it's protected", 400); } const id = state.messages.length + 1; const caption = body["caption"] === undefined ? undefined : String(body["caption"]); state.messages.push({ id, chatId: String(body["chat_id"]), method, caption }); if (method === "copyMessage") return apiOk({ message_id: id }); return apiOk({ message_id: id, date: now, chat: CHAT, text: caption }); } case "deleteMessage": { const body = jsonBody(init); const before = state.messages.length; state.messages = state.messages.filter( (message) => !(message.chatId === String(body["chat_id"]) && message.id === Number(body["message_id"])), ); return apiOk(state.messages.length < before); } case "sendPhoto": { const form = init?.body; expect(form).toBeInstanceOf(FormData); const id = state.messages.length + 1; const caption = (form as FormData).get("caption"); state.messages.push({ id, chatId: String((form as FormData).get("chat_id")), method, caption: caption === null ? undefined : String(caption), }); return apiOk({ message_id: id, date: now, chat: CHAT, photo: [{ file_id: "AgACAgIAAx0D", file_unique_id: "AQADqr", width: 1280, height: 720 }], }); } case "sendMediaGroup": { const form = init?.body; expect(form).toBeInstanceOf(FormData); const media = JSON.parse(String((form as FormData).get("media"))) as { type: string; media: string; }[]; const messages = media.map((_, index) => ({ message_id: state.messages.length + 1 + index, date: now, chat: CHAT, photo: [{ file_id: `AgACAgIAAx0D${index}`, file_unique_id: `AQAD${index}`, width: 1280, height: 720 }], })); messages.forEach((message) => { state.messages.push({ id: message.message_id, chatId: CHANNEL, method }); }); return apiOk(messages); } case "getWebhookInfo": return apiOk({ url: "", has_custom_certificate: false, pending_update_count: 2 }); case "setWebhook": { const body = jsonBody(init); state.webhookSecret = String(body["secret_token"]); return apiOk(true); } default: return apiErr(`unknown method ${method}`, 404); } }; } interface Harness { state: StubState; connector: ReturnType; } function createHarness(overrides: Partial[0]> = {}): Harness { const state: StubState = { calls: [], messages: [] }; const connector = createTelegramConnector({ fetchImpl: stubApi(state), requestsPerSecond: 1000, sleep: async () => Promise.resolve(), env: {}, webhookPublicUrl: "https://app.example.com/webhooks/telegram", loadAsset: async (assetId) => mediaAsset(assetId), openAsset: async () => ({ bytes: new Uint8Array([137, 80, 78, 71]), filename: "photo.png" }), ...overrides, }); return { state, connector }; } function mediaAsset(id: string): MediaAsset { return { id, workspaceId: "ws-1", storageKey: `s3://assets/${id}`, mimeType: id.endsWith(".mp4") ? "video/mp4" : "image/png", sizeBytes: 2048, width: 1280, height: 720, createdAt: new Date(), }; } const post = (text: string, mediaAssetIds: string[] = []) => ({ text, mediaAssetIds, visibility: "public" as const, providerMetadata: {}, }); connectorContractTests(createHarness().connector, { connection }); describe("telegram connector: identity", () => { it("getIdentity resolves the bot via getMe", async () => { const { state, connector } = createHarness(); const identity = await connector.getIdentity(connection); expect(identity.provider).toBe("telegram"); expect(identity.remoteId).toBe(String(BOT.id)); expect(identity.handle).toBe("@anarchoposter_bot"); expect(identity.displayName).toBe("AnarchoPoster Bot"); expect(state.calls[0]?.url).toBe(`https://api.telegram.org/bot${TOKEN}/getMe`); }); it("fails closed when the token is missing or blank", async () => { const blank = { ...connection, fields: { ...connection.fields, botToken: "" } }; const absent = { ...connection, fields: { channelId: CHANNEL } }; const { connector } = createHarness(); await expect(connector.getIdentity(blank)).rejects.toThrow(/missing bot token/); await expect(connector.getIdentity(absent)).rejects.toThrow(/missing bot token/); }); it("authorize explains there is no OAuth round-trip", async () => { const { connector } = createHarness(); await expect(connector.authorize({ redirectUri: "https://x", workspaceId: "ws", state: "s" })).rejects.toThrow( /@BotFather/, ); }); }); describe("telegram connector: publish text", () => { it("sends via sendMessage with HTML parse mode and escaped text", async () => { const { state, connector } = createHarness(); const publication = await connector.publish?.(connection, post("Ship & iterate")); const call = state.calls[0]; expect(call?.url.endsWith("/sendMessage")).toBe(true); const body = jsonBody(call?.init); expect(body["chat_id"]).toBe(CHANNEL); expect(body["parse_mode"]).toBe("HTML"); // Raw < > & are escaped so a post can never inject Telegram markup. expect(body["text"]).toBe("Ship <it> & iterate"); expect(publication?.remoteId).toBe("1"); expect(publication?.remoteUrl).toBe("https://t.me/anarchoposter_channel/1"); expect(publication?.providerMetadata).toMatchObject({ method: "sendMessage", chatId: CHANNEL }); expect(publication?.workspaceId).toBe("ws-1"); expect(publication?.connectionId).toBe("conn-telegram"); }); it("maps a Telegram 429 to RateLimitError carrying retry_after", async () => { const { connector } = createHarness(); await expect(connector.publish?.(connection, post("rate-limit-me"))).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 5000, }); }); it("sends a 429 exactly once and carries the body retry_after", async () => { const { state, connector } = createHarness(); await connector.publish?.(connection, post("rate-limit-me")).catch(() => undefined); expect(state.calls.filter((c) => c.url.endsWith("/sendMessage"))).toHaveLength(1); }); it("never resends after a 5xx: unknown delivery ProviderServerError", async () => { const { state, connector } = createHarness(); await expect(connector.publish?.(connection, post("server-error-me"))).rejects.toMatchObject({ name: "ProviderServerError", status: 502, delivery: "unknown", }); expect(state.calls.filter((c) => c.url.endsWith("/sendMessage"))).toHaveLength(1); }); it("an accepted send whose response is lost is one visible message and an unknown-delivery error", async () => { const { state, connector } = createHarness(); await expect(connector.publish?.(connection, post("drop-response-me"))).rejects.toMatchObject({ name: "ProviderTransportError", delivery: "unknown", }); expect(state.calls.filter((c) => c.url.endsWith("/sendMessage"))).toHaveLength(1); expect(state.messages).toHaveLength(1); }); it("maps 403 to ProviderRejectedError and 401 to AuthError", async () => { const { connector } = createHarness(); await expect(connector.publish?.(connection, post("forbidden-me"))).rejects.toMatchObject({ name: "ProviderRejectedError", status: 403, delivery: "rejected", }); await expect(connector.publish?.(connection, post("revoked-me"))).rejects.toMatchObject({ name: "AuthError" }); }); it("echoes the injected idempotency key; draftId stays a local placeholder", async () => { const { connector } = createHarness(); const publication = await connector.publish?.(connection, { ...post("keyed"), providerMetadata: { idempotencyKey: "wp3:ws-1:draft-1:a1:publish", draftId: "draft-1" }, }); expect(publication?.providerMetadata).toMatchObject({ method: "sendMessage", messageId: 1, idempotencyKey: "wp3:ws-1:draft-1:a1:publish", }); expect(publication?.draftId).toBe("direct"); }); it("retries the read-only getChat lookup but not the send", async () => { let getChatCalls = 0; const base = createHarness(); const stub = stubApi(base.state); const connector = createTelegramConnector({ fetchImpl: async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.endsWith("/getChat") && (getChatCalls += 1) === 1) return apiErr("Internal Server Error", 500); return stub(input, init); }, requestsPerSecond: 1000, sleep: async () => Promise.resolve(), env: {}, }); const numeric = { ...connection, fields: { botToken: TOKEN, channelId: String(CHANNEL_ID) }, channelId: String(CHANNEL_ID) }; const publication = await connector.publish?.(numeric, post("hi")); expect(getChatCalls).toBe(2); expect(publication?.remoteUrl).toBe("https://t.me/anarchoposter_channel/1"); }); it("rejects text over 4096 characters before any request", async () => { const { state, connector } = createHarness(); await expect(connector.publish?.(connection, post("x".repeat(4097)))).rejects.toThrow( /over the 4096 limit/, ); expect(state.calls).toHaveLength(0); }); it("omits the deep link when the chat has no public username", async () => { const privateConnection: ConnectorConnection = { ...connection, fields: { botToken: TOKEN, channelId: "-1009999999999" }, channelId: "-1009999999999", }; const noUsername = stubApi({ calls: [], messages: [] }); const connector = createTelegramConnector({ fetchImpl: async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; // getChat for the private channel: no username field. if (url.endsWith("/getChat")) { return apiOk({ id: -1009999999999, type: "channel", title: "Private" }); } return noUsername(input, init); }, requestsPerSecond: 1000, sleep: async () => Promise.resolve(), env: {}, }); const publication = await connector.publish?.(privateConnection, post("internal note")); expect(publication?.remoteId).toBe("1"); expect(publication?.remoteUrl).toBeUndefined(); }); }); describe("telegram connector: publish media", () => { it("publishes a single photo via multipart sendPhoto with the caption", async () => { const { state, connector } = createHarness(); const publication = await connector.publish?.(connection, post("chart of the week", ["asset-1"])); const call = state.calls.find((c) => c.url.endsWith("/sendPhoto")); expect(call).toBeDefined(); const form = call?.init?.body as FormData; expect(form.get("chat_id")).toBe(CHANNEL); expect(form.get("caption")).toBe("chart of the week"); expect(form.get("parse_mode")).toBe("HTML"); expect(form.get("photo")).toBeInstanceOf(Blob); expect(publication?.providerMetadata).toMatchObject({ method: "sendPhoto", mediaAssetIds: ["asset-1"], }); }); it("publishes multiple photos as a media group, caption on the first item", async () => { const { state, connector } = createHarness(); const publication = await connector.publish?.( connection, post("album", ["asset-1", "asset-2", "asset-3"]), ); const call = state.calls.find((c) => c.url.endsWith("/sendMediaGroup")); expect(call).toBeDefined(); const form = call?.init?.body as FormData; const media = JSON.parse(String(form.get("media"))) as { type: string; media: string; caption?: string; parse_mode?: string; }[]; expect(media).toHaveLength(3); expect(media.every((item) => item.type === "photo")).toBe(true); expect(media.map((item) => item.media)).toEqual(["attach://file0", "attach://file1", "attach://file2"]); expect(media[0]?.caption).toBe("album"); expect(media[0]?.parse_mode).toBe("HTML"); expect(media[1]?.caption).toBeUndefined(); expect(form.get("file0")).toBeInstanceOf(Blob); expect(publication?.providerMetadata.messageIds).toHaveLength(3); expect(publication?.remoteUrl).toBe("https://t.me/anarchoposter_channel/1"); }); it("rejects captions over 1024 characters", async () => { const { connector } = createHarness(); await expect(connector.publish?.(connection, post("x".repeat(1025), ["asset-1"]))).rejects.toThrow( /over the 1024 limit/, ); }); it("refuses non-image media — publishVideo is not a Telegram capability", async () => { const { connector } = createHarness(); await expect(connector.publish?.(connection, post("clip", ["clip.mp4"]))).rejects.toThrow( /publishVideo is not a Telegram capability/, ); }); it("refuses media publishing when no asset loader is configured", async () => { const { connector } = createHarness({ loadAsset: undefined, openAsset: undefined }); await expect(connector.publish?.(connection, post("photo", ["asset-1"]))).rejects.toThrow(/loadAsset/); }); }); describe("telegram connector: uploadMedia", () => { it("returns the file_id from sendPhoto with dimensions", async () => { const { state, connector } = createHarness(); const remote = await connector.uploadMedia?.(connection, mediaAsset("asset-9")); expect(remote?.remoteId).toBe("AgACAgIAAx0D"); expect(remote?.mimeType).toBe("image/png"); expect(remote?.width).toBe(1280); expect(remote?.height).toBe(720); expect(remote?.url).toBeUndefined(); // file bytes need the bot token → no url expect(state.calls.some((c) => c.url.endsWith("/sendPhoto"))).toBe(true); }); it("refuses non-image uploads", async () => { const { connector } = createHarness(); await expect(connector.uploadMedia?.(connection, mediaAsset("clip.mp4"))).rejects.toThrow( /images only/, ); }); }); describe("telegram connector: amplify / unamplify", () => { it("forwards a cross-chat message reference via forwardMessage", async () => { const { state, connector } = createHarness(); const forwarded = await connector.amplify?.(connection, { kind: "share", targetRemoteId: "-1009876543210:42", }); const call = state.calls.find((c) => c.url.endsWith("/forwardMessage")); expect(call).toBeDefined(); expect(jsonBody(call?.init)).toEqual({ chat_id: CHANNEL, from_chat_id: "-1009876543210", message_id: 42, }); expect(forwarded?.providerMetadata).toMatchObject({ kind: "share", amplifies: "-1009876543210:42", method: "forwardMessage", fromChatId: "-1009876543210", }); expect(forwarded?.draftId).toBe("amplify:share"); }); it("uses copyMessage with a caption when commentary is supplied", async () => { const { state, connector } = createHarness(); const shared = await connector.amplify?.(connection, { kind: "share", targetRemoteId: "5", commentary: "worth reading", }); const call = state.calls.find((c) => c.url.endsWith("/copyMessage")); expect(call).toBeDefined(); expect(jsonBody(call?.init)).toEqual({ chat_id: CHANNEL, from_chat_id: CHANNEL, message_id: 5, caption: "worth reading", parse_mode: "HTML", }); expect(shared?.providerMetadata.method).toBe("copyMessage"); expect(shared?.publishedAt).toBeInstanceOf(Date); }); it("surfaces protected content as a ConnectorError, not a silent no-op", async () => { const { connector } = createHarness(); await expect( connector.amplify?.(connection, { kind: "share", targetRemoteId: "@protected_channel:9" }), ).rejects.toBeInstanceOf(ConnectorError); }); it("rejects amplification kinds Telegram does not have", async () => { const { connector } = createHarness(); await expect(connector.amplify?.(connection, { kind: "repost", targetRemoteId: "1" })).rejects.toThrow( /forwarding only/, ); await expect(connector.amplify?.(connection, { kind: "quote", targetRemoteId: "1" })).rejects.toThrow( /forwarding only/, ); }); it("unamplify and deletePublication both call deleteMessage", async () => { const { state, connector } = createHarness(); await connector.unamplify?.(connection, "3"); await connector.deletePublication?.(connection, "-1009876543210:3"); const calls = state.calls.filter((c) => c.url.endsWith("/deleteMessage")); expect(calls).toHaveLength(2); expect(jsonBody(calls[0]?.init)).toEqual({ chat_id: CHANNEL, message_id: 3 }); expect(jsonBody(calls[1]?.init)).toEqual({ chat_id: "-1009876543210", message_id: 3 }); }); it("rejects malformed remote references", async () => { const { connector } = createHarness(); await expect(connector.unamplify?.(connection, "not-a-number")).rejects.toThrow(/not a message id/); await expect(connector.unamplify?.(connection, ":7")).rejects.toThrow(/malformed reference/); }); }); describe("telegram connector: webhooks", () => { it("subscribes via setWebhook with a generated secret", async () => { const { state, connector } = createHarness(); const subscription = await connector.subscribeWebhooks?.(connection, { topics: [] }); const call = state.calls.find((c) => c.url.endsWith("/setWebhook")); expect(call).toBeDefined(); const body = jsonBody(call?.init); expect(body["url"]).toBe("https://app.example.com/webhooks/telegram"); expect(String(body["secret_token"])).toMatch(/^[0-9a-f]{64}$/); // getWebhookInfo reported 2 pending updates → dropped, never replayed. expect(body["drop_pending_updates"]).toBe(true); expect(body["allowed_updates"]).toBeUndefined(); expect(subscription?.subscriptionId).toBe("telegram-webhook:conn-telegram"); }); it("forwards requested topics as allowed_updates", async () => { const { state, connector } = createHarness(); await connector.subscribeWebhooks?.(connection, { topics: ["channel_post", "message"] }); const call = state.calls.find((c) => c.url.endsWith("/setWebhook")); expect(jsonBody(call?.init)["allowed_updates"]).toEqual(["channel_post", "message"]); }); it("prefers TELEGRAM_WEBHOOK_SECRET from the environment", async () => { const state: StubState = { calls: [], messages: [] }; const connector = createTelegramConnector({ fetchImpl: stubApi(state), requestsPerSecond: 1000, sleep: async () => Promise.resolve(), env: { TELEGRAM_WEBHOOK_SECRET: "env-secret-token" }, webhookPublicUrl: "https://app.example.com/webhooks/telegram", }); await connector.subscribeWebhooks?.(connection, { topics: [] }); const call = state.calls.find((c) => c.url.endsWith("/setWebhook")); expect(jsonBody(call?.init)["secret_token"]).toBe("env-secret-token"); }); it("requires a public webhook url before subscribing", async () => { const { connector } = createHarness({ webhookPublicUrl: undefined, env: {} }); await expect(connector.subscribeWebhooks?.(connection, { topics: [] })).rejects.toThrow( /TELEGRAM_WEBHOOK_URL/, ); }); it("accepts a signed channel post and normalizes the update", async () => { const { state, connector } = createHarness(); await connector.subscribeWebhooks?.(connection, { topics: [] }); const secret = state.webhookSecret; expect(secret).toBeDefined(); const event = await connector.handleWebhook?.({ headers: { "X-Telegram-Bot-Api-Secret-Token": secret ?? "" }, body: { update_id: 712, channel_post: { message_id: 12, chat: CHAT, text: "hi" }, }, }); expect(event?.provider).toBe("telegram"); expect(event?.eventType).toBe("channel_post"); expect(event?.connectionKey).toBe("conn-telegram"); expect(event?.payload["update_id"]).toBe(712); expect(event?.receivedAt).toBeInstanceOf(Date); }); it("rejects unsigned webhooks", async () => { const { connector } = createHarness({ env: { TELEGRAM_WEBHOOK_SECRET: "s3cret" } }); await expect( connector.handleWebhook?.({ headers: {}, body: { update_id: 1, message: { message_id: 1, chat: { id: 1, type: "private" }, text: "x" } }, }), ).rejects.toThrow(/secret/); }); it("rejects a mismatched secret", async () => { const { connector } = createHarness({ env: { TELEGRAM_WEBHOOK_SECRET: "s3cret" } }); await expect( connector.handleWebhook?.({ headers: { "x-telegram-bot-api-secret-token": "not-the-secret" }, body: { update_id: 1, message: { message_id: 1, chat: { id: 1, type: "private" }, text: "x" } }, }), ).rejects.toThrow(/mismatch/); }); it("rejects a body that is not a Telegram Update", async () => { const { connector } = createHarness({ env: { TELEGRAM_WEBHOOK_SECRET: "s3cret" } }); await expect( connector.handleWebhook?.({ headers: { "x-telegram-bot-api-secret-token": "s3cret" }, body: { nope: true }, }), ).rejects.toThrow(/malformed webhook/); }); });