Просмотр исходного кода

Harden AI relay runtime controls and gates

Maciek 3 недель назад
Родитель
Сommit
800e02512d

+ 3 - 0
.env.example

@@ -43,6 +43,9 @@ FLOWISE_API_KEY=
 # Optional; derived from FLOWISE_PREDICT_URL when empty. Used by /ready only.
 FLOWISE_BASE_URL=
 FLOWISE_TIMEOUT_MS=90000
+# Optional read-only KB sidecar/API used by /ops. Leave empty until deployed.
+KB_API_BASE_URL=
+KB_API_TOKEN=
 
 # --- WooCommerce / WordPress ----------------------------------------------
 WOOCOMMERCE_BASE_URL=https://easyklima.com

+ 23 - 0
prisma/migrations/20260824190000_runtime_controls/migration.sql

@@ -0,0 +1,23 @@
+-- Runtime panic button/cutoff settings and durable Flowise session IDs.
+CREATE TABLE "RuntimeSetting" (
+    "key" TEXT NOT NULL PRIMARY KEY,
+    "value" TEXT NOT NULL,
+    "updatedBy" TEXT,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+CREATE TABLE "ConversationState" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "conversationId" INTEGER NOT NULL,
+    "flowiseSessionId" TEXT NOT NULL,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+CREATE UNIQUE INDEX "ConversationState_conversationId_key" ON "ConversationState"("conversationId");
+CREATE INDEX "ConversationState_flowiseSessionId_idx" ON "ConversationState"("flowiseSessionId");
+
+INSERT INTO "RuntimeSetting" ("key", "value", "updatedBy", "updatedAt") VALUES
+  ('bot_enabled', 'true', 'migration', CURRENT_TIMESTAMP),
+  ('max_message_age_minutes', '240', 'migration', CURRENT_TIMESTAMP);

+ 20 - 0
prisma/schema.prisma

@@ -101,3 +101,23 @@ model TranslationCache {
   @@index([conversationId])
   @@index([messageId])
 }
+
+/// Runtime operator controls used by /ops panic button and webhook cutoffs.
+model RuntimeSetting {
+  key       String   @id
+  value     String
+  updatedBy String?
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+}
+
+/// Conversation-local durable state that must survive relay restarts.
+model ConversationState {
+  id               String   @id @default(cuid())
+  conversationId   Int      @unique
+  flowiseSessionId String
+  createdAt        DateTime @default(now())
+  updatedAt        DateTime @updatedAt
+
+  @@index([flowiseSessionId])
+}

+ 42 - 0
public/ops.html

@@ -128,6 +128,9 @@
     <span class="badge" id="mode-badge">—</span>
     <span class="badge"><span class="dot" id="health-dot"></span><span id="health-text">health</span></span>
     <span class="badge"><span class="dot" id="ready-dot"></span><span id="ready-text">ready</span></span>
+    <span class="badge" id="bot-badge">AI —</span>
+    <button id="panic-off" title="Natychmiast zatrzymaj kolejkowanie AI">AI OFF</button>
+    <button id="panic-on" class="primary" title="Włącz AI tylko dla nowych wiadomości od teraz">AI ON od teraz</button>
     <span class="spacer"></span>
     <label class="badge" style="cursor:pointer">
       <input type="checkbox" id="autorefresh"> auto 15&nbsp;s
@@ -144,6 +147,7 @@
       <button data-tab="jobs">Kolejka</button>
       <button data-tab="messages">Wiadomości</button>
       <button data-tab="tickets">Tickety</button>
+      <button data-tab="kb">KB</button>
       <button data-tab="ready">Zależności</button>
     </div>
 
@@ -233,6 +237,19 @@
     });
   }
 
+  function apiPatch(path, body) {
+    return fetch(path, {
+      method: 'PATCH',
+      headers: { Authorization: 'Bearer ' + token(), 'Content-Type': 'application/json' },
+      body: JSON.stringify(body || {}),
+      cache: 'no-store'
+    }).then(function (r) {
+      if (r.status === 401) { logout('Token odrzucony przez serwer.'); throw new Error('unauthorized'); }
+      if (!r.ok) throw new Error('HTTP ' + r.status);
+      return r.json();
+    });
+  }
+
   function logout(msg) {
     sessionStorage.removeItem(KEY);
     stopAuto();
@@ -375,6 +392,20 @@
       }
     },
 
+    kb: {
+      title: 'Knowledge Base',
+      filters: ['limit'],
+      load: function () { return api('/admin/kb', { path: '/kb/status' }); },
+      render: function (d) {
+        var box = el('div', 'meta');
+        box.style.maxWidth = 'none';
+        box.style.padding = '14px';
+        box.appendChild(el('div', null, d.ok ? 'KB API działa. Dane poniżej są bez embeddingów/sekretów.' : 'KB API niedostępne.'));
+        box.appendChild(el('pre', 'mono', shorten(d.data, 4000)));
+        return box;
+      }
+    },
+
     ready: {
       title: 'Zależności (/ready)',
       filters: [],
@@ -485,6 +516,9 @@
           s.appendChild(box);
         });
       var st = d.settings || {};
+      var rt = d.runtime || {};
+      $('bot-badge').textContent = rt.botEnabled ? 'AI ON · max ' + rt.maxMessageAgeMinutes + 'm' : 'AI OFF';
+      $('bot-badge').style.color = rt.botEnabled ? 'var(--ok)' : 'var(--bad)';
       var box = el('div', 'stat');
       box.appendChild(el('div', 'k', 'Ustawienia'));
       box.appendChild(el('div', 'meta',
@@ -541,6 +575,14 @@
   $('apply').addEventListener('click', loadTab);
   $('f-conv').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadTab(); });
   $('refresh').addEventListener('click', refresh);
