Переглянути джерело

Open and unassign Chatwoot conversations on ticket handoff

Maciek 3 тижнів тому
батько
коміт
57a0e9e325

+ 2 - 1
.env.example

@@ -22,7 +22,8 @@ CHATWOOT_SPAM_LABEL=spam
 # Only enable once the `spam` label actually exists in the Chatwoot account.
 CHATWOOT_APPLY_SPAM_LABEL=false
 # Unassign the bot agent when a ticket is created.
-CHATWOOT_UNASSIGN_ON_TICKET=false
+CHATWOOT_UNASSIGN_ON_TICKET=true
+CHATWOOT_OPEN_ON_TICKET=true
 # Webchat / API inbox — reserved for the later web widget adapter.
 CHATWOOT_API_INBOX_ID=
 CHATWOOT_API_IDENTITY_VALIDATION_TOKEN=

+ 1 - 1
docs/MIGRATION.md

@@ -22,7 +22,7 @@ so a rollback is a webhook-URL change in Chatwoot, nothing more.
 | Spam | not implemented | rule-based gate in front of Flowise (bounces, autoreplies, newsletters, empty/attachment-only mail, automated senders, `Auto-Submitted`/`List-Unsubscribe` headers) |
 | Ticket number | the bare conversation id | `EKS-YYYYMMDD-<conversationId>` |
 | Ticket idempotency | re-read Chatwoot attributes only | local `Ticket` table keyed by `conversationId` **and** adoption of an existing Chatwoot `ticket_number` |
-| Unassign on handoff | `unassignConversation()` existed but was never called | called when `CHATWOOT_UNASSIGN_ON_TICKET=true` |
+| Unassign/open on handoff | `unassignConversation()` existed but was never called; status was not normalized | `new_ticket` now best-effort unassigns and sets the conversation status to `open` when `CHATWOOT_UNASSIGN_ON_TICKET=true` and `CHATWOOT_OPEN_ON_TICKET=true` |
 | Ticket-mode check | label or `handoff` | label, `handoff`, **or** a non-zero `ticket_number` |
 | Health | none | `/health` and `/ready` with per-dependency status |
 | Logging | wrote request bodies to a file, including PII | structured JSON, secrets always redacted, PII behind `LOG_PII` |

+ 12 - 0
src/clients/chatwootClient.ts

@@ -94,6 +94,18 @@ export class ChatwootClient {
     }
   }
 
