T-HUB/packages/connector-telegram/src/index.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

731 lines
27 KiB
TypeScript

/**
* 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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(),
};
},
};
}