+  $('panic-off').addEventListener('click', function () {
+    if (!confirm('Wyłączyć AI? Nowe wiadomości nie będą kolejkowane.')) return;
+    apiPatch('/admin/runtime', { botEnabled: false }).then(refresh).catch(function (err) { alert('Błąd: ' + err.message); });
+  });
+  $('panic-on').addEventListener('click', function () {
+    if (!confirm('Włączyć AI tylko dla wiadomości od teraz? Backlog zostanie odcięty.')) return;
+    apiPatch('/admin/runtime', { botEnabled: true, enableFromNow: true, maxMessageAgeMinutes: 240 }).then(refresh).catch(function (err) { alert('Błąd: ' + err.message); });
+  });
   $('logout').addEventListener('click', function () { logout(); });
   $('autorefresh').addEventListener('change', function (e) { e.target.checked ? startAuto() : stopAuto(); });
 

+ 8 - 0
src/clients/chatwootClient.ts

@@ -137,6 +137,14 @@ export class ChatwootClient {
     });
   }
 
+  async sendPrivateNote(conversationId: number, content: string): Promise<unknown> {
+    return this.api('POST', `/conversations/${conversationId}/messages`, {
+      content,
+      message_type: 'outgoing',
+      private: true,
+    });
+  }
+
   /** Lightweight reachability probe for /ready — never exposes the token. */
   async ping(): Promise<{ ok: boolean; status: number; target: string }> {
     const url = this.url('/conversations?status=open&page=1');

+ 5 - 0
src/clients/flowiseClient.ts

@@ -45,8 +45,13 @@ export class FlowiseClient {
 
     if (res.status >= 400) {
       logger.error('Flowise error response', { status: res.status });
+      if (res.status === 429) throw upstream('OPENAI_RATE_LIMIT', 'Flowise/OpenAI returned HTTP 429.');
+      if (res.status === 401 || res.status === 403) throw upstream('FLOWISE_AUTH_ERROR', `Flowise returned HTTP ${res.status}.`);
       throw upstream('FLOWISE_ERROR', `Flowise returned HTTP ${res.status}.`);
     }
+    if (res.json === null && /^\s*</.test(res.body)) {
+      throw upstream('FLOWISE_INVALID_JSON', 'Flowise returned HTML instead of JSON/text.');
+    }
 
     return normaliseFlowiseResponse(res.json, res.body);
   }

+ 16 - 0
src/clients/wpStoreClient.ts

@@ -69,6 +69,22 @@ export class WpStoreClient {
     return res.json ?? res.body;
   }
 
+  async shippingEligibility(payload: Record<string, unknown>): Promise<unknown> {
+    const res = await request<unknown>(`${this.base}/shipping-eligibility`, {
+      method: 'POST',
+      json: payload,
+      headers: this.headers(),
+      label: 'wp-store POST /shipping-eligibility',
+    });
+    if (res.status >= 400) {
+      throw upstream(
+        'WP_STORE_API_ERROR',
+        `WP Store API returned HTTP ${res.status} for /shipping-eligibility.`,
+      );
+    }
+    return res.json ?? res.body;
+  }
+
   async ping(): Promise<{ ok: boolean; status: number; target: string }> {
     try {
       // zone 0 always exists in WooCommerce ("Rest of the World").

+ 2 - 0
src/config.ts

@@ -53,6 +53,8 @@ const schema = z.object({
   FLOWISE_API_KEY: z.string().default(''),
   FLOWISE_BASE_URL: z.string().default(''),
   FLOWISE_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
+  KB_API_BASE_URL: z.string().default(''),
+  KB_API_TOKEN: z.string().default(''),
 
   // --- WooCommerce / WordPress ---
   WOOCOMMERCE_BASE_URL: z.string().url(),

+ 39 - 0
src/domain/attachmentPolicy.ts

@@ -0,0 +1,39 @@
+import type { NormalizedAttachment } from './messageNormalizer.js';
+
+export interface AttachmentVerdict {
+  requiresHandoff: boolean;
+  reason: string;
+  significant: NormalizedAttachment[];
+}
+
+const SIGNIFICANT_NAME_RE = /(paragon|faktura|invoice|receipt|zdj[eę]cie|photo|foto|uszkodzenie|damage|attachment|za[lł][aą]cznik)/i;
+const INLINE_NAME_RE = /(logo|signature|image00\d|facebook|linkedin|instagram|twitter|x-logo|stopka)/i;
+const IMPORTANT_BODY_RE = /(w za[lł][aą]czniku|za[lł][aą]czam|wysy[lł]am zdj[eę]cie|attached|attachment|see file|foto|photo|paragon|faktura|invoice)/i;
+
+export function classifyAttachments(attachments: NormalizedAttachment[], content: string): AttachmentVerdict {
+  if (attachments.length === 0) return { requiresHandoff: false, reason: 'none', significant: [] };
+  const significant = attachments.filter((a) => isSignificantAttachment(a, content, attachments.length));
+  if (significant.length > 0) return { requiresHandoff: true, reason: 'significant_attachment', significant };
+  return { requiresHandoff: false, reason: 'only_inline_or_signature_attachments', significant: [] };
+}
+
+function isSignificantAttachment(a: NormalizedAttachment, content: string, total: number): boolean {
+  const name = a.fileName || '';
+  const mime = a.contentType || '';
+  const size = a.fileSize ?? Number.POSITIVE_INFINITY;
+  if (/\b(pdf|msword|officedocument|zip|rar|7z|excel|spreadsheet|csv)\b/i.test(mime)) return true;
+  if (SIGNIFICANT_NAME_RE.test(name)) return true;
+  if (IMPORTANT_BODY_RE.test(content) && !looksLikeInlineLogo(a)) return true;
+  if (mime.startsWith('image/')) {
+    const small = mime.includes('jpeg') || mime.includes('jpg') ? size <= 50_000 : size <= 25_000;
+    if (total > 1 && !a.inline) return true;
+    return !(a.inline && small && INLINE_NAME_RE.test(name));
+  }
+  if (!mime && !looksLikeInlineLogo(a)) return true;
+  return false;
+}
+
+function looksLikeInlineLogo(a: NormalizedAttachment): boolean {
+  const size = a.fileSize ?? Number.POSITIVE_INFINITY;
+  return a.inline && size <= 50_000 && INLINE_NAME_RE.test(a.fileName || '');
+}

+ 143 - 4
src/domain/conversationPipeline.ts

@@ -4,6 +4,9 @@ import { config } from '../config.js';
 import { logger } from '../logger.js';
 import { audit } from '../store/auditLog.js';
 import { setMessageStatus } from '../store/idempotencyStore.js';
+import { evaluateRuntimeSkip, getFlowiseSessionId } from '../store/runtimeSettings.js';
+import { RelayError } from '../errors.js';
+import { classifyAttachments } from './attachmentPolicy.js';
 import { evaluateSpam } from './spamGate.js';
 import { createTicket, hasLocalTicket, isTicketMode } from './ticketService.js';
 import type { SupportMessageEvent } from './messageNormalizer.js';
@@ -18,6 +21,7 @@ export type PipelineOutcome =
   | { action: 'spam'; reason: string }
   | { action: 'reply' }
   | { action: 'handoff'; ticketNumber: string }
+  | { action: 'ai_error'; code: string }
   | { action: 'no_reply'; reason: string };
 
 /**
@@ -53,6 +57,19 @@ export async function processMessageEvent(
     additionalAttributes = { ...additionalAttributes, ...(conv.additional_attributes ?? {}) };
   }
 
+  const runtime = await evaluateRuntimeSkip(event.messageCreatedAt ? new Date(event.messageCreatedAt) : null);
+  if (runtime.decision.skip) {
+    await setMessageStatus(event.source, event.messageId, 'skipped', runtime.decision.reason);
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: `skipped_${runtime.decision.reason}`,
+      summary: `Message stopped by runtime controls: ${runtime.decision.reason}`,
+      meta: { stage: 'pipeline', messageCreatedAt: event.messageCreatedAt },
+    });
+    return { action: 'skipped', reason: runtime.decision.reason };
+  }
+
   if (cfg.CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT && conv && !isAssignedToRequiredAgentBot(conv)) {
     await setMessageStatus(event.source, event.messageId, 'skipped', 'not_assigned_to_bot');
     await audit({
@@ -104,14 +121,74 @@ export async function processMessageEvent(
     }
   }
 
+  if (event.formMail) {
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'form_mail_detected',
+      summary: 'WWW/contact-form mail detected and mapped to customer sender',
+      meta: { technicalSender: event.formMail.technicalSender, hasPhone: event.formMail.phone !== '' },
+    });
+    try {
+      await chatwoot.setCustomAttributes(event.conversationId, {
+        form_mail: true,
+        form_customer_email: event.formMail.customerEmail,
+        form_source: event.formMail.formSource,
+      });
+      await chatwoot.addLabel(event.conversationId, 'www-form');
+    } catch {
+      logger.warn('Could not persist form-mail attributes (non-fatal)', { conversationId: event.conversationId });
+    }
+  }
+
+  const attachmentVerdict = classifyAttachments(event.attachments, event.content);
+  if (attachmentVerdict.requiresHandoff) {
+    const ticket = await createTicket(event.conversationId, attachmentVerdict.reason, chatwoot);
+    try {
+      await chatwoot.addLabel(event.conversationId, 'attachment');
+      await chatwoot.sendPrivateNote(
+        event.conversationId,
+        `Wiadomość zawiera istotny lub nieobsługiwany załącznik; AI nie analizowała pliku. Ticket: ${ticket.ticketNumber}.`,
+      );
+    } catch {
+      logger.warn('Could not persist attachment handoff note/label (non-fatal)', { conversationId: event.conversationId });
+    }
+    await setMessageStatus(event.source, event.messageId, 'ticket', 'attachment_handoff');
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'attachment_handoff',
+      summary: 'Message contains a significant attachment — handed off before Flowise',
+      meta: { significantCount: attachmentVerdict.significant.length },
+    });
+    return { action: 'handoff', ticketNumber: ticket.ticketNumber };
+  }
+
   // Snapshot before the LLM turn: the agent may call /tools/new_ticket while
   // Flowise is thinking, and afterwards we need to tell "ticketed just now"
   // apart from "a ticket row already existed from an earlier, since-reopened
   // conversation".
   const hadTicketBefore = await hasLocalTicket(event.conversationId);
 
-  const payload = buildFlowisePayload(event);
-  const response = await flowise.predict(payload);
+  const payload = await buildFlowisePayload(event, customAttributes);
+  if (typeof customAttributes.flowise_session_id !== 'string') {
+    try {
+      await chatwoot.setCustomAttributes(event.conversationId, {
+        ...customAttributes,
+        flowise_session_id: String(payload.overrideConfig.sessionId),
+      });
+    } catch {
+      logger.warn('Could not persist flowise_session_id in Chatwoot (local state still exists)', { conversationId: event.conversationId });
+    }
+  }
+  let response;
+  try {
+    response = await flowise.predict(payload);
+  } catch (err) {
+    const code = classifyAiError(err);
+    await handleAiFailure(event, chatwoot, code, err);
+    return { action: 'ai_error', code };
+  }
 
   await audit({
     conversationId: event.conversationId,
@@ -187,7 +264,10 @@ function isAssignedToRequiredAgentBot(
  * Flowise payload. `[CONTACT_INFO]` is a contract with the deployed custom
  * tools: `get_order_data` parses `contact_email:` out of `$flow.input`.
  */
-export function buildFlowisePayload(event: SupportMessageEvent): FlowisePayload {
+export async function buildFlowisePayload(
+  event: SupportMessageEvent,
+  customAttributes: Record<string, unknown> = event.customAttributes,
+): Promise<FlowisePayload> {
   const cfg = config();
 
   const contactLines: string[] = ['[CONTACT_INFO]'];
@@ -199,11 +279,12 @@ export function buildFlowisePayload(event: SupportMessageEvent): FlowisePayload
   contactLines.push('[/CONTACT_INFO]');
 
   const question = `${contactLines.join('\n')}\n\n---\n${event.content}`;
+  const { sessionId, created } = await getFlowiseSessionId(event.conversationId, customAttributes);
 
   return {
     question,
     overrideConfig: {
-      sessionId: `chatwoot:${event.conversationId}`,
+      sessionId,
       conversationId: event.conversationId,
       inboxId: event.inboxId,
       messageId: event.messageId,
@@ -219,6 +300,64 @@ export function buildFlowisePayload(event: SupportMessageEvent): FlowisePayload
       labels: event.labels,
       subject: event.subject,
       attachments: event.attachmentCount,
+      flowiseSessionPersisted: !created,
+      formMail: event.formMail
+        ? { technicalSender: event.formMail.technicalSender, customerEmail: event.formMail.customerEmail }
+        : null,
     },
   };
 }
+
+function classifyAiError(err: unknown): string {
+  if (err instanceof RelayError) {
+    if (err.code === 'UPSTREAM_TIMEOUT') return 'FLOWISE_TIMEOUT';
+    if (/quota|insufficient/i.test(err.message)) return 'OPENAI_INSUFFICIENT_QUOTA';
+    if (/rate.?limit|429/i.test(err.message)) return 'OPENAI_RATE_LIMIT';
+    if (/invalid json|doctype|html/i.test(err.message)) return 'FLOWISE_INVALID_JSON';
+    if (err.code.includes('FLOWISE')) return err.code;
+    return err.code;
+  }
+  const msg = err instanceof Error ? err.message : String(err);
+  if (/timeout|abort/i.test(msg)) return 'FLOWISE_TIMEOUT';
+  if (/insufficient_quota|quota|billing|balance/i.test(msg)) return 'OPENAI_INSUFFICIENT_QUOTA';
+  if (/rate.?limit|429/i.test(msg)) return 'OPENAI_RATE_LIMIT';
+  return 'FLOWISE_ERROR';
+}
+
+async function handleAiFailure(
+  event: SupportMessageEvent,
+  chatwoot: ChatwootClient,
+  code: string,
+  err: unknown,
+): Promise<void> {
+  const terminal = /INSUFFICIENT_QUOTA|AUTH|INVALID|401|403/.test(code);
+  const now = new Date().toISOString();
+  const safeMessage = err instanceof Error ? err.message.slice(0, 300) : String(err).slice(0, 300);
+  await setMessageStatus(event.source, event.messageId, 'failed', code);
+  await audit({
+    conversationId: event.conversationId,
+    messageId: event.messageId,
+    eventType: 'ai_error',
+    summary: `AI/Flowise failure: ${code}`,
+    meta: { code, terminal, error: safeMessage },
+  });
+  try {
+    await chatwoot.addLabel(event.conversationId, 'ai-error');
+    if (terminal) await chatwoot.addLabel(event.conversationId, 'ticket');
+    await chatwoot.setCustomAttributes(event.conversationId, {
+      ai_status: 'error',
+      ai_error_code: code,
+      ai_error_at: now,
+      ai_last_failed_message_id: event.messageId,
+      ...(terminal ? { handoff: true, handoff_reason: code } : {}),
+    });
+    await chatwoot.sendPrivateNote(
+      event.conversationId,
+      `AI nie odpowiedziała na wiadomość ${event.messageId}. Kod: ${code}. Sprawdź klienta ręcznie; relay nie wysłał duplikatu odpowiedzi do klienta.`,
+    );
+    await chatwoot.unassignConversation(event.conversationId);
+    await chatwoot.setConversationStatus(event.conversationId, 'open');
+  } catch {
+    logger.warn('Could not persist AI failure side effects in Chatwoot', { conversationId: event.conversationId, code });
+  }
+}

+ 98 - 2
src/domain/messageNormalizer.ts

@@ -13,6 +13,7 @@ export interface SupportMessageEvent {
   inboxId: number;
   content: string;
   subject: string | null;
+  messageCreatedAt: string | null;
   senderEmail: string;
   senderName: string;
   senderId: string | null;
@@ -20,10 +21,28 @@ export interface SupportMessageEvent {
   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 };
@@ -94,6 +113,7 @@ export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): Norma
   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' };
@@ -124,6 +144,10 @@ export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): Norma
     (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,
@@ -135,13 +159,16 @@ export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): Norma
       inboxId: Number(conversation.inbox_id ?? payload.inbox?.id ?? 0),
       content,
       subject,
-      senderEmail,
-      senderName,
+      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,
     },
@@ -155,3 +182,72 @@ function mapChannel(channel?: string): string {
   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;
+}

+ 11 - 0
src/domain/spamGate.ts

@@ -41,8 +41,13 @@ const AUTO_HEADER_KEYS = [
   'auto-submitted',
   'x-autoreply',
   'x-autorespond',
+  'x-auto-response-suppress',
+  'x-ms-exchange-inbox-rules-loop',
+  'x-loop',
+  'feedback-id',
   'precedence',
   'list-unsubscribe',
+  'list-id',
   'x-spam-flag',
 ];
 
@@ -71,6 +76,12 @@ export function evaluateSpam(event: SupportMessageEvent): SpamVerdict {
   if (AUTOREPLY_SUBJECT_RE.test(subject)) return { spam: true, reason: 'autoreply_subject' };
   if (DMARC_REPORT_RE.test(subject)) return { spam: true, reason: 'dmarc_report' };
   if (NEWSLETTER_SUBJECT_RE.test(subject)) return { spam: true, reason: 'newsletter_subject' };
+  if (/^(re:|odp:)?\s*(automatic reply|out of office|poza biurem)/i.test(content.slice(0, 120))) {
+    return { spam: true, reason: 'autoreply_body' };
+  }
+  if (/^>\s*(Dzie[ńn] dobry|Hello|Hi)\b/im.test(content) && content.length < 500) {
+    return { spam: true, reason: 'probable_loop_quote' };
+  }
 
   const headers = normaliseHeaders(event.additionalAttributes);
   for (const key of AUTO_HEADER_KEYS) {

+ 68 - 2
src/http/routes/admin.ts

@@ -1,9 +1,11 @@
 import { Router, type Request } from 'express';
 import { requireAdminAuth } from '../middleware/auth.js';
-import { knownEventTypes, recentEvents } from '../../store/auditLog.js';
+import { knownEventTypes, recentEvents, audit } from '../../store/auditLog.js';
 import { queueStats } from '../../queue/jobQueue.js';
 import { db } from '../../store/db.js';
 import { config } from '../../config.js';
+import { getRuntimeSettings, updateRuntimeSettings } from '../../store/runtimeSettings.js';
+import { request, safeLabel } from '../../clients/httpClient.js';
 
 export const adminRouter = Router();
 
@@ -172,12 +174,13 @@ adminRouter.get('/admin/tickets', async (req, res, next) => {
 adminRouter.get('/admin/meta', async (_req, res, next) => {
   try {
     const cfg = config();
-    const [eventTypes, stats, messages, tickets, events] = await Promise.all([
+    const [eventTypes, stats, messages, tickets, events, runtime] = await Promise.all([
       knownEventTypes(),
       queueStats(),
       db().processedMessage.groupBy({ by: ['status'], _count: { _all: true } }),
       db().ticket.count(),
       db().auditEvent.count(),
+      getRuntimeSettings(),
     ]);
 
     res.json({
@@ -199,13 +202,76 @@ adminRouter.get('/admin/meta', async (_req, res, next) => {
         spamGate: cfg.SPAM_GATE_ENABLED,
         worker: cfg.WORKER_ENABLED,
         logPii: cfg.LOG_PII,
+        kbApiConfigured: cfg.KB_API_BASE_URL !== '',
       },
+      runtime,
     });
   } catch (err) {
     next(err);
   }
 });
 
+// ───────────────────────────────────────── GET/PATCH /admin/runtime
+adminRouter.get('/admin/runtime', async (_req, res, next) => {
+  try {
+    res.json({ ok: true, runtime: await getRuntimeSettings() });
+  } catch (err) {
+    next(err);
+  }
+});
+
+adminRouter.patch('/admin/runtime', async (req, res, next) => {
+  try {
+    const body = (req.body ?? {}) as Record<string, unknown>;
+    const updates: { botEnabled?: boolean; ignoreMessagesBefore?: Date | null; maxMessageAgeMinutes?: number; updatedBy: string } = {
+      updatedBy: 'ops',
+    };
+    if (typeof body.botEnabled === 'boolean') updates.botEnabled = body.botEnabled;
+    if (body.ignoreMessagesBefore === null) updates.ignoreMessagesBefore = null;
+    if (typeof body.ignoreMessagesBefore === 'string') {
+      const d = new Date(body.ignoreMessagesBefore);
+      if (Number.isFinite(d.getTime())) updates.ignoreMessagesBefore = d;
+    }
+    if (body.ignoreBacklogFromNow === true || body.enableFromNow === true) updates.ignoreMessagesBefore = new Date();
+    if (body.maxMessageAgeMinutes !== undefined) {
+      const n = Number(body.maxMessageAgeMinutes);
+      if (Number.isFinite(n) && n > 0) updates.maxMessageAgeMinutes = Math.trunc(n);
+    }
+    const runtime = await updateRuntimeSettings(updates);
+    await audit({ eventType: 'runtime_settings_updated', summary: 'Runtime AI settings changed from /ops', meta: { keys: Object.keys(body) } });
+    res.json({ ok: true, runtime });
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ─────────────────────────────────────────── GET /admin/kb
+adminRouter.get('/admin/kb', async (req, res, next) => {
+  try {
+    const cfg = config();
+    if (!cfg.KB_API_BASE_URL) {
+      res.status(501).json({ ok: false, code: 'KB_API_NOT_CONFIGURED', message: 'KB sidecar/API is not configured.' });
+      return;
+    }
+    const path = stringOf(req, 'path') || '/kb/status';
+    if (!path.startsWith('/kb/')) {
+      res.status(400).json({ ok: false, code: 'INVALID_KB_PATH', message: 'Only /kb/* paths are allowed.' });
+      return;
+    }
+    const url = new URL(path, cfg.KB_API_BASE_URL.replace(/\/+$/, '') + '/');
+    for (const [k, v] of Object.entries(req.query)) {
+      if (k === 'path' || typeof v !== 'string') continue;
+      url.searchParams.set(k, v);
+    }
+    const headers: Record<string, string> = {};
+    if (cfg.KB_API_TOKEN) headers.Authorization = `Bearer ${cfg.KB_API_TOKEN}`;
+    const upstream = await request(url.toString(), { headers, label: `kb ${safeLabel(url.toString())}`, timeoutMs: 10_000 });
+    res.status(upstream.status >= 400 ? 502 : 200).json({ ok: upstream.status < 400, status: upstream.status, data: upstream.json ?? upstream.body });
+  } catch (err) {
+    next(err);
+  }
+});
+
 function safeParse(json: string): unknown {
   try {
     return JSON.parse(json);

+ 23 - 0
src/http/routes/tools.ts

@@ -11,6 +11,7 @@ import { formatOrder, formatPaymentGateway, formatProduct } from '../../domain/f
 import { audit } from '../../store/auditLog.js';
 import { logger } from '../../logger.js';
 import {
+  checkShippingEligibilitySchema,
   getCarDataSchema,
   getOrderDataSchema,
   getPaymentMethodsSchema,
@@ -192,6 +193,28 @@ toolsRouter.post('/tools/get_payment_methods', async (req, res, next) => {
   }
 });
 
+// ─────────────────────────────────── POST /tools/check_shipping_eligibility
+
+toolsRouter.post('/tools/check_shipping_eligibility', async (req, res, next) => {
+  try {
+    const body = parseBody(checkShippingEligibilitySchema, req);
+    const country = pickString(body.country).toUpperCase();
+    const postcode = pickString(body.postcode, body.postalCode, body.postal_code);
+    const language = pickString(body.language) || null;
+    if (!country || !postcode) {
+      throw badRequest('MISSING_PARAMS', 'country and postcode are required.');
+    }
+    const payload: Record<string, unknown> = { country, postcode };
+    const city = pickString(body.city);
+    if (city) payload.city = city;
+    if (language) payload.language = language;
+    const data = await new WpStoreClient().shippingEligibility(payload);
+    res.json({ ok: true, data });
+  } catch (err) {
+    next(err);
+  }
+});
+
 // ─────────────────────────────────── POST /tools/get_product_compatibility
 
 toolsRouter.post('/tools/get_product_compatibility', async (req, res, next) => {

+ 23 - 0
src/http/routes/webhooks.ts

@@ -8,6 +8,7 @@ import { audit } from '../../store/auditLog.js';
 import { logger } from '../../logger.js';
 import { config } from '../../config.js';
 import { drainOnce } from '../../queue/worker.js';
+import { evaluateRuntimeSkip } from '../../store/runtimeSettings.js';
 
 export const webhookRouter = Router();
 
@@ -39,6 +40,28 @@ webhookRouter.post('/webhooks/chatwoot', async (req, res, next) => {
 
     const event = normalized.event;
 
+    const runtime = await evaluateRuntimeSkip(event.messageCreatedAt ? new Date(event.messageCreatedAt) : null);
+    if (runtime.decision.skip) {
+      await setMessageStatus(event.source, event.messageId, 'skipped', runtime.decision.reason).catch(async () => {
+        await claimMessage(event.source, event.messageId, event.conversationId);
+        await setMessageStatus(event.source, event.messageId, 'skipped', runtime.decision.skip ? runtime.decision.reason : 'runtime_skip');
+      });
+      await audit({
+        conversationId: event.conversationId,
+        messageId: event.messageId,
+        eventType: `skipped_${runtime.decision.reason}`,
+        summary: `Message not queued: ${runtime.decision.reason}`,
+        meta: {
+          stage: 'webhook',
+          messageCreatedAt: event.messageCreatedAt,
+          maxMessageAgeMinutes: runtime.settings.maxMessageAgeMinutes,
+          ignoreMessagesBefore: runtime.settings.ignoreMessagesBefore?.toISOString() ?? null,
+        },
+      });
+      res.status(200).json({ ok: true, skipped: true, reason: runtime.decision.reason, conversationId: event.conversationId });
+      return;
+    }
+
     // Idempotency: the unique (source, messageId) insert decides the winner of
     // a retry race before any job is created.
     const claim = await claimMessage(event.source, event.messageId, event.conversationId);

+ 92 - 0
src/store/runtimeSettings.ts

@@ -0,0 +1,92 @@
+import { db } from './db.js';
+
+const BOT_ENABLED = 'bot_enabled';
+const IGNORE_MESSAGES_BEFORE = 'ignore_messages_before';
+const MAX_MESSAGE_AGE_MINUTES = 'max_message_age_minutes';
+const DEFAULT_MAX_AGE_MINUTES = 240;
+
+export interface RuntimeSettingsSnapshot {
+  botEnabled: boolean;
+  ignoreMessagesBefore: Date | null;
+  maxMessageAgeMinutes: number;
+  updatedAt: Date | null;
+}
+
+export type SkipDecision =
+  | { skip: false }
+  | { skip: true; reason: 'bot_disabled' | 'before_cutoff' | 'too_old' | 'missing_message_created_at' };
+
+export async function getRuntimeSettings(): Promise<RuntimeSettingsSnapshot> {
+  const rows = await db().runtimeSetting.findMany({
+    where: { key: { in: [BOT_ENABLED, IGNORE_MESSAGES_BEFORE, MAX_MESSAGE_AGE_MINUTES] } },
+  });
+  const byKey = new Map(rows.map((r) => [r.key, r]));
+  const maxAge = Number(byKey.get(MAX_MESSAGE_AGE_MINUTES)?.value ?? DEFAULT_MAX_AGE_MINUTES);
+  return {
+    botEnabled: (byKey.get(BOT_ENABLED)?.value ?? 'true') !== 'false',
+    ignoreMessagesBefore: parseDate(byKey.get(IGNORE_MESSAGES_BEFORE)?.value),
+    maxMessageAgeMinutes: Number.isFinite(maxAge) && maxAge > 0 ? Math.trunc(maxAge) : DEFAULT_MAX_AGE_MINUTES,
+    updatedAt: rows.reduce<Date | null>((latest, r) => (!latest || r.updatedAt > latest ? r.updatedAt : latest), null),
+  };
+}
+
+export async function updateRuntimeSettings(input: {
+  botEnabled?: boolean;
+  ignoreMessagesBefore?: Date | null;
+  maxMessageAgeMinutes?: number;
+  updatedBy?: string;
+}): Promise<RuntimeSettingsSnapshot> {
+  const updatedBy = input.updatedBy ?? 'ops';
+  const writes: Promise<unknown>[] = [];
+  if (input.botEnabled !== undefined) writes.push(upsert(BOT_ENABLED, input.botEnabled ? 'true' : 'false', updatedBy));
+  if (input.ignoreMessagesBefore !== undefined) {
+    writes.push(upsert(IGNORE_MESSAGES_BEFORE, input.ignoreMessagesBefore ? input.ignoreMessagesBefore.toISOString() : '', updatedBy));
+  }
+  if (input.maxMessageAgeMinutes !== undefined) {
+    writes.push(upsert(MAX_MESSAGE_AGE_MINUTES, String(Math.max(1, Math.trunc(input.maxMessageAgeMinutes))), updatedBy));
+  }
+  await Promise.all(writes);
+  return getRuntimeSettings();
+}
+
+export async function evaluateRuntimeSkip(messageCreatedAt: Date | null): Promise<{ settings: RuntimeSettingsSnapshot; decision: SkipDecision }> {
+  const settings = await getRuntimeSettings();
+  if (!settings.botEnabled) return { settings, decision: { skip: true, reason: 'bot_disabled' } };
+  if (!messageCreatedAt) return { settings, decision: { skip: true, reason: 'missing_message_created_at' } };
+  if (settings.ignoreMessagesBefore && messageCreatedAt < settings.ignoreMessagesBefore) {
+    return { settings, decision: { skip: true, reason: 'before_cutoff' } };
+  }
+  const ageMs = Date.now() - messageCreatedAt.getTime();
+  if (ageMs > settings.maxMessageAgeMinutes * 60_000) {
+    return { settings, decision: { skip: true, reason: 'too_old' } };
+  }
+  return { settings, decision: { skip: false } };
+}
+
+export async function getFlowiseSessionId(conversationId: number, attrs: Record<string, unknown> = {}): Promise<{ sessionId: string; created: boolean }> {
+  const attr = typeof attrs.flowise_session_id === 'string' ? attrs.flowise_session_id.trim() : '';
+  if (attr) return { sessionId: attr, created: false };
+  const existing = await db().conversationState.findUnique({ where: { conversationId } });
+  if (existing?.flowiseSessionId) return { sessionId: existing.flowiseSessionId, created: false };
+  const sessionId = `chatwoot:${conversationId}`;
+  await db().conversationState.upsert({
+    where: { conversationId },
+    create: { conversationId, flowiseSessionId: sessionId },
+    update: { flowiseSessionId: sessionId },
+  });
+  return { sessionId, created: true };
+}
+
+function parseDate(value: string | undefined): Date | null {
+  if (!value) return null;
+  const d = new Date(value);
+  return Number.isFinite(d.getTime()) ? d : null;
+}
+
+function upsert(key: string, value: string, updatedBy: string) {
+  return db().runtimeSetting.upsert({
+    where: { key },
+    create: { key, value, updatedBy },
+    update: { value, updatedBy },
+  });
+}

+ 9 - 0
src/types/tools.ts

@@ -28,6 +28,15 @@ export const getShippingDataSchema = z.object({
   currency: optionalString,
 });
 
+export const checkShippingEligibilitySchema = z.object({
+  country: optionalString,
+  postcode: optionalString,
+  postalCode: optionalString,
+  postal_code: optionalString,
+  city: optionalString,
+  language: optionalString,
+});
+
 export const getPaymentMethodsSchema = z.object({
   language: optionalString,
   country: optionalString,

+ 2 - 0
tests/fixtures/chatwoot.ts

@@ -6,6 +6,7 @@ export const incomingEmailMessage = {
   message_type: 'incoming',
   content_type: 'incoming_email',
   content: 'Dzień dobry, czy gaz R1234yf pasuje do mojego Golfa VII z 2016 roku?',
+  created_at: Math.floor(Date.now() / 1000),
   private: false,
   conversation: {
     id: 1311,
@@ -83,6 +84,7 @@ export const noLabelsPayload = {
   id: 90217,
   message_type: 'incoming',
   content: 'Gdzie jest moja paczka?',
+  created_at: Math.floor(Date.now() / 1000),
   conversation: {
     id: 1412,
     inbox_id: 1,

+ 25 - 1
tests/integration/opsPanel.test.ts

@@ -18,6 +18,7 @@ const ADMIN_ENDPOINTS = [
   '/admin/messages',
   '/admin/tickets',
   '/admin/meta',
+  '/admin/runtime',
 ];
 
 before(async () => {
@@ -143,9 +144,10 @@ test('admin endpoints answer with a valid token', async () => {
 
 // ─────────────────────────────────────────────────────────── read-only
 
-test('the admin API exposes no write verbs', async () => {
+test('the admin API exposes no unexpected write verbs except runtime controls', async () => {
   for (const path of ADMIN_ENDPOINTS) {
     for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
+      if (path === '/admin/runtime' && method === 'PATCH') continue;
       const res = await fetch(`${running.baseUrl}${path}`, {
         method,
         headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
@@ -250,3 +252,25 @@ test('meta supplies the filter vocabulary without secrets', async () => {
   assert.ok(!serialized.includes('test-flowise-key'));
   assert.ok(!serialized.includes('ck_'));
 });
+
+test('runtime controls can disable AI and re-enable with a cutoff', async () => {
+  const off = await fetch(`${running.baseUrl}/admin/runtime`, {
+    method: 'PATCH',
+    headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ botEnabled: false, maxMessageAgeMinutes: 240 }),
+  });
+  assert.equal(off.status, 200);
+  const offBody = (await off.json()) as { runtime: { botEnabled: boolean; maxMessageAgeMinutes: number } };
+  assert.equal(offBody.runtime.botEnabled, false);
+  assert.equal(offBody.runtime.maxMessageAgeMinutes, 240);
+
+  const on = await fetch(`${running.baseUrl}/admin/runtime`, {
+    method: 'PATCH',
+    headers: { Authorization: ADMIN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ botEnabled: true, enableFromNow: true }),
+  });
+  assert.equal(on.status, 200);
+  const onBody = (await on.json()) as { runtime: { botEnabled: boolean; ignoreMessagesBefore: string | null } };
+  assert.equal(onBody.runtime.botEnabled, true);
+  assert.ok(onBody.runtime.ignoreMessagesBefore);
+});

+ 70 - 3
tests/integration/pipeline.test.ts

@@ -17,6 +17,7 @@ const { claimMessage } = await import('../../src/store/idempotencyStore.js');
 /** Records every Chatwoot side effect instead of performing it. */
 class FakeChatwoot {
   sentMessages: { conversationId: number; content: string }[] = [];
+  privateNotes: { conversationId: number; content: string }[] = [];
   labels: { conversationId: number; label: string }[] = [];
   attributes: { conversationId: number; attrs: Record<string, unknown> }[] = [];
   unassigned: number[] = [];
@@ -46,13 +47,18 @@ class FakeChatwoot {
     this.sentMessages.push({ conversationId, content });
     return {};
   }
+  async sendPrivateNote(conversationId: number, content: string): Promise<unknown> {
+    this.privateNotes.push({ conversationId, content });
+    return {};
+  }
 }
 
 class FakeFlowise {
   calls: unknown[] = [];
-  constructor(private readonly result: FlowiseResult) {}
+  constructor(private readonly result: FlowiseResult | Error) {}
   async predict(payload: unknown): Promise<FlowiseResult> {
     this.calls.push(payload);
+    if (this.result instanceof Error) throw this.result;
     return this.result;
   }
 }
@@ -66,6 +72,7 @@ function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent
     inboxId: 1,
     content: 'Czy macie gaz R134a?',
     subject: 'Pytanie',
+    messageCreatedAt: new Date().toISOString(),
     senderEmail: 'klient@example.com',
     senderName: 'Klient Testowy',
     senderId: '332',
@@ -73,6 +80,8 @@ function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent
     customAttributes: {},
     additionalAttributes: {},
     attachmentCount: 0,
+    attachments: [],
+    formMail: null,
     needsConversationFetch: false,
     ...overrides,
   };
@@ -274,8 +283,8 @@ test('the conversation is fetched when the webhook omitted labels', async () =>
   assert.equal(flowise.calls.length, 0);
 });
 
-test('the Flowise payload keeps the contract the custom tools depend on', () => {
-  const payload = buildFlowisePayload(evt());
+test('the Flowise payload keeps the contract the custom tools depend on', async () => {
+  const payload = await buildFlowisePayload(evt());
 
   assert.match(payload.question, /\[CONTACT_INFO\]/);
   assert.match(payload.question, /contact_email: klient@example\.com/);
@@ -288,3 +297,61 @@ test('the Flowise payload keeps the contract the custom tools depend on', () =>
   assert.equal(payload.metadata.source, 'chatwoot');
   assert.equal(payload.metadata.messageType, 'incoming');
 });
+
+test('a significant attachment creates ticket handoff before Flowise', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'nie powinno wyjść', actions: null });
+  const event = evt({
+    messageId: 'att-1',
+    attachmentCount: 1,
+    attachments: [{ fileName: 'faktura.pdf', contentType: 'application/pdf', fileSize: 100_000, inline: false, contentId: null }],
+  });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'handoff');
+  assert.equal(flowise.calls.length, 0);
+  assert.ok(chatwoot.labels.some((l) => l.label === 'attachment'));
+  assert.equal(chatwoot.privateNotes.length, 1);
+});
+
+test('form-mail mapping uses customer email in Flowise payload and records attributes', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'OK', actions: null });
+  const event = evt({
+    messageId: 'form-1',
+    senderEmail: 'wordpress@easyklima.com',
+    formMail: {
+      customerEmail: 'anna@example.com',
+      customerName: 'Anna',
+      phone: '+48123123123',
+      technicalSender: 'wordpress@easyklima.com',
+      formSource: 'Formularz kontaktowy',
+    },
+  });
+  event.senderEmail = event.formMail?.customerEmail ?? event.senderEmail;
+  event.senderName = event.formMail?.customerName ?? event.senderName;
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  await processMessageEvent(event, deps(chatwoot, flowise));
+
+  const payload = flowise.calls[0] as { question: string };
+  assert.match(payload.question, /contact_email: anna@example\.com/);
+  assert.ok(chatwoot.attributes.some((a) => a.attrs.form_mail === true));
+  assert.ok(chatwoot.labels.some((l) => l.label === 'www-form'));
+});
+
+test('Flowise timeout/error is marked ai-error without customer reply', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise(new Error('upstream timeout'));
+  const event = evt({ messageId: 'ai-fail-1' });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.deepEqual(outcome, { action: 'ai_error', code: 'FLOWISE_TIMEOUT' });
+  assert.equal(chatwoot.sentMessages.length, 0);
+  assert.ok(chatwoot.labels.some((l) => l.label === 'ai-error'));
+  assert.equal(chatwoot.privateNotes.length, 1);
+});

+ 1 - 0
tests/integration/tools.test.ts

@@ -14,6 +14,7 @@ const TOOL_PATHS = [
   '/tools/get_order_data',
   '/tools/get_product_data',
   '/tools/get_shipping_data',
+  '/tools/check_shipping_eligibility',
   '/tools/get_payment_methods',
   '/tools/get_product_compatibility',
   '/tools/get_car_data',

+ 3 - 0
tests/spamGate.test.ts

@@ -12,6 +12,7 @@ function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent
     inboxId: 1,
     content: 'Czy ten produkt pasuje do mojego auta?',
     subject: 'Pytanie',
+    messageCreatedAt: new Date().toISOString(),
     senderEmail: 'klient@example.com',
     senderName: 'Klient',
     senderId: '1',
@@ -19,6 +20,8 @@ function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent
     customAttributes: {},
     additionalAttributes: {},
     attachmentCount: 0,
+    attachments: [],
+    formMail: null,
     needsConversationFetch: false,
     ...overrides,
   };