Tactical Media Broadcast Hub — initial release
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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
609
packages/connector-telegram/src/contract.test.ts
Normal file
609
packages/connector-telegram/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
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/);
|
||||
});
|
||||
});
|
||||
731
packages/connector-telegram/src/index.ts
Normal file
731
packages/connector-telegram/src/index.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
/**
|
||||
* 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<token>/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<void>;
|
||||
/** Secret store. Default: in-memory (dev + contract tests). */
|
||||
credentials?: CredentialAccessor;
|
||||
/** Environment read for TELEGRAM_WEBHOOK_SECRET / TELEGRAM_WEBHOOK_URL. */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** 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<MediaAsset>;
|
||||
/** 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<string, unknown>;
|
||||
edited_channel_post?: Record<string, unknown>;
|
||||
my_chat_member?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<T> =
|
||||
| { 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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function jsonPost(payload: Record<string, unknown>): 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<TelegramPhotoSize | undefined>(
|
||||
(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<string, string>();
|
||||
/** Resolved public usernames per chat target, so deep links cost one getChat. */
|
||||
const chatUsernames = new Map<string, string>();
|
||||
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<string> {
|
||||
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<T>(
|
||||
token: string,
|
||||
method: string,
|
||||
init: RequestInit = {},
|
||||
requestOptions: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await client.request(PROVIDER, `${API_BASE}/bot${token}/${method}`, init, requestOptions);
|
||||
let body: TelegramResponse<T>;
|
||||
try {
|
||||
body = (await response.json()) as TelegramResponse<T>;
|
||||
} 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<string | undefined> {
|
||||
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<TelegramChat>(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<string, unknown>,
|
||||
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<MediaAsset> {
|
||||
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<TelegramMessage> {
|
||||
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<TelegramMessage>(token, "sendPhoto", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
async function sendMediaGroup(
|
||||
token: string,
|
||||
target: string,
|
||||
assets: { media: MediaAsset; bytes: Uint8Array; filename: string }[],
|
||||
caption?: string,
|
||||
): Promise<TelegramMessage[]> {
|
||||
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<TelegramMessage[]>(token, "sendMediaGroup", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
async function deleteMessage(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
const token = await botToken(connection);
|
||||
const { chatId, messageId } = parseTargetReference(connection, remoteId);
|
||||
await call<boolean>(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<TelegramUser>(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<NormalizedIdentity> {
|
||||
const token = await botToken(connection);
|
||||
const me = await call<TelegramUser>(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<Publication> {
|
||||
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<TelegramMessage>(
|
||||
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<RemoteMedia> {
|
||||
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<Publication> {
|
||||
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<TelegramMessage | { message_id: number }>(
|
||||
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<void> {
|
||||
await deleteMessage(connection, remoteId);
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
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<TelegramWebhookInfo>(token, "getWebhookInfo");
|
||||
const result = await call<boolean>(
|
||||
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<string, string>;
|
||||
body: unknown;
|
||||
}): Promise<NormalizedWebhookEvent> {
|
||||
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<string, unknown> | 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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue