Ver Fonte

Tighten Chatwoot bot assignment and translation UI

Maciek há 3 semanas atrás
pai
commit
63283506ae

+ 5 - 0
.env.example

@@ -24,6 +24,11 @@ CHATWOOT_APPLY_SPAM_LABEL=false
 # Unassign the bot agent when a ticket is created.
 CHATWOOT_UNASSIGN_ON_TICKET=true
 CHATWOOT_OPEN_ON_TICKET=true
+# When true, EKSRelay only answers if Chatwoot reports the conversation as
+# assigned to a real AgentBot (assignee_agent_bot_id/meta.assignee_type), not the
+# legacy technical user account used for API calls.
+CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT=false
+CHATWOOT_REQUIRED_AGENT_BOT_ID=
 # Webchat / API inbox — reserved for the later web widget adapter.
 CHATWOOT_API_INBOX_ID=
 CHATWOOT_API_IDENTITY_VALIDATION_TOKEN=

+ 72 - 5
public/chatwoot-ai-tools.js

@@ -57,9 +57,15 @@
       #${PANEL_ID} .eks-error { color: #b91c1c; white-space: pre-wrap; }
       #${PANEL_ID} .eks-result { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; white-space: pre-wrap; min-height: 44px; }
       .eks-inline-translate {
-        margin-left: 6px !important; border: 0 !important; border-radius: 999px !important;
-        padding: 3px 7px !important; background: #e0f2fe !important; color: #075985 !important;
-        font: 600 11px system-ui, sans-serif !important; cursor: pointer !important;
+        margin-left: 4px !important; border: 0 !important; border-radius: 999px !important;
+        padding: 1px 5px !important; background: #e0f2fe !important; color: #075985 !important;
+        font: 600 9px system-ui, sans-serif !important; line-height: 14px !important; cursor: pointer !important;
+        opacity: .82 !important; vertical-align: middle !important;
+      }
+      .eks-composer-translate {
+        border: 0 !important; border-radius: 999px !important; padding: 5px 9px !important;
+        background: #1f6feb !important; color: white !important; font: 600 12px system-ui, sans-serif !important;
+        cursor: pointer !important; margin: 4px !important; box-shadow: 0 2px 8px rgba(15,23,42,.16) !important;
       }
     `;
     document.head.appendChild(style);
@@ -150,6 +156,44 @@
     return candidate ? (candidate.value || candidate.innerText || candidate.textContent || '') : '';
   }
 
+  function getDraftElement() {
+    const active = document.activeElement;
+    if (active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT' || active.isContentEditable)) return active;
+    return document.querySelector('[contenteditable="true"][role="textbox"], textarea');
+  }
+
+  function setDraftText(el, text) {
+    if (!el) return false;
+    if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
+      el.value = text;
+      el.dispatchEvent(new Event('input', { bubbles: true }));
+      el.dispatchEvent(new Event('change', { bubbles: true }));
+      el.focus();
+      return true;
+    }
+    if (el.isContentEditable) {
+      el.focus();
+      el.textContent = text;
+      el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
+      return true;
+    }
+    return false;
+  }
+
+  async function translateAndReplaceDraft() {
+    const el = getDraftElement();
+    const text = (el ? (el.value || el.innerText || el.textContent || '') : '').trim();
+    const panel = document.getElementById(PANEL_ID);
+    panel.hidden = false;
+    panelField('source').value = 'pl';
+    panelField('text').value = text;
+    if (!panelField('target').value.trim() || panelField('target').value.trim() === 'pl') {
+      panelField('target').value = localStorage.getItem(LANG_KEY) || 'auto';
+    }
+    const translated = await translate('draft');
+    if (translated) setDraftText(el, translated);
+  }
+
   async function translate(mode) {
     const token = panelField('token').value.trim();
     const targetLanguage = panelField('target').value.trim() || 'pl';
@@ -181,6 +225,7 @@
       if (!res.ok || !json?.ok) throw new Error(json?.message || `HTTP ${res.status}`);
       state.result = json.translatedText || '';
       panelField('result').textContent = state.result + (json.cached ? '\n\n[cached]' : '');
+      return state.result;
     } catch (err) {
       showError(err instanceof Error ? err.message : String(err));
     } finally {
@@ -223,7 +268,8 @@
       const btn = document.createElement('button');
       btn.type = 'button';
       btn.className = 'eks-inline-translate';
-      btn.textContent = 'AI→PL';
+      btn.textContent = 'AI';
+      btn.title = 'Przetłumacz tę wiadomość na polski';
       btn.addEventListener('click', (e) => {
         e.preventDefault(); e.stopPropagation();
         const panel = document.getElementById(PANEL_ID);
@@ -235,11 +281,32 @@
     });
   }
 
+  function addComposerButtons() {
+    const editors = document.querySelectorAll('[contenteditable="true"][role="textbox"], textarea');
+    editors.forEach((el) => {
+      if (el.dataset?.eksComposerBound === '1') return;
+      if (el.closest(`#${PANEL_ID}`)) return;
+      if (el.dataset) el.dataset.eksComposerBound = '1';
+      const btn = document.createElement('button');
+      btn.type = 'button';
+      btn.className = 'eks-composer-translate';
+      btn.textContent = 'AI: PL → język klienta';
+      btn.title = 'Przetłumacz wpisaną odpowiedź po polsku na język klienta i wstaw do edytora';
+      btn.addEventListener('click', (e) => {
+        e.preventDefault(); e.stopPropagation();
+        void translateAndReplaceDraft();
+      });
+      const host = el.parentElement;
+      if (host) host.appendChild(btn);
+    });
+  }
+
   function boot() {
     if (!document.body) return setTimeout(boot, 200);
     makeUi();
     addInlineButtons();
-    const obs = new MutationObserver(() => addInlineButtons());
+    addComposerButtons();
+    const obs = new MutationObserver(() => { addInlineButtons(); addComposerButtons(); });
     obs.observe(document.body, { childList: true, subtree: true });
   }
 

