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, }, }; }