Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
609 lines
25 KiB
TypeScript
609 lines
25 KiB
TypeScript
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<T>(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<string, unknown> {
|
|
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
|
}
|
|
|
|
function stubApi(state: StubState): typeof fetch {
|
|
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
|
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<typeof createTelegramConnector>;
|
|
}
|
|
|
|
function createHarness(overrides: Partial<Parameters<typeof createTelegramConnector>[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 <it> & 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/);
|
|
});
|
|
});
|