+ 6 - 1
src/clients/chatwootClient.ts

@@ -10,7 +10,12 @@ export interface ChatwootConversation {
   status?: string;
   custom_attributes?: Record<string, unknown>;
   additional_attributes?: Record<string, unknown>;
-  meta?: { sender?: { id?: number; name?: string; email?: string } };
+  assignee_agent_bot_id?: number | null;
+  meta?: {
+    sender?: { id?: number; name?: string; email?: string };
+    assignee?: { id?: number; name?: string; email?: string };
+    assignee_type?: string | null;
+  };
   contact_inbox?: { source_id?: string };
 }
 

+ 5 - 0
src/config.ts

@@ -38,6 +38,11 @@ const schema = z.object({
   CHATWOOT_APPLY_SPAM_LABEL: boolish('false'),
   CHATWOOT_UNASSIGN_ON_TICKET: boolish('true'),
   CHATWOOT_OPEN_ON_TICKET: boolish('true'),
+  CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT: boolish('false'),
+  CHATWOOT_REQUIRED_AGENT_BOT_ID: z.preprocess(
+    (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v),
+    z.coerce.number().int().positive().optional(),
+  ),
   /// Webchat / API inbox — reserved for the later web widget adapter.
   CHATWOOT_API_INBOX_ID: z.string().default(''),
   CHATWOOT_API_IDENTITY_VALIDATION_TOKEN: z.string().default(''),

+ 38 - 2
src/domain/conversationPipeline.ts

@@ -36,19 +36,40 @@ export async function processMessageEvent(
   let labels = event.labels;
   let customAttributes = event.customAttributes;
   let additionalAttributes = event.additionalAttributes;
+  let conv: Awaited<ReturnType<ChatwootClient['getConversation']>> | null = null;
 
   // The webhook payload often omits labels/custom attributes; without them the
   // ticket-mode check would wrongly let the AI answer a handed-off conversation.
-  if (event.needsConversationFetch) {
+  // When configured, also fetch the full conversation to verify that Chatwoot has
+  // actually assigned this conversation to the AgentBot, not merely to the old
+  // technical user account.
+  if (event.needsConversationFetch || cfg.CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT) {
     logger.info('Fetching full conversation from Chatwoot', {
       conversationId: event.conversationId,
     });
-    const conv = await chatwoot.getConversation(event.conversationId);
+    conv = await chatwoot.getConversation(event.conversationId);
     labels = conv.labels ?? [];
     customAttributes = conv.custom_attributes ?? {};
     additionalAttributes = { ...additionalAttributes, ...(conv.additional_attributes ?? {}) };
   }
 
+  if (cfg.CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT && conv && !isAssignedToRequiredAgentBot(conv)) {
+    await setMessageStatus(event.source, event.messageId, 'skipped', 'not_assigned_to_bot');
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'skipped_not_assigned_to_bot',
+      summary: 'Conversation is not assigned to the configured Chatwoot AgentBot — Flowise not called',
+      meta: {
+        requiredAgentBotId: cfg.CHATWOOT_REQUIRED_AGENT_BOT_ID ?? null,
+        assigneeType: conv.meta?.assignee_type ?? null,
+        assigneeAgentBotId: conv.assignee_agent_bot_id ?? null,
+        assigneeId: conv.meta?.assignee?.id ?? null,
+      },
+    });
+    return { action: 'skipped', reason: 'not_assigned_to_bot' };
+  }
+
   if (isTicketMode({ labels, custom_attributes: customAttributes })) {
     await setMessageStatus(event.source, event.messageId, 'skipped', 'ticket_mode');
     await audit({
@@ -147,6 +168,21 @@ export async function processMessageEvent(
   return { action: 'no_reply', reason: 'empty_flowise_response' };
 }
 
+function isAssignedToRequiredAgentBot(
+  conv: Awaited<ReturnType<ChatwootClient['getConversation']>>,
+): boolean {
+  const required = config().CHATWOOT_REQUIRED_AGENT_BOT_ID;
+  const topLevelBotId = conv.assignee_agent_bot_id;
+  const metaAssigneeType = String(conv.meta?.assignee_type ?? '').toLowerCase();
+  const metaAssigneeId = conv.meta?.assignee?.id;
+
+  if (required !== undefined) {
+    return topLevelBotId === required || (metaAssigneeType === 'agentbot' && metaAssigneeId === required);
+  }
+
+  return typeof topLevelBotId === 'number' || metaAssigneeType === 'agentbot';
+}
+
 /**
  * Flowise payload. `[CONTACT_INFO]` is a contract with the deployed custom
  * tools: `get_order_data` parses `contact_email:` out of `$flow.input`.

+ 52 - 0
tests/integration/pipeline.test.ts

@@ -151,6 +151,58 @@ test('spam is blocked before Flowise and recorded', async () => {
   assert.equal(row?.status, 'spam');
 });
 
+test('configured AgentBot guard skips conversations assigned to the legacy user account', async () => {
+  applyTestConfig({
+    DATABASE_URL: dbUrl,
+    CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT: 'true',
+    CHATWOOT_REQUIRED_AGENT_BOT_ID: '1',
+  });
+  const chatwoot = new FakeChatwoot();
+  chatwoot.conversation = {
+    id: 1311,
+    labels: [],
+    custom_attributes: {},
+    assignee_agent_bot_id: null,
+    meta: { assignee_type: 'User', assignee: { id: 2, name: 'KlimBot' } },
+  };
+  const flowise = new FakeFlowise({ type: 'reply', text: 'nie powinno wyjść', actions: null });
+  const event = evt();
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.deepEqual(outcome, { action: 'skipped', reason: 'not_assigned_to_bot' });
+  assert.equal(flowise.calls.length, 0);
+  assert.equal(chatwoot.sentMessages.length, 0);
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('configured AgentBot guard allows conversations assigned to the Chatwoot AgentBot', async () => {
+  applyTestConfig({
+    DATABASE_URL: dbUrl,
+    CHATWOOT_REQUIRE_AGENT_BOT_ASSIGNMENT: 'true',
+    CHATWOOT_REQUIRED_AGENT_BOT_ID: '1',
+  });
+  const chatwoot = new FakeChatwoot();
+  chatwoot.conversation = {
+    id: 1311,
+    labels: [],
+    custom_attributes: {},
+    assignee_agent_bot_id: 1,
+    meta: { assignee_type: 'AgentBot', assignee: { id: 1, name: 'Agent' } },
+  };
+  const flowise = new FakeFlowise({ type: 'reply', text: 'OK od bota', actions: null });
+  const event = evt();
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'reply');
+  assert.equal(flowise.calls.length, 1);
+  assert.equal(chatwoot.sentMessages[0]?.content, 'OK od bota');
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
 test('a handoff action creates a ticket, labels it and replies with the number', async () => {
   const chatwoot = new FakeChatwoot();
   const flowise = new FakeFlowise({