import { createHash } from 'node:crypto'; import type { ChatwootWebhookPayload } from '../types/chatwoot.js'; /** * Channel-agnostic event the rest of the pipeline works on. New channels * (webchat, Allegro, …) only need to produce this shape. */ export interface SupportMessageEvent { source: 'chatwoot'; channel: string; conversationId: number; messageId: string; inboxId: number; content: string; subject: string | null; messageCreatedAt: string | null; senderEmail: string; senderName: string; senderId: string | null; labels: string[]; customAttributes: Record; additionalAttributes: Record; attachmentCount: number; attachments: NormalizedAttachment[]; formMail: FormMailInfo | null; /** true when the webhook did not carry labels/custom attributes. */ needsConversationFetch: boolean; } export interface NormalizedAttachment { fileName: string; contentType: string; fileSize: number | null; inline: boolean; contentId: string | null; } export interface FormMailInfo { customerEmail: string; customerName: string; phone: string; technicalSender: string; formSource: string; } export type NormalizeResult = | { ok: true; event: SupportMessageEvent } | { ok: false; reason: string }; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; /** Strips quoted replies, signatures and HTML so the LLM sees only new text. */ export function cleanEmailBody(raw: string): string { let text = raw.replace(/\r\n/g, '\n'); if (/<[a-z][\s\S]*>/i.test(text)) { text = text .replace(//gi, ' ') .replace(//gi, ' ') .replace(//gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, ' '); } const cutMarkers = [ /^\s*-{2,}\s*Original Message\s*-{2,}/im, /^\s*-{2,}\s*Wiadomość oryginalna\s*-{2,}/im, /^\s*_{5,}\s*$/m, /^\s*(On|W dniu)\b.*\b(wrote|napisał|napisała|pisze):\s*$/im, ]; for (const marker of cutMarkers) { const m = marker.exec(text); if (m && m.index > 0) text = text.slice(0, m.index); } return text .split('\n') .filter((line) => !/^\s*>/.test(line)) .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function firstEmail(...candidates: (string | undefined | null)[]): string { for (const c of candidates) { const v = (c ?? '').trim(); if (v && EMAIL_RE.test(v)) return v.toLowerCase(); } return ''; } /** * Turns a raw Chatwoot webhook into a SupportMessageEvent, or explains why the * payload is not something the AI pipeline should act on. */ export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): NormalizeResult { if (payload.event !== 'message_created') { return { ok: false, reason: `unsupported_event:${payload.event ?? 'none'}` }; } if (payload.message_type !== 'incoming') { return { ok: false, reason: `not_incoming:${payload.message_type ?? 'none'}` }; } if (payload.private === true) { return { ok: false, reason: 'private_note' }; } const conversation = payload.conversation ?? {}; const conversationId = Number(conversation.id ?? 0); if (!Number.isFinite(conversationId) || conversationId <= 0) { return { ok: false, reason: 'missing_conversation_id' }; } const rawContent = typeof payload.content === 'string' ? payload.content : ''; const content = cleanEmailBody(rawContent); const attachments = Array.isArray(payload.attachments) ? payload.attachments : []; const normalizedAttachments = attachments.map(normalizeAttachment); if (content === '' && attachments.length === 0) { return { ok: false, reason: 'empty_message' }; } const metaSender = conversation.meta?.sender; const senderEmail = firstEmail( metaSender?.email, payload.sender?.email, conversation.contact_inbox?.source_id, payload.source_id ?? undefined, ); const senderName = String(metaSender?.name ?? payload.sender?.name ?? '').trim(); const senderIdRaw = metaSender?.id ?? payload.sender?.id; const labels = Array.isArray(conversation.labels) ? conversation.labels : []; const customAttributes = conversation.custom_attributes ?? {}; const additionalAttributes = conversation.additional_attributes ?? {}; const messageId = payload.id !== undefined && payload.id !== null ? String(payload.id) : // No message id (older Chatwoot / some channels): derive a stable // surrogate so retries still deduplicate. `hash:${conversationId}:${createHash('sha256').update(rawContent).digest('hex').slice(0, 32)}`; const subject = (additionalAttributes.mail_subject as string | undefined) ?? (additionalAttributes.subject as string | undefined) ?? null; const formMail = detectFormMail({ content, subject, senderEmail, senderName, additionalAttributes }); const effectiveSenderEmail = formMail?.customerEmail || senderEmail; const effectiveSenderName = formMail?.customerName || senderName; const messageCreatedAt = parseMessageCreatedAt((payload as Record).created_at); return { ok: true, event: { source: 'chatwoot', channel: mapChannel(conversation.channel), conversationId, messageId, inboxId: Number(conversation.inbox_id ?? payload.inbox?.id ?? 0), content, subject, messageCreatedAt, senderEmail: effectiveSenderEmail, senderName: effectiveSenderName, senderId: senderIdRaw !== undefined && senderIdRaw !== null ? String(senderIdRaw) : null, labels, customAttributes, additionalAttributes, attachmentCount: attachments.length, attachments: normalizedAttachments, formMail, needsConversationFetch: conversation.labels === undefined || conversation.custom_attributes === undefined, }, }; } function mapChannel(channel?: string): string { if (!channel) return 'email'; if (channel.includes('Email')) return 'email'; if (channel.includes('Api')) return 'api'; if (channel.includes('WebWidget')) return 'webchat'; return channel.replace('Channel::', '').toLowerCase(); } function parseMessageCreatedAt(value: unknown): string | null { if (typeof value === 'number') { const ms = value > 10_000_000_000 ? value : value * 1000; return new Date(ms).toISOString(); } if (typeof value === 'string' && value.trim() !== '') { const n = Number(value); const d = Number.isFinite(n) ? new Date(n > 10_000_000_000 ? n : n * 1000) : new Date(value); if (Number.isFinite(d.getTime())) return d.toISOString(); } return null; } function normalizeAttachment(a: unknown): NormalizedAttachment { const o = (a && typeof a === 'object') ? (a as Record) : {}; const fileName = String(o.file_name ?? o.filename ?? o.name ?? o.data_url ?? '').slice(0, 200); const contentType = String(o.content_type ?? o.contentType ?? o.mime_type ?? o.file_type ?? '').toLowerCase(); const sizeRaw = o.file_size ?? o.filesize ?? o.size ?? o.byte_size; const fileSize = Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null; const inline = Boolean(o.inline ?? o.is_inline ?? o.content_id ?? o.contentId); const contentId = o.content_id || o.contentId ? String(o.content_id ?? o.contentId) : null; return { fileName, contentType, fileSize, inline, contentId }; } function detectFormMail(input: { content: string; subject: string | null; senderEmail: string; senderName: string; additionalAttributes: Record; }): FormMailInfo | null { const subject = input.subject ?? ''; const headers = flattenHeaders(input.additionalAttributes); const replyTo = firstEmail(headers['reply-to'], headers.reply_to, headers.replyto); const bodyEmail = extractField(input.content, /(?:e-?mail|email address|adres e-?mail)\s*[::]\s*([^\s<>]+@[^\s<>]+)/i); const name = extractField(input.content, /(?:imi[ęe]|name|nazwisko)\s*[::]\s*(.+)/i); const phone = extractField(input.content, /(?:telefon|phone|tel\.)\s*[::]\s*([+\d][\d\s().-]{5,})/i); const looksLikeForm = /(formularz|contact form|zapytanie ze strony|wiadomo[śs][ćc] ze strony|web form)/i.test(subject) || /(?:e-?mail|telefon|imi[ęe]|name)\s*[::]/i.test(input.content); const technicalSender = /^(no-?reply|wordpress|sklep|www|formularz|kontakt|notification)/i.test(input.senderEmail.split('@')[0] ?? ''); const customerEmail = replyTo || bodyEmail; if (!looksLikeForm || !customerEmail || (!technicalSender && customerEmail === input.senderEmail)) return null; return { customerEmail, customerName: name || input.senderName, phone, technicalSender: input.senderEmail, formSource: subject || 'www_form', }; } function extractField(content: string, re: RegExp): string { const m = re.exec(content); if (!m?.[1]) return ''; return m[1].split('\n')[0]?.trim().replace(/[<>;,]+$/g, '') ?? ''; } function flattenHeaders(attrs: Record): Record { const out: Record = {}; const raw = (attrs.email as Record | undefined) ?? attrs; const headers = (raw?.headers as Record | undefined) ?? raw; if (!headers || typeof headers !== 'object') return out; for (const [k, v] of Object.entries(headers)) { if (typeof v === 'string' || typeof v === 'number') out[k.toLowerCase()] = String(v); } return out; }