+  /** Best-effort status update; a failure here must not abort ticket creation. */
+  async setConversationStatus(conversationId: number, status: 'open' | 'resolved' | 'pending'): Promise<boolean> {
+    try {
+      await this.api('POST', `/conversations/${conversationId}/toggle_status`, { status });
+      logger.info('Chatwoot conversation status updated', { conversationId, status });
+      return true;
+    } catch {
+      logger.warn('Chatwoot status update failed (non-fatal)', { conversationId, status });
+      return false;
+    }
+  }
+
   async sendOutgoingMessage(conversationId: number, content: string): Promise<unknown> {
     return this.api('POST', `/conversations/${conversationId}/messages`, {
       content,

+ 2 - 1
src/config.ts

@@ -36,7 +36,8 @@ const schema = z.object({
   CHATWOOT_TICKET_LABEL: z.string().min(1).default('ticket'),
   CHATWOOT_SPAM_LABEL: z.string().default('spam'),
   CHATWOOT_APPLY_SPAM_LABEL: boolish('false'),
-  CHATWOOT_UNASSIGN_ON_TICKET: boolish('false'),
+  CHATWOOT_UNASSIGN_ON_TICKET: boolish('true'),
+  CHATWOOT_OPEN_ON_TICKET: boolish('true'),
   /// 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(''),

+ 18 - 1
src/domain/ticketService.ts

@@ -106,6 +106,7 @@ export async function createTicket(
   await chatwoot.addLabel(conversationId, config().CHATWOOT_TICKET_LABEL);
 
   const unassigned = await applyUnassign(chatwoot, conversationId);
+  const opened = await applyOpenStatus(chatwoot, conversationId);
 
   await audit({
     conversationId,
@@ -113,7 +114,13 @@ export async function createTicket(
     summary: `Ticket ${ticketNumber} created`,
     // `unassigned` is the real outcome of the API call, not the config flag —
     // an audit trail that reports intent instead of effect is worthless.
-    meta: { reason: reason ?? null, unassignRequested: config().CHATWOOT_UNASSIGN_ON_TICKET, unassigned },
+    meta: {
+      reason: reason ?? null,
+      unassignRequested: config().CHATWOOT_UNASSIGN_ON_TICKET,
+      unassigned,
+      openRequested: config().CHATWOOT_OPEN_ON_TICKET,
+      opened,
+    },
   });
 
   logger.info('Ticket created', { conversationId, ticketNumber });
@@ -152,6 +159,15 @@ async function applyUnassign(
   return chatwoot.unassignConversation(conversationId);
 }
 
+/** Open the conversation when configured to; returns whether Chatwoot accepted it. */
+async function applyOpenStatus(
+  chatwoot: ChatwootClient,
+  conversationId: number,
+): Promise<boolean | null> {
+  if (!config().CHATWOOT_OPEN_ON_TICKET) return null;
+  return chatwoot.setConversationStatus(conversationId, 'open');
+}
+
 /**
  * Re-apply label/attributes when a locally known ticket lost them in Chatwoot.
  * Also re-applies the unassign, so an adopted or repeated ticket ends in the
@@ -172,6 +188,7 @@ async function ensureChatwootTicketState(
     }
     await chatwoot.addLabel(conversationId, config().CHATWOOT_TICKET_LABEL);
     await applyUnassign(chatwoot, conversationId);
+    await applyOpenStatus(chatwoot, conversationId);
   } catch {
     logger.warn('Could not re-assert ticket state in Chatwoot', { conversationId });
   }

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

@@ -20,6 +20,7 @@ class FakeChatwoot {
   labels: { conversationId: number; label: string }[] = [];
   attributes: { conversationId: number; attrs: Record<string, unknown> }[] = [];
   unassigned: number[] = [];
+  statusUpdates: { conversationId: number; status: string }[] = [];
   conversation: Record<string, unknown> = { id: 1311, labels: [], custom_attributes: {} };
 
   async getConversation(): Promise<Record<string, unknown>> {
@@ -37,6 +38,10 @@ class FakeChatwoot {
     this.unassigned.push(conversationId);
     return true;
   }
+  async setConversationStatus(conversationId: number, status: 'open' | 'resolved' | 'pending'): Promise<boolean> {
+    this.statusUpdates.push({ conversationId, status });
+    return true;
+  }
   async sendOutgoingMessage(conversationId: number, content: string): Promise<unknown> {
     this.sentMessages.push({ conversationId, content });
     return {};
@@ -162,6 +167,8 @@ test('a handoff action creates a ticket, labels it and replies with the number',
   assert.match((outcome as { ticketNumber: string }).ticketNumber, /^EKS-\d{8}-1311$/);
   assert.ok(chatwoot.labels.some((l) => l.label === 'ticket'));
   assert.ok(chatwoot.attributes.some((a) => 'ticket_number' in a.attrs));
+  assert.deepEqual(chatwoot.unassigned, [1311]);
+  assert.deepEqual(chatwoot.statusUpdates, [{ conversationId: 1311, status: 'open' }]);
   assert.equal(chatwoot.sentMessages.length, 1);
   assert.match(chatwoot.sentMessages[0]?.content ?? '', /EKS-\d{8}-1311/);
 });

+ 44 - 0
tests/integration/ticketLogic.test.ts

@@ -14,7 +14,9 @@ class FakeChatwoot {
   labels: string[] = [];
   attributeWrites: Record<string, unknown>[] = [];
   unassignCalls = 0;
+  statusCalls: string[] = [];
   unassignSucceeds = true;
+  statusSucceeds = true;
   getCalls = 0;
   conversation: Record<string, unknown> = { id: 500, labels: [], custom_attributes: {} };
 
@@ -34,6 +36,10 @@ class FakeChatwoot {
     this.unassignCalls++;
     return this.unassignSucceeds;
   }
+  async setConversationStatus(_id: number, status: 'open' | 'resolved' | 'pending'): Promise<boolean> {
+    this.statusCalls.push(status);
+    return this.statusSucceeds;
+  }
   async sendOutgoingMessage(): Promise<unknown> {
     return {};
   }
@@ -126,6 +132,43 @@ test('unassign is skipped when the flag is off', async () => {
   const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
   assert.equal(meta.unassignRequested, false);
   assert.equal(meta.unassigned, null);
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('ticket creation opens the conversation by default and audits the real outcome', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl });
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'handoff', as(cw));
+  assert.deepEqual(cw.statusCalls, ['open']);
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.openRequested, true);
+  assert.equal(meta.opened, true);
+});
+
+test('open status is skipped when the flag is off', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl, CHATWOOT_OPEN_ON_TICKET: 'false' });
+  const cw = new FakeChatwoot();
+  await createTicket(500, 'handoff', as(cw));
+  assert.deepEqual(cw.statusCalls, []);
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.openRequested, false);
+  assert.equal(meta.opened, null);
+  applyTestConfig({ DATABASE_URL: dbUrl });
+});
+
+test('a failed open status update is audited as failed, not success', async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl });
+  const cw = new FakeChatwoot();
+  cw.statusSucceeds = false;
+  await createTicket(500, 'handoff', as(cw));
+
+  const event = await db().auditEvent.findFirst({ where: { eventType: 'ticket_created' } });
+  const meta = JSON.parse(event?.metaJson ?? '{}') as Record<string, unknown>;
+  assert.equal(meta.opened, false, 'the audit must record effect, not intent');
 });
 
 test('unassign runs when the flag is on and the real outcome is audited', async () => {
@@ -161,6 +204,7 @@ test('an adopted ticket is also unassigned when the flag is on', async () => {
   await createTicket(500, 'handoff', as(cw));
 
   assert.equal(cw.unassignCalls, 1, 'adoption must reach the same Chatwoot state as creation');
+  assert.deepEqual(cw.statusCalls, ['open'], 'adoption must leave the conversation open');
   assert.ok(cw.labels.includes('ticket'));
   applyTestConfig({ DATABASE_URL: dbUrl });
 });