270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { JsonObject, OmniInboundEnvelopeV1 } from "./types";
|
|
|
|
const MAX_TEXT_LENGTH = 4096;
|
|
|
|
function asObject(value: unknown): JsonObject {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {};
|
|
}
|
|
|
|
function pickMessage(update: JsonObject): JsonObject {
|
|
const candidates = [
|
|
update.message,
|
|
update.edited_message,
|
|
update.business_message,
|
|
update.edited_business_message,
|
|
update.channel_post,
|
|
update.edited_channel_post,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const obj = asObject(candidate);
|
|
if (Object.keys(obj).length > 0) return obj;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
function pickEventType(update: JsonObject): string {
|
|
if (update.business_message) return "business_message";
|
|
if (update.edited_business_message) return "edited_business_message";
|
|
if (update.business_connection) return "business_connection";
|
|
if (update.deleted_business_messages) return "deleted_business_messages";
|
|
if (update.message) return "message";
|
|
if (update.edited_message) return "edited_message";
|
|
return "unknown";
|
|
}
|
|
|
|
function isoFromUnix(value: unknown) {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
return new Date(value * 1000).toISOString();
|
|
}
|
|
|
|
function cropText(value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
return value.slice(0, MAX_TEXT_LENGTH);
|
|
}
|
|
|
|
function normalizeString(value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function normalizeNumber(value: unknown) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeId(value: unknown) {
|
|
if (value == null) return null;
|
|
const text = String(value).trim();
|
|
return text || null;
|
|
}
|
|
|
|
type TelegramMediaInfo = {
|
|
kind: "voice" | "audio" | "video_note" | null;
|
|
fileId: string | null;
|
|
durationSec: number | null;
|
|
mimeType: string | null;
|
|
title: string | null;
|
|
};
|
|
|
|
function pickTelegramMedia(message: JsonObject): TelegramMediaInfo {
|
|
const voice = asObject(message.voice);
|
|
if (Object.keys(voice).length > 0) {
|
|
return {
|
|
kind: "voice",
|
|
fileId: normalizeString(voice.file_id),
|
|
durationSec: normalizeNumber(voice.duration),
|
|
mimeType: normalizeString(voice.mime_type),
|
|
title: "Voice message",
|
|
};
|
|
}
|
|
|
|
const audio = asObject(message.audio);
|
|
if (Object.keys(audio).length > 0) {
|
|
const performer = normalizeString(audio.performer);
|
|
const title = normalizeString(audio.title) ?? normalizeString(audio.file_name);
|
|
const combinedTitle = performer && title ? `${performer} - ${title}` : title ?? performer;
|
|
return {
|
|
kind: "audio",
|
|
fileId: normalizeString(audio.file_id),
|
|
durationSec: normalizeNumber(audio.duration),
|
|
mimeType: normalizeString(audio.mime_type),
|
|
title: combinedTitle,
|
|
};
|
|
}
|
|
|
|
const videoNote = asObject(message.video_note);
|
|
if (Object.keys(videoNote).length > 0) {
|
|
return {
|
|
kind: "video_note",
|
|
fileId: normalizeString(videoNote.file_id),
|
|
durationSec: normalizeNumber(videoNote.duration),
|
|
mimeType: null,
|
|
title: "Video note",
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: null,
|
|
fileId: null,
|
|
durationSec: null,
|
|
mimeType: null,
|
|
title: null,
|
|
};
|
|
}
|
|
|
|
function detectDirection(message: JsonObject, chat: JsonObject, from: JsonObject): "IN" | "OUT" {
|
|
if (typeof message.outgoing === "boolean") return message.outgoing ? "OUT" : "IN";
|
|
if (typeof message.is_outgoing === "boolean") return message.is_outgoing ? "OUT" : "IN";
|
|
if (typeof message.out === "boolean") return message.out ? "OUT" : "IN";
|
|
|
|
const chatType = normalizeString(chat.type);
|
|
if (chatType === "private" && from.is_bot === true) return "OUT";
|
|
|
|
const chatId = chat.id != null ? String(chat.id) : null;
|
|
const fromId = from.id != null ? String(from.id) : null;
|
|
if (chatType === "private" && chatId && fromId && chatId !== fromId) {
|
|
return "OUT";
|
|
}
|
|
|
|
return "IN";
|
|
}
|
|
|
|
function requireString(value: unknown, fallback: string) {
|
|
const v = String(value ?? "").trim();
|
|
return v || fallback;
|
|
}
|
|
|
|
function makeFallbackEventId(raw: unknown) {
|
|
return createHash("sha256").update(JSON.stringify(raw ?? null)).digest("hex");
|
|
}
|
|
|
|
export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1 {
|
|
const update = asObject(raw);
|
|
const message = pickMessage(update);
|
|
const receivedAt = new Date().toISOString();
|
|
|
|
const updateId = update.update_id;
|
|
const messageId = message.message_id;
|
|
const businessConnection = asObject(update.business_connection);
|
|
|
|
const providerEventId =
|
|
(updateId != null && requireString(updateId, "")) ||
|
|
(messageId != null && requireString(messageId, "")) ||
|
|
makeFallbackEventId(raw);
|
|
|
|
const providerMessageId = messageId != null ? String(messageId) : null;
|
|
|
|
const chat = asObject(message.chat);
|
|
const from = asObject(message.from);
|
|
const direction = detectDirection(message, chat, from);
|
|
const ownerChatId = normalizeId(businessConnection.user_chat_id);
|
|
const chatId = normalizeId(chat.id);
|
|
const fromId = normalizeId(from.id);
|
|
|
|
let contactSource: JsonObject | null = null;
|
|
if (ownerChatId) {
|
|
// Prefer the counterparty id/source (different from connected owner chat id).
|
|
if (chatId && chatId !== ownerChatId) contactSource = chat;
|
|
if (fromId && fromId !== ownerChatId) {
|
|
if (!contactSource || direction === "IN") {
|
|
contactSource = from;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!contactSource) {
|
|
contactSource = direction === "OUT" && Object.keys(chat).length > 0 ? chat : from;
|
|
}
|
|
|
|
const fallbackContactSource = contactSource === chat ? from : chat;
|
|
|
|
const threadExternalId =
|
|
chat.id != null
|
|
? String(chat.id)
|
|
: businessConnection.user_chat_id != null
|
|
? String(businessConnection.user_chat_id)
|
|
: null;
|
|
|
|
const contactExternalId =
|
|
normalizeId(contactSource?.id) ??
|
|
normalizeId(fallbackContactSource?.id) ??
|
|
null;
|
|
|
|
const media = pickTelegramMedia(message);
|
|
const text =
|
|
cropText(message.text) ??
|
|
cropText(message.caption) ??
|
|
(media.kind === "voice"
|
|
? "[voice message]"
|
|
: media.kind === "video_note"
|
|
? "[video note]"
|
|
: media.kind === "audio"
|
|
? media.title
|
|
? `[audio] ${media.title}`
|
|
: "[audio]"
|
|
: null);
|
|
|
|
const businessConnectionId =
|
|
message.business_connection_id != null
|
|
? String(message.business_connection_id)
|
|
: businessConnection.id != null
|
|
? String(businessConnection.id)
|
|
: null;
|
|
|
|
const occurredAt =
|
|
isoFromUnix(message.date) ??
|
|
isoFromUnix(businessConnection.date) ??
|
|
receivedAt;
|
|
|
|
const eventType = pickEventType(update);
|
|
|
|
const idempotencyKey = ["telegram_business", providerEventId, businessConnectionId || "no-bc"].join(":");
|
|
|
|
return {
|
|
version: 1,
|
|
idempotencyKey,
|
|
provider: "telegram_business",
|
|
channel: "TELEGRAM",
|
|
direction,
|
|
providerEventId,
|
|
providerMessageId,
|
|
eventType,
|
|
occurredAt,
|
|
receivedAt,
|
|
payloadRaw: raw,
|
|
payloadNormalized: {
|
|
threadExternalId,
|
|
contactExternalId,
|
|
text,
|
|
businessConnectionId,
|
|
mediaKind: media.kind,
|
|
mediaFileId: media.fileId,
|
|
mediaDurationSec: media.durationSec,
|
|
mediaMimeType: media.mimeType,
|
|
mediaTitle: media.title,
|
|
updateId: updateId != null ? String(updateId) : null,
|
|
chatTitle: typeof chat.title === "string" ? chat.title : null,
|
|
chatUsername: normalizeString(chat.username),
|
|
chatFirstName: normalizeString(chat.first_name),
|
|
chatLastName: normalizeString(chat.last_name),
|
|
contactUsername: normalizeString(contactSource.username),
|
|
contactFirstName: normalizeString(contactSource.first_name),
|
|
contactLastName: normalizeString(contactSource.last_name),
|
|
contactTitle: normalizeString(contactSource.title),
|
|
contactAvatarUrl: normalizeString(contactSource.photo_url),
|
|
fromUsername: typeof from.username === "string" ? from.username : null,
|
|
fromFirstName: typeof from.first_name === "string" ? from.first_name : null,
|
|
fromLastName: typeof from.last_name === "string" ? from.last_name : null,
|
|
fromIsBot: from.is_bot === true,
|
|
},
|
|
};
|
|
}
|