| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- 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<string, unknown>;
- additionalAttributes: Record<string, unknown>;
- 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(/<style[\s\S]*?<\/style>/gi, ' ')
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
- .replace(/<br\s*\/?>/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<string, unknown>).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<string, unknown>) : {};
- 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<string, unknown>;
- }): 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<string, unknown>): Record<string, string> {
- const out: Record<string, string> = {};
- const raw = (attrs.email as Record<string, unknown> | undefined) ?? attrs;
- const headers = (raw?.headers as Record<string, unknown> | 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;
- }
|