messageNormalizer.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { createHash } from 'node:crypto';
  2. import type { ChatwootWebhookPayload } from '../types/chatwoot.js';
  3. /**
  4. * Channel-agnostic event the rest of the pipeline works on. New channels
  5. * (webchat, Allegro, …) only need to produce this shape.
  6. */
  7. export interface SupportMessageEvent {
  8. source: 'chatwoot';
  9. channel: string;
  10. conversationId: number;
  11. messageId: string;
  12. inboxId: number;
  13. content: string;
  14. subject: string | null;
  15. messageCreatedAt: string | null;
  16. senderEmail: string;
  17. senderName: string;
  18. senderId: string | null;
  19. labels: string[];
  20. customAttributes: Record<string, unknown>;
  21. additionalAttributes: Record<string, unknown>;
  22. attachmentCount: number;
  23. attachments: NormalizedAttachment[];
  24. formMail: FormMailInfo | null;
  25. /** true when the webhook did not carry labels/custom attributes. */
  26. needsConversationFetch: boolean;
  27. }
  28. export interface NormalizedAttachment {
  29. fileName: string;
  30. contentType: string;
  31. fileSize: number | null;
  32. inline: boolean;
  33. contentId: string | null;
  34. }
  35. export interface FormMailInfo {
  36. customerEmail: string;
  37. customerName: string;
  38. phone: string;
  39. technicalSender: string;
  40. formSource: string;
  41. }
  42. export type NormalizeResult =
  43. | { ok: true; event: SupportMessageEvent }
  44. | { ok: false; reason: string };
  45. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
  46. /** Strips quoted replies, signatures and HTML so the LLM sees only new text. */
  47. export function cleanEmailBody(raw: string): string {
  48. let text = raw.replace(/\r\n/g, '\n');
  49. if (/<[a-z][\s\S]*>/i.test(text)) {
  50. text = text
  51. .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  52. .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  53. .replace(/<br\s*\/?>/gi, '\n')
  54. .replace(/<\/p>/gi, '\n')
  55. .replace(/<[^>]+>/g, ' ');
  56. }
  57. const cutMarkers = [
  58. /^\s*-{2,}\s*Original Message\s*-{2,}/im,
  59. /^\s*-{2,}\s*Wiadomość oryginalna\s*-{2,}/im,
  60. /^\s*_{5,}\s*$/m,
  61. /^\s*(On|W dniu)\b.*\b(wrote|napisał|napisała|pisze):\s*$/im,
  62. ];
  63. for (const marker of cutMarkers) {
  64. const m = marker.exec(text);
  65. if (m && m.index > 0) text = text.slice(0, m.index);
  66. }
  67. return text
  68. .split('\n')
  69. .filter((line) => !/^\s*>/.test(line))
  70. .join('\n')
  71. .replace(/\n{3,}/g, '\n\n')
  72. .trim();
  73. }
  74. function firstEmail(...candidates: (string | undefined | null)[]): string {
  75. for (const c of candidates) {
  76. const v = (c ?? '').trim();
  77. if (v && EMAIL_RE.test(v)) return v.toLowerCase();
  78. }
  79. return '';
  80. }
  81. /**
  82. * Turns a raw Chatwoot webhook into a SupportMessageEvent, or explains why the
  83. * payload is not something the AI pipeline should act on.
  84. */
  85. export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): NormalizeResult {
  86. if (payload.event !== 'message_created') {
  87. return { ok: false, reason: `unsupported_event:${payload.event ?? 'none'}` };
  88. }
  89. if (payload.message_type !== 'incoming') {
  90. return { ok: false, reason: `not_incoming:${payload.message_type ?? 'none'}` };
  91. }
  92. if (payload.private === true) {
  93. return { ok: false, reason: 'private_note' };
  94. }
  95. const conversation = payload.conversation ?? {};
  96. const conversationId = Number(conversation.id ?? 0);
  97. if (!Number.isFinite(conversationId) || conversationId <= 0) {
  98. return { ok: false, reason: 'missing_conversation_id' };
  99. }
  100. const rawContent = typeof payload.content === 'string' ? payload.content : '';
  101. const content = cleanEmailBody(rawContent);
  102. const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
  103. const normalizedAttachments = attachments.map(normalizeAttachment);
  104. if (content === '' && attachments.length === 0) {
  105. return { ok: false, reason: 'empty_message' };
  106. }
  107. const metaSender = conversation.meta?.sender;
  108. const senderEmail = firstEmail(
  109. metaSender?.email,
  110. payload.sender?.email,
  111. conversation.contact_inbox?.source_id,
  112. payload.source_id ?? undefined,
  113. );
  114. const senderName = String(metaSender?.name ?? payload.sender?.name ?? '').trim();
  115. const senderIdRaw = metaSender?.id ?? payload.sender?.id;
  116. const labels = Array.isArray(conversation.labels) ? conversation.labels : [];
  117. const customAttributes = conversation.custom_attributes ?? {};
  118. const additionalAttributes = conversation.additional_attributes ?? {};
  119. const messageId =
  120. payload.id !== undefined && payload.id !== null
  121. ? String(payload.id)
  122. : // No message id (older Chatwoot / some channels): derive a stable
  123. // surrogate so retries still deduplicate.
  124. `hash:${conversationId}:${createHash('sha256').update(rawContent).digest('hex').slice(0, 32)}`;
  125. const subject =
  126. (additionalAttributes.mail_subject as string | undefined) ??
  127. (additionalAttributes.subject as string | undefined) ??
  128. null;
  129. const formMail = detectFormMail({ content, subject, senderEmail, senderName, additionalAttributes });
  130. const effectiveSenderEmail = formMail?.customerEmail || senderEmail;
  131. const effectiveSenderName = formMail?.customerName || senderName;
  132. const messageCreatedAt = parseMessageCreatedAt((payload as Record<string, unknown>).created_at);
  133. return {
  134. ok: true,
  135. event: {
  136. source: 'chatwoot',
  137. channel: mapChannel(conversation.channel),
  138. conversationId,
  139. messageId,
  140. inboxId: Number(conversation.inbox_id ?? payload.inbox?.id ?? 0),
  141. content,
  142. subject,
  143. messageCreatedAt,
  144. senderEmail: effectiveSenderEmail,
  145. senderName: effectiveSenderName,
  146. senderId: senderIdRaw !== undefined && senderIdRaw !== null ? String(senderIdRaw) : null,
  147. labels,
  148. customAttributes,
  149. additionalAttributes,
  150. attachmentCount: attachments.length,
  151. attachments: normalizedAttachments,
  152. formMail,
  153. needsConversationFetch:
  154. conversation.labels === undefined || conversation.custom_attributes === undefined,
  155. },
  156. };
  157. }
  158. function mapChannel(channel?: string): string {
  159. if (!channel) return 'email';
  160. if (channel.includes('Email')) return 'email';
  161. if (channel.includes('Api')) return 'api';
  162. if (channel.includes('WebWidget')) return 'webchat';
  163. return channel.replace('Channel::', '').toLowerCase();
  164. }
  165. function parseMessageCreatedAt(value: unknown): string | null {
  166. if (typeof value === 'number') {
  167. const ms = value > 10_000_000_000 ? value : value * 1000;
  168. return new Date(ms).toISOString();
  169. }
  170. if (typeof value === 'string' && value.trim() !== '') {
  171. const n = Number(value);
  172. const d = Number.isFinite(n) ? new Date(n > 10_000_000_000 ? n : n * 1000) : new Date(value);
  173. if (Number.isFinite(d.getTime())) return d.toISOString();
  174. }
  175. return null;
  176. }
  177. function normalizeAttachment(a: unknown): NormalizedAttachment {
  178. const o = (a && typeof a === 'object') ? (a as Record<string, unknown>) : {};
  179. const fileName = String(o.file_name ?? o.filename ?? o.name ?? o.data_url ?? '').slice(0, 200);
  180. const contentType = String(o.content_type ?? o.contentType ?? o.mime_type ?? o.file_type ?? '').toLowerCase();
  181. const sizeRaw = o.file_size ?? o.filesize ?? o.size ?? o.byte_size;
  182. const fileSize = Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null;
  183. const inline = Boolean(o.inline ?? o.is_inline ?? o.content_id ?? o.contentId);
  184. const contentId = o.content_id || o.contentId ? String(o.content_id ?? o.contentId) : null;
  185. return { fileName, contentType, fileSize, inline, contentId };
  186. }
  187. function detectFormMail(input: {
  188. content: string;
  189. subject: string | null;
  190. senderEmail: string;
  191. senderName: string;
  192. additionalAttributes: Record<string, unknown>;
  193. }): FormMailInfo | null {
  194. const subject = input.subject ?? '';
  195. const headers = flattenHeaders(input.additionalAttributes);
  196. const replyTo = firstEmail(headers['reply-to'], headers.reply_to, headers.replyto);
  197. const bodyEmail = extractField(input.content, /(?:e-?mail|email address|adres e-?mail)\s*[::]\s*([^\s<>]+@[^\s<>]+)/i);
  198. const name = extractField(input.content, /(?:imi[ęe]|name|nazwisko)\s*[::]\s*(.+)/i);
  199. const phone = extractField(input.content, /(?:telefon|phone|tel\.)\s*[::]\s*([+\d][\d\s().-]{5,})/i);
  200. const looksLikeForm =
  201. /(formularz|contact form|zapytanie ze strony|wiadomo[śs][ćc] ze strony|web form)/i.test(subject) ||
  202. /(?:e-?mail|telefon|imi[ęe]|name)\s*[::]/i.test(input.content);
  203. const technicalSender = /^(no-?reply|wordpress|sklep|www|formularz|kontakt|notification)/i.test(input.senderEmail.split('@')[0] ?? '');
  204. const customerEmail = replyTo || bodyEmail;
  205. if (!looksLikeForm || !customerEmail || (!technicalSender && customerEmail === input.senderEmail)) return null;
  206. return {
  207. customerEmail,
  208. customerName: name || input.senderName,
  209. phone,
  210. technicalSender: input.senderEmail,
  211. formSource: subject || 'www_form',
  212. };
  213. }
  214. function extractField(content: string, re: RegExp): string {
  215. const m = re.exec(content);
  216. if (!m?.[1]) return '';
  217. return m[1].split('\n')[0]?.trim().replace(/[<>;,]+$/g, '') ?? '';
  218. }
  219. function flattenHeaders(attrs: Record<string, unknown>): Record<string, string> {
  220. const out: Record<string, string> = {};
  221. const raw = (attrs.email as Record<string, unknown> | undefined) ?? attrs;
  222. const headers = (raw?.headers as Record<string, unknown> | undefined) ?? raw;
  223. if (!headers || typeof headers !== 'object') return out;
  224. for (const [k, v] of Object.entries(headers)) {
  225. if (typeof v === 'string' || typeof v === 'number') out[k.toLowerCase()] = String(v);
  226. }
  227. return out;
  228. }