chatwoot-router 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,7 @@ function errorFields(error) {
67
67
 
68
68
  // src/router.ts
69
69
  import { DurableObject } from "cloudflare:workers";
70
- import { z as z7 } from "zod";
70
+ import { z as z6 } from "zod";
71
71
 
72
72
  // ../../shared/budget.ts
73
73
  var BudgetExhaustedError = class extends Error {
@@ -148,6 +148,7 @@ var messageSchema = z2.object({
148
148
  email: emailSchema.nullish(),
149
149
  deleted: z2.boolean().nullish(),
150
150
  external_error: text,
151
+ activity: z2.object({ type: text, status: text }).nullish(),
151
152
  submitted_values: z2.unknown().optional(),
152
153
  submitted_email: z2.unknown().optional(),
153
154
  items: z2.unknown().optional()
@@ -157,7 +158,10 @@ var messageSchema = z2.object({
157
158
  attachments: z2.array(attachmentSchema).nullish()
158
159
  });
159
160
  var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
160
- var MESSAGE_PAGE_SIZE = 100;
161
+ var MESSAGE_HISTORY_PAGE_SIZE = 20;
162
+ var inboxBotSchema = z2.object({
163
+ agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
164
+ });
161
165
  function chatwootClient(baseUrl, token, fetch2) {
162
166
  const client = createClient({
163
167
  baseUrl: baseUrl.replace(/\/+$/, ""),
@@ -174,7 +178,7 @@ function chatwootClient(baseUrl, token, fetch2) {
174
178
  async function dataUnlessNotFound(operation, pending) {
175
179
  const { data: data2, response } = await pending;
176
180
  if (notFound(response)) {
177
- log.info("conversation deleted", { operation });
181
+ log.info("Chatwoot resource deleted", { operation });
178
182
  return void 0;
179
183
  }
180
184
  if (!response.ok || data2 === void 0) throw new ChatwootError(response.status, operation);
@@ -227,6 +231,30 @@ function chatwootClient(baseUrl, token, fetch2) {
227
231
  const messages = await listMessages(accountId2, conversationId, { after: messageId - 1, before: messageId + 1 });
228
232
  return messages?.find((message) => message.id === messageId);
229
233
  },
234
+ async listInboxes(accountId2) {
235
+ const list = await data(
236
+ "list inboxes",
237
+ client.GET("/api/v1/accounts/{account_id}/inboxes", {
238
+ params: { path: { account_id: accountId2 } }
239
+ })
240
+ );
241
+ return (list.payload ?? []).flatMap((inbox) => inbox.id === void 0 ? [] : [inbox.id]);
242
+ },
243
+ async inboxBot(accountId2, inboxId) {
244
+ const value = await dataUnlessNotFound(
245
+ "get inbox agent bot",
246
+ client.GET("/api/v1/accounts/{account_id}/inboxes/{id}/agent_bot", {
247
+ params: { path: { account_id: accountId2, id: inboxId } }
248
+ })
249
+ );
250
+ if (value === void 0) return void 0;
251
+ const parsed = inboxBotSchema.safeParse(value);
252
+ if (!parsed.success) throw new Error("Chatwoot returned an invalid inbox bot");
253
+ const bot = parsed.data.agent_bot;
254
+ if (bot?.id === void 0) return void 0;
255
+ if (bot.account_id !== accountId2) throw new Error("Chatwoot inbox bot does not belong to the account");
256
+ return { id: bot.id };
257
+ },
230
258
  async inboxName(accountId2, inboxId) {
231
259
  const inbox = await data(
232
260
  "get inbox",
@@ -295,12 +323,13 @@ function chatwootClient(baseUrl, token, fetch2) {
295
323
  false
296
324
  );
297
325
  },
298
- assign(accountId2, conversationId, assigneeId) {
326
+ assign(accountId2, conversationId, assigneeId, assigneeType = "User") {
327
+ const body = { assignee_id: assigneeId, assignee_type: assigneeType };
299
328
  return ensureOk(
300
329
  "assign conversation",
301
330
  client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments", {
302
331
  params: { path: { account_id: accountId2, conversation_id: conversationId } },
303
- body: { assignee_id: assigneeId }
332
+ body
304
333
  })
305
334
  );
306
335
  },
@@ -382,9 +411,13 @@ function chatwootClient(baseUrl, token, fetch2) {
382
411
  function notFound(response) {
383
412
  return response.status === 404 && (response.headers.get("content-type")?.includes("application/json") ?? false);
384
413
  }
414
+ function personAssignee(conversation) {
415
+ const type = conversation.meta?.assignee_type;
416
+ return type == null || type === "User" ? conversation.meta?.assignee : void 0;
417
+ }
385
418
  function toRelayConversation(conversationId, conversation) {
386
419
  const meta = conversation.meta;
387
- const assignee = meta?.assignee;
420
+ const assignee = personAssignee(conversation);
388
421
  return {
389
422
  id: conversationId,
390
423
  status: conversation.status,
@@ -399,6 +432,7 @@ function toRelayConversation(conversationId, conversation) {
399
432
  avatarUrl: meta?.sender?.thumbnail ?? null
400
433
  },
401
434
  assignee: assignee ? { id: assignee.id, name: assignee.name } : null,
435
+ assigneeType: meta?.assignee_type ?? null,
402
436
  customAttributes: conversation.custom_attributes ?? {}
403
437
  };
404
438
  }
@@ -436,6 +470,10 @@ var QueueStore = class {
436
470
  );
437
471
  CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
438
472
  CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);`);
473
+ const columns = this.sql.exec("PRAGMA table_info(jobs)").toArray();
474
+ if (!columns.some((column) => column.name === "suspended")) {
475
+ this.sql.exec("ALTER TABLE jobs ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0");
476
+ }
439
477
  }
440
478
  // Cache
441
479
  get(key) {
@@ -454,14 +492,6 @@ var QueueStore = class {
454
492
  delete(key) {
455
493
  this.sql.exec("DELETE FROM cache WHERE key = ?", key);
456
494
  }
457
- list(start, end) {
458
- return this.sql.exec(
459
- "SELECT value FROM cache WHERE key >= ? AND key < ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key",
460
- start,
461
- end,
462
- this.now()
463
- ).toArray().map((row) => row.value);
464
- }
465
495
  // Jobs
466
496
  /**
467
497
  * Adds a job, or marks an existing one as needing another run (its version changes). A job
@@ -470,7 +500,7 @@ var QueueStore = class {
470
500
  enqueue(key, priority, payload, notBefore = this.now()) {
471
501
  this.sql.exec(
472
502
  `INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
473
- ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
503
+ ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload, suspended = 0,
474
504
  not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
475
505
  key,
476
506
  priority,
@@ -481,7 +511,7 @@ var QueueStore = class {
481
511
  }
482
512
  nextDueJob() {
483
513
  const row = this.sql.exec(
484
- `SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
514
+ `SELECT key, payload, version, attempts, created_at FROM jobs WHERE suspended != 2 AND not_before <= ?
485
515
  ORDER BY priority, not_before, created_at LIMIT 1`,
486
516
  this.now()
487
517
  ).toArray()[0];
@@ -491,7 +521,7 @@ var QueueStore = class {
491
521
  }
492
522
  /** Earliest due time. */
493
523
  nextWakeup() {
494
- const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
524
+ const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs WHERE suspended != 2").toArray()[0];
495
525
  return row?.at ?? void 0;
496
526
  }
497
527
  /** Removes a finished job unless it was enqueued again while running. */
@@ -516,73 +546,136 @@ var QueueStore = class {
516
546
  deferJob(job, delayMs = 0) {
517
547
  this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
518
548
  }
549
+ /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
550
+ holdJob(job) {
551
+ this.sql.exec(
552
+ `UPDATE jobs SET suspended = CASE WHEN suspended = 0 THEN 1 ELSE 2 END,
553
+ not_before = ?, attempts = 0 WHERE key = ? AND version = ?`,
554
+ this.now() + 1e3,
555
+ job.key,
556
+ job.version
557
+ );
558
+ }
559
+ /** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
560
+ wakeHeldJobs() {
561
+ this.sql.exec("UPDATE jobs SET suspended = 0, not_before = ? WHERE suspended = 2", this.now());
562
+ }
519
563
  prune() {
520
564
  this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
521
565
  }
522
566
  };
523
567
 
524
- // ../../shared/sweep.ts
568
+ // src/routing.ts
569
+ import ipRegex from "ip-regex";
570
+ import { z as z4 } from "zod";
571
+
572
+ // src/turn.ts
525
573
  import { z as z3 } from "zod";
526
- var passSchema = z3.object({ cutoff: z3.number(), page: z3.number().int().positive(), startedAt: z3.number() });
527
- var PASS_TTL_MS = 24 * 60 * 60 * 1e3;
528
- function readSweepPass(store, accountId2, reconcile) {
529
- const parsed = passSchema.safeParse(parseJson(store.get(`sweep:${accountId2}:pass`)));
530
- if (parsed.success) return parsed.data;
531
- const now = Date.now();
532
- const last = Number(store.get(`sweep:${accountId2}:last`) ?? 0);
533
- const { lookbackSeconds, maxCatchUpSeconds } = reconcile;
534
- const sinceLast = last > 0 ? (now - last) / 1e3 + 60 : lookbackSeconds;
535
- const window = Math.min(Math.max(sinceLast, lookbackSeconds), maxCatchUpSeconds);
536
- return { cutoff: now / 1e3 - window, page: 1, startedAt: now };
574
+ var guardSchema = z3.object({
575
+ boundary: z3.number().optional(),
576
+ claimed: z3.boolean().optional(),
577
+ pending: z3.boolean().optional(),
578
+ transitionAt: z3.number().optional(),
579
+ expected: z3.object({ status: z3.string(), at: z3.number().optional(), after: z3.number().optional() }).optional(),
580
+ handoff: z3.boolean().default(false),
581
+ failures: z3.number().int().min(0).default(0)
582
+ });
583
+ function readGuard(store, accountId2, conversationId) {
584
+ const parsed = guardSchema.safeParse(parseJson(store.get(`turn:${accountId2}:${conversationId}`)));
585
+ return parsed.success ? parsed.data : { handoff: false, failures: 0 };
537
586
  }
538
- function saveSweepPass(store, accountId2, pass, nextPage) {
539
- const key = `sweep:${accountId2}:pass`;
540
- if (nextPage === void 0) {
541
- store.set(`sweep:${accountId2}:last`, String(pass.startedAt));
542
- store.delete(key);
543
- } else {
544
- store.set(key, JSON.stringify({ ...pass, page: nextPage }), PASS_TTL_MS);
587
+ function saveGuard(store, accountId2, conversationId, guard) {
588
+ store.set(`turn:${accountId2}:${conversationId}`, JSON.stringify(guard));
589
+ }
590
+ function expectActivity(store, accountId2, conversationId, transition) {
591
+ const guard = readGuard(store, accountId2, conversationId);
592
+ if (transition.at !== void 0 && transition.at <= (guard.transitionAt ?? 0)) return;
593
+ if (transition.at === void 0 && guard.expected?.status === transition.status) return;
594
+ const claimed = guard.claimed ?? guard.transitionAt !== void 0;
595
+ const after = transition.at === void 0 || claimed ? guard.boundary ?? 0 : 0;
596
+ saveGuard(store, accountId2, conversationId, {
597
+ ...guard,
598
+ // A first webhook must not relax a newer activity requirement already observed locally.
599
+ expected: { ...transition, after: Math.max(guard.expected?.after ?? 0, after) },
600
+ transitionAt: transition.at ?? guard.transitionAt,
601
+ handoff: false,
602
+ failures: 0
603
+ });
604
+ }
605
+ function observeStatus(store, accountId2, conversationId, status) {
606
+ const guard = readGuard(store, accountId2, conversationId);
607
+ const pending = status === "pending";
608
+ if (guard.pending && !pending) expectActivity(store, accountId2, conversationId, { status });
609
+ if (guard.pending !== pending) {
610
+ saveGuard(store, accountId2, conversationId, { ...readGuard(store, accountId2, conversationId), pending });
545
611
  }
546
612
  }
547
-
548
- // src/routing.ts
549
- import ipRegex from "ip-regex";
550
- import { z as z5 } from "zod";
551
-
552
- // ../../shared/attributes.ts
553
- var ROUTING_ATTRIBUTES = {
554
- seen: "routing_seen",
555
- handled: "routing_handled",
556
- kind: "routing_kind"
557
- };
558
- function messageWatermark(value) {
559
- const number = typeof value === "string" ? Number(value) : value;
560
- return typeof number === "number" && Number.isSafeInteger(number) && number >= 0 ? number : 0;
613
+ function requestHandoff(store, accountId2, conversationId) {
614
+ saveGuard(store, accountId2, conversationId, { ...readGuard(store, accountId2, conversationId), handoff: true });
561
615
  }
562
-
563
- // src/effects.ts
564
- import { z as z4 } from "zod";
565
- var effectSchema = z4.object({
566
- inputId: z4.number().int().min(0),
567
- kind: z4.string().nullable(),
568
- handled: z4.number().int().min(0)
569
- });
570
- async function actOnce(ctx, key, effect, record, act) {
571
- if (ctx.store.get(key) !== void 0) return;
572
- if (record === "before") ctx.store.set(key, JSON.stringify({ ...effect, handled: 0 }));
573
- await act();
574
- ctx.store.set(key, JSON.stringify(effect));
616
+ function recordFailure(store, accountId2, conversationId) {
617
+ const guard = readGuard(store, accountId2, conversationId);
618
+ guard.failures += 1;
619
+ if (guard.failures >= 3) guard.handoff = true;
620
+ saveGuard(store, accountId2, conversationId, guard);
621
+ return guard.failures;
575
622
  }
576
- function readEffects(store, accountId2, conversationId) {
577
- return [
578
- store.get(`reply:${accountId2}:${conversationId}`),
579
- ...["assign", "labels", "status"].flatMap(
580
- (effect) => store.list(`${effect}:${accountId2}:${conversationId}:`, `${effect}:${accountId2}:${conversationId};`)
581
- )
582
- ].flatMap((value) => {
583
- const parsed = effectSchema.safeParse(parseJson(value));
584
- return parsed.success ? [parsed.data] : [];
585
- }).sort((left, right) => left.inputId - right.inputId);
623
+ function clearFailures(store, accountId2, conversationId) {
624
+ const guard = readGuard(store, accountId2, conversationId);
625
+ if (guard.failures) saveGuard(store, accountId2, conversationId, { ...guard, failures: 0 });
626
+ }
627
+ var ActivityPendingError = class extends Error {
628
+ constructor() {
629
+ super("Chatwoot status activity is not available yet");
630
+ this.name = "ActivityPendingError";
631
+ }
632
+ };
633
+ async function readTurn(chatwoot, store, accountId2, conversationId) {
634
+ const messages = [];
635
+ let before;
636
+ let boundary;
637
+ let complete = false;
638
+ let deleted = false;
639
+ for (let page = 0; page < 5; page += 1) {
640
+ const batch = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
641
+ for (const message of batch.toReversed()) {
642
+ if (message.message_type === 2 && message.content_attributes?.deleted) {
643
+ deleted = true;
644
+ break;
645
+ }
646
+ if (message.message_type === 2 && message.content_attributes?.activity?.type === "conversation_status_changed") {
647
+ boundary = message;
648
+ break;
649
+ }
650
+ messages.push(message);
651
+ }
652
+ complete = boundary !== void 0 || batch.length < MESSAGE_HISTORY_PAGE_SIZE;
653
+ if (complete || deleted) break;
654
+ const next = batch[0]?.id;
655
+ if (next === void 0 || before !== void 0 && next >= before) break;
656
+ before = next;
657
+ }
658
+ const guard = readGuard(store, accountId2, conversationId);
659
+ const missing = guard.boundary !== void 0 && (boundary?.id ?? 0) < guard.boundary;
660
+ const expected = guard.expected;
661
+ const activity = boundary?.content_attributes?.activity;
662
+ const late = expected && (!boundary || boundary.id <= (expected.after ?? 0) || expected.at !== void 0 && (boundary.created_at ?? 0) < Math.floor(expected.at) || activity?.status !== expected.status);
663
+ if (late && !guard.handoff && !deleted && !missing) throw new ActivityPendingError();
664
+ if (boundary && boundary.id !== guard.boundary && guard.boundary !== void 0 && !missing && !late) {
665
+ guard.handoff = false;
666
+ guard.failures = 0;
667
+ }
668
+ if (!missing && complete) {
669
+ if (guard.boundary !== (boundary?.id ?? 0)) guard.claimed = false;
670
+ guard.boundary = boundary?.id ?? 0;
671
+ }
672
+ if (expected && !late) {
673
+ guard.claimed = expected.at !== void 0;
674
+ delete guard.expected;
675
+ }
676
+ if (deleted || missing || !complete) guard.handoff = true;
677
+ saveGuard(store, accountId2, conversationId, guard);
678
+ return { messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
586
679
  }
587
680
 
588
681
  // src/routing.ts
@@ -594,7 +687,6 @@ var REQUEST_CRITERIA = {
594
687
  };
595
688
  var NO_KIND = "none";
596
689
  var NO_KIND_CRITERION = "None of the other kinds.";
597
- var JEV_URL = "https://api.typesafe.ai/v1/systemone";
598
690
  var MAX_MESSAGES = 3;
599
691
  var MAX_TEXT = 1600;
600
692
  var REDACTIONS = [
@@ -619,23 +711,23 @@ var REDACTIONS = [
619
711
  /(?<!\w)@[A-Za-z0-9_.-]{2,}/g
620
712
  // handles
621
713
  ];
622
- var decisionSchema = z5.object({
623
- owner: z5.string().nullable(),
624
- ownerConfidence: z5.number(),
625
- topic: z5.string().nullable(),
626
- topicConfidence: z5.number(),
627
- kind: z5.string().nullable().default(null),
628
- kindConfidence: z5.number().default(0),
629
- /** Jev is confident the customer asked for nothing yet (asked with `snoozeUnclear` only). */
630
- noRequest: z5.boolean().default(false)
714
+ var decisionSchema = z4.object({
715
+ owner: z4.string().nullable(),
716
+ ownerConfidence: z4.number(),
717
+ topic: z4.string().nullable(),
718
+ topicConfidence: z4.number(),
719
+ kind: z4.string().nullable().default(null),
720
+ kindConfidence: z4.number().default(0),
721
+ /** Jev is confident the customer asked for nothing yet. */
722
+ noRequest: z4.boolean().default(false)
631
723
  });
632
- var jevResponseSchema = z5.object({
633
- answers: z5.record(
634
- z5.string(),
635
- z5.object({
636
- choice: z5.string(),
637
- probabilities: z5.record(z5.string(), z5.number()).optional(),
638
- confidence: z5.number().optional()
724
+ var jevResponseSchema = z4.object({
725
+ answers: z4.record(
726
+ z4.string(),
727
+ z4.object({
728
+ choice: z4.string(),
729
+ probabilities: z4.record(z4.string(), z4.number()).optional(),
730
+ confidence: z4.number().optional()
639
731
  })
640
732
  )
641
733
  });
@@ -652,143 +744,112 @@ async function routeConversation(ctx, accountId2, conversationId) {
652
744
  const { settings, store, chatwoot } = ctx;
653
745
  const routing = settings.config.routing;
654
746
  const owners = routing.accounts[String(accountId2)];
655
- if (!owners) return;
747
+ const token = settings.secrets.CHATWOOT_AGENT_BOT_TOKENS[String(accountId2)];
748
+ if (!owners || !token) return;
749
+ const bot = chatwootClient(settings.config.chatwoot.baseUrl, token, ctx.fetch);
656
750
  const kinds = routing.kinds?.[String(accountId2)] ?? {};
657
- const raw = await chatwoot.getConversation(accountId2, conversationId);
658
- if (!raw) return;
659
- const conversation = toRelayConversation(conversationId, raw);
660
- const latest = await chatwoot.listMessages(accountId2, conversationId, { filter_internal_messages: true });
661
- const identities = [conversation.contact?.name, conversation.contact?.email];
662
- const inputs = await customerInputs(ctx, accountId2, conversationId, identities);
663
- let seen = Math.max(inputs.seen, ...latest.filter(isCustomer).map((message) => message.id));
664
- const key = inputs.ids.join(",");
665
- const memoKey = `decision:${accountId2}:${conversationId}:${key}`;
666
- const cached = inputs.ids.map(
667
- (_, index) => readDecision(store.get(`decision:${accountId2}:${conversationId}:${inputs.ids.slice(0, index + 1).join(",")}`))
668
- );
669
- let decision = cached.at(-1);
670
- const previous = cached.findLast((entry) => entry !== void 0);
671
- const kindFor = (answer) => answer?.kind && answer.kindConfidence >= routing.minConfidence ? kinds[answer.kind] : void 0;
672
- const assigneeFor = (answer) => answer?.owner && answer.ownerConfidence >= routing.minConfidence ? owners[answer.owner]?.assignee : void 0;
673
- const eligible = (observed2, answer) => conversationId > (settings.config.startAfterConversationId[String(accountId2)] ?? 0) && !observed2.contact?.blocked && observed2.status === "open" && (!observed2.assignee || observed2.assignee.id === assigneeFor(answer));
674
- let observed = raw;
675
- if (inputs.ids.length > 0 && eligible(conversation, previous)) {
676
- if (!decision) {
677
- decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], inputs.text);
678
- store.set(memoKey, JSON.stringify(decision));
679
- }
680
- const freshInputs = await customerInputs(ctx, accountId2, conversationId, identities);
681
- if (freshInputs.ids.join(",") !== key) return "defer";
682
- seen = Math.max(seen, freshInputs.seen);
683
- const fresh = await chatwoot.getConversation(accountId2, conversationId);
684
- if (!fresh) return;
685
- observed = fresh;
686
- const current = toRelayConversation(conversationId, fresh);
687
- if (eligible(current, decision)) {
688
- const kind = kindFor(decision);
689
- const kindName = kind ? decision.kind : null;
690
- const assignee = kind?.status ? void 0 : assigneeFor(decision);
691
- const inputId = inputs.ids.at(-1) ?? 0;
692
- if (assignee !== void 0) {
693
- await actOnce(
694
- ctx,
695
- `assign:${accountId2}:${conversationId}:${key}`,
696
- { inputId, kind: null, handled: 0 },
697
- "after",
698
- async () => {
699
- if (!current.assignee) await chatwoot.assign(accountId2, conversationId, assignee);
700
- }
701
- );
702
- }
703
- const topic = !kind?.status && decision.topic !== null && Object.hasOwn(routing.topics ?? {}, decision.topic) && decision.topicConfidence >= routing.minConfidence && current.labels.every((label) => Object.hasOwn(kinds, label)) ? decision.topic : null;
704
- const labels = [.../* @__PURE__ */ new Set([...current.labels, ...[topic, kindName].filter((label) => label !== null)])];
705
- await actOnce(
706
- ctx,
707
- `labels:${accountId2}:${conversationId}:${key}`,
708
- { inputId, kind: kindName, handled: 0 },
709
- "after",
710
- async () => {
711
- if (labels.length !== current.labels.length) await chatwoot.setLabels(accountId2, conversationId, labels);
712
- }
713
- );
714
- const replyKey = `reply:${accountId2}:${conversationId}`;
715
- const botToken = settings.botToken(accountId2);
716
- if (kind?.cannedResponse && botToken && store.get(replyKey) === void 0) {
717
- const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
718
- if (content === void 0) throw new Error("Chatwoot canned response is missing");
719
- const bot = chatwootClient(settings.config.chatwoot.baseUrl, botToken, ctx.fetch);
720
- await actOnce(
721
- ctx,
722
- replyKey,
723
- { inputId, kind: kindName, handled: inputId },
724
- "before",
725
- () => bot.createMessage(accountId2, conversationId, { content, private: false, files: [] })
726
- );
727
- }
728
- const snooze = !assignee && inputs.ids.length < MAX_MESSAGES && routing.snoozeUnclear && decision.noRequest;
729
- const status = freshInputs.complete ? kind?.status ?? (snooze ? "snoozed" : void 0) : void 0;
730
- if (status) {
731
- await actOnce(
732
- ctx,
733
- `status:${accountId2}:${conversationId}:${key}`,
734
- { inputId, kind: kindName, handled: kind?.status ? inputId : 0 },
735
- "before",
736
- () => chatwoot.setStatus(accountId2, conversationId, { status })
737
- );
738
- }
751
+ const botId = routing.botIds[String(accountId2)];
752
+ const snapshot = async (inboxId) => {
753
+ const raw = await chatwoot.getConversation(accountId2, conversationId);
754
+ if (!raw || raw.inbox_id === void 0 || inboxId !== void 0 && raw.inbox_id !== inboxId) return;
755
+ if (raw.status !== "pending") {
756
+ observeStatus(store, accountId2, conversationId, raw.status ?? "open");
757
+ return;
739
758
  }
740
- }
741
- await syncAttributes(ctx, accountId2, conversationId, observed, seen);
742
- }
743
- async function syncAttributes(ctx, accountId2, conversationId, observed, seen) {
744
- const current = observed.custom_attributes ?? {};
745
- const names = ROUTING_ATTRIBUTES;
746
- const key = `seen:${accountId2}:${conversationId}`;
747
- const checkpoint = Math.max(messageWatermark(ctx.store.get(key)), seen, messageWatermark(current[names.seen]));
748
- ctx.store.set(key, String(checkpoint));
749
- const effects = readEffects(ctx.store, accountId2, conversationId);
750
- const handled = Math.max(0, ...effects.map((effect) => effect.handled));
751
- const kind = effects.findLast((effect) => effect.kind !== null)?.kind ?? null;
752
- const attributes = {
753
- [names.seen]: checkpoint,
754
- ...handled > 0 ? { [names.handled]: Math.max(handled, messageWatermark(current[names.handled])) } : {},
755
- ...kind === null ? {} : { [names.kind]: kind }
759
+ const conversation = toRelayConversation(conversationId, raw);
760
+ if (conversation.contact.blocked || conversation.assignee) return;
761
+ if (raw.meta?.assignee && (raw.meta.assignee_type !== "AgentBot" || raw.meta.assignee.id !== botId)) return;
762
+ if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId) return;
763
+ observeStatus(store, accountId2, conversationId, "pending");
764
+ return { raw, conversation };
756
765
  };
757
- if (Object.entries(attributes).some(
758
- ([name, value]) => (name === names.kind ? current[name] : messageWatermark(current[name])) !== value
759
- ))
760
- await ctx.chatwoot.setCustomAttributes(accountId2, conversationId, attributes);
761
- }
762
- function isCustomer(message) {
763
- return message.message_type === 0 && !message.private;
764
- }
765
- async function customerInputs(ctx, accountId2, conversationId, identities) {
766
- const ids = [];
767
- const parts = [];
768
- let after = 0;
769
- let seen = 0;
770
- let complete = false;
771
- for (let page = 0; page < 3 && ids.length < MAX_MESSAGES; page += 1) {
772
- const messages = await ctx.chatwoot.listMessages(accountId2, conversationId, {
773
- after,
774
- filter_internal_messages: true
775
- });
776
- for (const message of messages.filter(isCustomer)) {
777
- seen = Math.max(seen, message.id);
766
+ const initial = await snapshot();
767
+ if (!initial) return;
768
+ const identities = [initial.conversation.contact.name, initial.conversation.contact.email];
769
+ const inputs = (messages) => {
770
+ const texts = messages.filter(isCustomer).flatMap((message) => {
778
771
  const text2 = sanitize(
779
772
  [message.content_attributes?.email?.subject ?? "", messageContent(message)].join("\n"),
780
773
  identities
781
774
  );
782
- if (ids.length < MAX_MESSAGES && text2.replaceAll("[REDACTED]", "").trim()) {
783
- ids.push(message.id);
784
- parts.push(text2);
785
- }
786
- }
787
- complete = ids.length === MAX_MESSAGES || messages.length < MESSAGE_PAGE_SIZE;
788
- if (complete) break;
789
- after = messages.at(-1)?.id ?? after;
775
+ return text2.replaceAll("[REDACTED]", "").trim() ? [{ id: message.id, text: text2 }] : [];
776
+ }).slice(0, MAX_MESSAGES);
777
+ return {
778
+ key: texts.map((entry) => entry.id).join(","),
779
+ text: texts.map((entry) => entry.text).join(" ").slice(0, MAX_TEXT),
780
+ count: texts.length
781
+ };
782
+ };
783
+ const turn = await readTurn(chatwoot, store, accountId2, conversationId);
784
+ const input = inputs(turn.messages);
785
+ const humanReply = (messages) => messages.some(
786
+ (message) => message.message_type === 1 && !message.private && message.sender?.type === "user" && !message.content_attributes?.deleted
787
+ );
788
+ const fresh = async () => {
789
+ const current2 = await snapshot(initial.raw.inbox_id);
790
+ if (!current2) return;
791
+ const latest = await readTurn(chatwoot, store, accountId2, conversationId);
792
+ if (latest.boundary !== turn.boundary || inputs(latest.messages).key !== input.key) return "defer";
793
+ return { ...current2, handoff: latest.handoff || humanReply(latest.messages) };
794
+ };
795
+ const handoff = async (current2) => {
796
+ requestHandoff(store, accountId2, conversationId);
797
+ current2 ??= await fresh();
798
+ if (current2 === "defer") return "defer";
799
+ if (current2) await bot.setStatus(accountId2, conversationId, { status: "open" });
800
+ return void 0;
801
+ };
802
+ if (turn.handoff || humanReply(turn.messages) || input.count === 0) return handoff();
803
+ const memoKey = `decision:${accountId2}:${conversationId}:${input.key}`;
804
+ let decision = readDecision(store.get(memoKey));
805
+ if (!decision) {
806
+ decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], input.text);
807
+ store.set(memoKey, JSON.stringify(decision));
790
808
  }
791
- return { ids, text: parts.join(" ").slice(0, MAX_TEXT), complete, seen };
809
+ const kind = decision.kind && decision.kindConfidence >= routing.minConfidence ? kinds[decision.kind] : void 0;
810
+ const assignee = decision.owner && decision.ownerConfidence >= routing.minConfidence ? owners[decision.owner]?.assignee : void 0;
811
+ let current = await fresh();
812
+ if (current === "defer" || !current) return current;
813
+ if (current.handoff) return handoff(current);
814
+ if (!kind && decision.noRequest && input.count < MAX_MESSAGES) return;
815
+ const topic = !kind?.status && decision.topic !== null && Object.hasOwn(routing.topics ?? {}, decision.topic) && decision.topicConfidence >= routing.minConfidence && current.conversation.labels.every((label) => Object.hasOwn(kinds, label)) ? decision.topic : null;
816
+ const labels = [
817
+ .../* @__PURE__ */ new Set([
818
+ ...current.conversation.labels,
819
+ ...[topic, kind ? decision.kind : null].filter((label) => label !== null)
820
+ ])
821
+ ];
822
+ if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
823
+ const replyKey = `reply:${accountId2}:${conversationId}`;
824
+ if (kind?.cannedResponse && store.get(replyKey) === void 0) {
825
+ const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
826
+ if (!content?.trim()) return handoff();
827
+ current = await fresh();
828
+ if (current === "defer" || !current) return current;
829
+ if (current.handoff) return handoff(current);
830
+ store.set(replyKey, "attempted");
831
+ await bot.createMessage(accountId2, conversationId, { content, private: false, files: [] });
832
+ }
833
+ if (assignee !== void 0 && !kind?.status) {
834
+ const agents = await chatwoot.listAgents(accountId2);
835
+ if (!agents.some((agent) => agent.id === assignee)) return handoff();
836
+ }
837
+ current = await fresh();
838
+ if (current === "defer" || !current) return current;
839
+ if (current.handoff) return handoff(current);
840
+ if (kind?.status) {
841
+ await bot.setStatus(accountId2, conversationId, { status: kind.status });
842
+ expectActivity(store, accountId2, conversationId, { status: kind.status });
843
+ } else if (assignee !== void 0 && current.raw.meta?.assignee_type === "AgentBot") {
844
+ await bot.assign(accountId2, conversationId, assignee);
845
+ expectActivity(store, accountId2, conversationId, { status: "open" });
846
+ } else {
847
+ return handoff(current);
848
+ }
849
+ return void 0;
850
+ }
851
+ function isCustomer(message) {
852
+ return message.message_type === 0 && !message.private && !message.content_attributes?.deleted && !message.content_attributes?.email?.auto_reply && (message.sender?.type == null || message.sender.type === "contact");
792
853
  }
793
854
  function sanitize(text2, identities) {
794
855
  let value = text2.normalize("NFKC");
@@ -803,7 +864,6 @@ function sanitize(text2, identities) {
803
864
  async function decide(ctx, owners, kinds, text2) {
804
865
  const routing = ctx.settings.config.routing;
805
866
  const apiKey = ctx.settings.secrets.TYPESAFE_API_KEY;
806
- if (!routing || !apiKey) throw new JevError("routing is not configured");
807
867
  const ownerCriteria = Object.fromEntries(
808
868
  Object.entries(owners).map(([route, owner2]) => [route, owner2.covers])
809
869
  );
@@ -822,13 +882,11 @@ async function decide(ctx, owners, kinds, text2) {
822
882
  criteria: routing.topics
823
883
  };
824
884
  }
825
- if (routing.snoozeUnclear) {
826
- questions.request = {
827
- type: "choice",
828
- instructions: "Select whether the customer asks for anything in this support ticket, using only the ticket.",
829
- criteria: REQUEST_CRITERIA
830
- };
831
- }
885
+ questions.request = {
886
+ type: "choice",
887
+ instructions: "Select whether the customer asks for anything in this support ticket, using only the ticket.",
888
+ criteria: REQUEST_CRITERIA
889
+ };
832
890
  if (kinds) {
833
891
  questions.kind = {
834
892
  type: "choice",
@@ -840,7 +898,7 @@ async function decide(ctx, owners, kinds, text2) {
840
898
  };
841
899
  }
842
900
  const response = await ctx.fetch(
843
- new Request(JEV_URL, {
901
+ new Request(routing.endpoint, {
844
902
  method: "POST",
845
903
  redirect: "manual",
846
904
  headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
@@ -899,83 +957,84 @@ async function readConfig(env) {
899
957
  }
900
958
 
901
959
  // src/config.ts
902
- import { z as z6 } from "zod";
903
- var accountId = z6.string().regex(/^[1-9]\d*$/, "must be a Chatwoot account id").refine((value) => Number.isSafeInteger(Number(value)), "must be a safe integer");
904
- var configSchema = z6.strictObject({
905
- chatwoot: z6.strictObject({ baseUrl: z6.url({ protocol: /^https?$/ }) }),
906
- reconcile: reconcileSchema,
907
- startAfterConversationId: z6.record(accountId, z6.number().int().min(0)).default({}),
908
- subrequestBudget: z6.number().int().min(20).max(1e3).default(45),
909
- routing: z6.strictObject({
910
- model: z6.string().min(1).default("jev-1.13.0"),
960
+ import { z as z5 } from "zod";
961
+ var accountId = z5.string().regex(/^[1-9]\d*$/, "must be a Chatwoot account id").refine((value) => Number.isSafeInteger(Number(value)), "must be a safe integer");
962
+ var configSchema = z5.strictObject({
963
+ chatwoot: z5.strictObject({ baseUrl: z5.url({ protocol: /^https?$/ }) }),
964
+ subrequestBudget: z5.number().int().min(45).max(1e3).default(45),
965
+ routing: z5.strictObject({
966
+ /** TypeSafe's System One API (/v1/systemone), or another deployment such as a proxy or gateway. */
967
+ endpoint: z5.url({ protocol: /^https$/ }).default("https://api.typesafe.ai/v1/systemone"),
968
+ /** Model sent to TypeSafe's System One API; Jev by default. */
969
+ model: z5.string().min(1).default("jev-1.13.0"),
911
970
  /** Jev's probability an answer needs before it is applied. */
912
- minConfidence: z6.number().min(0.5).max(1).default(0.7),
913
- /**
914
- * Snooze a ticket Jev cannot assign yet, and in which the customer asked for nothing yet, until
915
- * their next message (see src/routing.ts).
916
- */
917
- snoozeUnclear: z6.boolean().default(false),
971
+ minConfidence: z5.number().min(0.5).max(1).default(0.7),
972
+ /** The account's native Chatwoot agent bot. */
973
+ botIds: z5.record(accountId, z5.number().int().positive()),
918
974
  /** Per Chatwoot account id: the owners Jev chooses from, by a short name. */
919
- accounts: z6.record(
975
+ accounts: z5.record(
920
976
  accountId,
921
- z6.record(
922
- z6.string().regex(/^[a-z0-9_]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "unclear", "unclear is reserved"),
923
- z6.strictObject({
977
+ z5.record(
978
+ z5.string().regex(/^[a-z0-9_]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "unclear", "unclear is reserved"),
979
+ z5.strictObject({
924
980
  /** Chatwoot user id to assign. */
925
- assignee: z6.number().int().positive(),
981
+ assignee: z5.number().int().positive(),
926
982
  /** What the owner handles, as Jev's criterion for choosing them. */
927
- covers: z6.string().min(1).max(1e3)
983
+ covers: z5.string().min(1).max(1e3)
928
984
  })
929
985
  ).refine((owners) => Object.keys(owners).length > 0, "needs at least one owner")
930
986
  ),
931
987
  /** Topic labels (Chatwoot label names, lower case) and what each covers. Unset: no topic. */
932
- topics: z6.record(
933
- z6.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
934
- z6.string().min(1).max(1e3)
988
+ topics: z5.record(
989
+ z5.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
990
+ z5.string().min(1).max(1e3)
935
991
  ).optional(),
936
992
  /**
937
993
  * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
938
994
  * once, when it does (see src/routing.ts). Unset: none.
939
995
  */
940
- kinds: z6.record(
996
+ kinds: z5.record(
941
997
  accountId,
942
- z6.record(
943
- z6.string().regex(/^[a-z0-9_-]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "none", "none is reserved"),
944
- z6.strictObject({
998
+ z5.record(
999
+ z5.string().regex(/^[a-z0-9_-]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "none", "none is reserved"),
1000
+ z5.strictObject({
945
1001
  /** What the kind is, as Jev's criterion for recognizing it. */
946
- covers: z6.string().min(1).max(1e3),
1002
+ covers: z5.string().min(1).max(1e3),
947
1003
  /**
948
1004
  * Short code of the account's Chatwoot canned response sent to the customer once, as
949
- * the account's agent bot (CHATWOOT_BOT_TOKENS). None is sent while it does not exist.
1005
+ * the account's agent bot (CHATWOOT_AGENT_BOT_TOKENS). Missing responses hand off to people.
950
1006
  */
951
- cannedResponse: z6.string().trim().min(1).max(255).optional(),
1007
+ cannedResponse: z5.string().trim().min(1).max(255).optional(),
952
1008
  /**
953
1009
  * Set instead of routing the ticket (after the reply, with cannedResponse): `resolved`,
954
1010
  * or `snoozed` until the customer's next message. A new message from the customer
955
1011
  * reopens either.
956
1012
  */
957
- status: z6.enum(["resolved", "snoozed"]).optional()
1013
+ status: z5.enum(["resolved", "snoozed"]).optional()
958
1014
  })
959
1015
  )
960
1016
  ).optional()
961
1017
  })
962
- }).refine((config) => Object.keys(config.startAfterConversationId).every((id3) => config.routing.accounts[id3]), {
963
- path: ["startAfterConversationId"],
964
- message: "must only name routed accounts"
965
- }).refine((config) => Object.keys(config.routing?.kinds ?? {}).every((id3) => config.routing?.accounts[id3]), {
1018
+ }).refine(
1019
+ (config) => {
1020
+ const accounts = Object.keys(config.routing.accounts);
1021
+ return accounts.length > 0 && accounts.length === Object.keys(config.routing.botIds).length && accounts.every((id3) => config.routing.botIds[id3] !== void 0);
1022
+ },
1023
+ { path: ["routing", "botIds"], message: "must name exactly the routed accounts" }
1024
+ ).refine((config) => Object.keys(config.routing.kinds ?? {}).every((id3) => config.routing.accounts[id3]), {
966
1025
  path: ["routing", "kinds"],
967
1026
  message: "must only name routed accounts"
968
1027
  }).refine(
969
- (config) => Object.values(config.routing?.kinds ?? {}).every(
970
- (kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing?.topics ?? {}, kind))
1028
+ (config) => Object.values(config.routing.kinds ?? {}).every(
1029
+ (kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing.topics ?? {}, kind))
971
1030
  ),
972
1031
  { path: ["routing", "kinds"], message: "a kind cannot be named as a topic: they are labels of two families" }
973
1032
  );
974
- var secretsSchema = z6.object({
975
- CHATWOOT_TOKEN: z6.string().min(1),
976
- CHATWOOT_WEBHOOK_SECRETS: jsonRecord,
977
- TYPESAFE_API_KEY: z6.string().min(1),
978
- CHATWOOT_BOT_TOKENS: jsonRecord.default({})
1033
+ var secretsSchema = z5.object({
1034
+ CHATWOOT_TOKEN: z5.string().min(1),
1035
+ CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
1036
+ TYPESAFE_API_KEY: z5.string().min(1),
1037
+ CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
979
1038
  });
980
1039
  function parseSettings(rawConfig, rawSecrets) {
981
1040
  const config = configSchema.safeParse(rawConfig);
@@ -985,17 +1044,13 @@ function parseSettings(rawConfig, rawSecrets) {
985
1044
  return buildSettings(config.data, secrets.data);
986
1045
  }
987
1046
  function buildSettings(config, secrets) {
988
- for (const accountId2 of Object.keys(config.routing.accounts)) {
989
- if (!secrets.CHATWOOT_WEBHOOK_SECRETS[accountId2]) {
990
- throw new ConfigError(`Invalid secrets: CHATWOOT_WEBHOOK_SECRETS: account ${accountId2} needs a webhook secret`);
991
- }
992
- }
993
- for (const [accountId2, kinds] of Object.entries(config.routing.kinds ?? {})) {
994
- if (Object.values(kinds).some((kind) => kind.cannedResponse) && !secrets.CHATWOOT_BOT_TOKENS[accountId2]) {
995
- throw new ConfigError(`Invalid secrets: CHATWOOT_BOT_TOKENS: account ${accountId2} has kinds that reply`);
1047
+ const accounts = Object.keys(config.routing.accounts);
1048
+ for (const name of ["CHATWOOT_AGENT_BOT_SECRETS", "CHATWOOT_AGENT_BOT_TOKENS"]) {
1049
+ if (Object.keys(secrets[name]).length !== accounts.length || accounts.some((id3) => !secrets[name][id3]?.trim())) {
1050
+ throw new ConfigError(`Invalid secrets: ${name}: needs exactly the routed accounts`);
996
1051
  }
997
1052
  }
998
- return { config, secrets, botToken: (accountId2) => secrets.CHATWOOT_BOT_TOKENS[String(accountId2)] };
1053
+ return { config, secrets };
999
1054
  }
1000
1055
 
1001
1056
  // src/settings.ts
@@ -1003,24 +1058,39 @@ var loadSettings = settingsLoader(parseSettings);
1003
1058
 
1004
1059
  // src/router.ts
1005
1060
  var ROUTER_NAME = "global";
1006
- var ROUTE_BUDGET = 15;
1007
- var BUDGET = { route: ROUTE_BUDGET, sweep: 1 };
1061
+ var ROUTE_BUDGET = 45;
1062
+ var BUDGET = { route: ROUTE_BUDGET, sweep: 2 };
1008
1063
  var RUN_WALL_MS = 5 * 60 * 1e3;
1009
- var id = z7.number().int().positive();
1010
- var jobSchema = z7.discriminatedUnion("type", [
1011
- z7.object({ type: z7.literal("route"), accountId: id, conversationId: id }),
1012
- z7.object({ type: z7.literal("sweep"), accountId: id })
1064
+ var id = z6.number().int().positive();
1065
+ var jobSchema = z6.discriminatedUnion("type", [
1066
+ z6.object({ type: z6.literal("route"), accountId: id, conversationId: id }),
1067
+ z6.object({ type: z6.literal("sweep"), accountId: id })
1013
1068
  ]);
1069
+ var passSchema = z6.object({
1070
+ page: z6.number().int().positive(),
1071
+ remaining: z6.array(id),
1072
+ routed: z6.array(id)
1073
+ });
1014
1074
  var Router = class extends DurableObject {
1015
1075
  store;
1016
1076
  constructor(context, env) {
1017
1077
  super(context, env);
1018
1078
  this.store = new QueueStore(context.storage.sql);
1019
- context.blockConcurrencyWhile(async () => this.store.migrate());
1079
+ context.blockConcurrencyWhile(async () => {
1080
+ this.store.migrate();
1081
+ if (!this.store.get("migration:agent-bot")) {
1082
+ context.storage.sql.exec("DELETE FROM jobs");
1083
+ for (const prefix of ["seen:", "decision:", "assign:", "labels:", "status:", "sweep:"]) {
1084
+ context.storage.sql.exec("DELETE FROM cache WHERE key >= ? AND key < ?", prefix, `${prefix.slice(0, -1)};`);
1085
+ }
1086
+ this.store.set("migration:agent-bot", "1");
1087
+ }
1088
+ });
1020
1089
  }
1021
- async enqueueConversation(accountId2, conversationId) {
1090
+ async enqueueConversation(accountId2, conversationId, transition) {
1022
1091
  const settings = await loadSettings(this.env);
1023
1092
  if (!routesAccount(settings, accountId2)) return;
1093
+ if (transition) expectActivity(this.store, accountId2, conversationId, transition);
1024
1094
  this.enqueue({ type: "route", accountId: accountId2, conversationId });
1025
1095
  await this.schedule();
1026
1096
  }
@@ -1057,6 +1127,7 @@ var Router = class extends DurableObject {
1057
1127
  yielded = true;
1058
1128
  break;
1059
1129
  }
1130
+ clearFailures(this.store, payload.accountId, payload.conversationId);
1060
1131
  } else if (routesAccount(settings, payload.accountId)) {
1061
1132
  await this.sweep(settings, chatwoot, payload.accountId);
1062
1133
  }
@@ -1072,11 +1143,12 @@ var Router = class extends DurableObject {
1072
1143
  yielded = true;
1073
1144
  break;
1074
1145
  }
1075
- const delay = retryDelay(job.attempts);
1076
- const logAt = job.attempts + 1 >= 3 ? log.error : log.warn;
1146
+ const attempts = payload.type === "route" ? recordFailure(this.store, payload.accountId, payload.conversationId) : job.attempts + 1;
1147
+ const delay = retryDelay(attempts - 1);
1148
+ const logAt = attempts >= 3 ? log.error : log.warn;
1077
1149
  logAt("job failed; will retry", {
1078
1150
  job: job.key,
1079
- attempts: job.attempts + 1,
1151
+ attempts,
1080
1152
  delayMs: delay,
1081
1153
  ...errorFields(error)
1082
1154
  });
@@ -1086,24 +1158,30 @@ var Router = class extends DurableObject {
1086
1158
  await this.schedule(yielded ? Date.now() : void 0);
1087
1159
  }
1088
1160
  async sweep(settings, chatwoot, accountId2) {
1089
- const pass = readSweepPass(this.store, accountId2, settings.config.reconcile);
1090
- const conversations = await chatwoot.listConversations(accountId2, pass.page);
1091
- let finished = conversations.length === 0;
1092
- for (const conversation of conversations) {
1093
- if ((conversation.last_activity_at ?? 0) < pass.cutoff) {
1094
- finished = true;
1095
- break;
1161
+ const key = `sweep:${accountId2}:pending`;
1162
+ const saved = passSchema.safeParse(parseJson(this.store.get(key)));
1163
+ const pass = saved.success ? saved.data : { page: 1, remaining: await chatwoot.listInboxes(accountId2), routed: [] };
1164
+ const inboxId = pass.remaining[0];
1165
+ if (inboxId !== void 0) {
1166
+ if ((await chatwoot.inboxBot(accountId2, inboxId))?.id === settings.config.routing.botIds[String(accountId2)]) {
1167
+ pass.routed.push(inboxId);
1096
1168
  }
1097
- const conversationId = conversation.id;
1098
- if (conversationId === void 0) continue;
1099
- this.enqueue({ type: "route", accountId: accountId2, conversationId });
1100
- }
1101
- if (finished) {
1102
- saveSweepPass(this.store, accountId2, pass);
1169
+ pass.remaining.shift();
1103
1170
  } else {
1104
- saveSweepPass(this.store, accountId2, pass, pass.page + 1);
1105
- this.enqueue({ type: "sweep", accountId: accountId2 });
1171
+ const conversations = await chatwoot.listConversations(accountId2, pass.page, "pending");
1172
+ for (const conversation of conversations) {
1173
+ if (conversation.id !== void 0 && conversation.inbox_id !== void 0 && pass.routed.includes(conversation.inbox_id)) {
1174
+ this.enqueue({ type: "route", accountId: accountId2, conversationId: conversation.id });
1175
+ }
1176
+ }
1177
+ if (conversations.length === 0) {
1178
+ this.store.delete(key);
1179
+ return;
1180
+ }
1181
+ pass.page += 1;
1106
1182
  }
1183
+ this.store.set(key, JSON.stringify(pass));
1184
+ this.enqueue({ type: "sweep", accountId: accountId2 });
1107
1185
  }
1108
1186
  enqueue(payload) {
1109
1187
  const key = payload.type === "sweep" ? `sweep:${payload.accountId}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
@@ -1118,24 +1196,36 @@ var Router = class extends DurableObject {
1118
1196
  };
1119
1197
 
1120
1198
  // src/webhook.ts
1121
- import { z as z8 } from "zod";
1122
- var id2 = z8.number().int().positive();
1123
- var eventSchema = z8.object({
1124
- event: z8.string(),
1125
- account: z8.object({ id: id2 }),
1199
+ import { z as z7 } from "zod";
1200
+ var id2 = z7.number().int().positive();
1201
+ var eventSchema = z7.object({
1202
+ event: z7.string(),
1203
+ account: z7.object({ id: id2 }),
1126
1204
  id: id2.optional(),
1127
- conversation: z8.object({ id: id2 }).optional(),
1128
- message_type: z8.union([z8.string(), z8.number()]).optional(),
1129
- private: z8.boolean().optional(),
1130
- sender: z8.object({ type: z8.string().optional() }).nullish()
1205
+ conversation: z7.object({ id: id2 }).optional(),
1206
+ message_type: z7.union([z7.string(), z7.number()]).optional(),
1207
+ private: z7.boolean().optional(),
1208
+ updated_at: z7.number().optional(),
1209
+ changed_attributes: z7.array(z7.object({ status: z7.object({ previous_value: z7.string(), current_value: z7.string() }).optional() })).optional(),
1210
+ sender: z7.object({ type: z7.string().optional() }).nullish()
1131
1211
  });
1132
- var CONVERSATION_EVENTS = /* @__PURE__ */ new Set(["conversation_created", "conversation_updated", "conversation_status_changed"]);
1212
+ var CONVERSATION_EVENTS = /* @__PURE__ */ new Set([
1213
+ "conversation_opened",
1214
+ "conversation_resolved",
1215
+ "conversation_updated",
1216
+ "conversation_status_changed"
1217
+ ]);
1133
1218
  function eventTarget(payload) {
1134
1219
  const parsed = eventSchema.safeParse(payload);
1135
1220
  if (!parsed.success) return void 0;
1136
1221
  const event = parsed.data;
1137
1222
  if (CONVERSATION_EVENTS.has(event.event) && event.id !== void 0) {
1138
- return { accountId: event.account.id, conversationId: event.id };
1223
+ const change = event.changed_attributes?.find((entry) => entry.status)?.status;
1224
+ return {
1225
+ accountId: event.account.id,
1226
+ conversationId: event.id,
1227
+ ...change && (change.previous_value === "pending" || change.current_value === "resolved" || change.current_value === "pending" && change.previous_value !== "resolved") && event.updated_at !== void 0 ? { transition: { status: change.current_value, at: event.updated_at } } : {}
1228
+ };
1139
1229
  }
1140
1230
  if (event.event === "message_created" && (event.message_type === "incoming" || event.message_type === 0) && !event.private && event.sender?.type === "contact" && event.id !== void 0 && event.conversation) {
1141
1231
  return { accountId: event.account.id, conversationId: event.conversation.id };
@@ -1154,7 +1244,7 @@ app.get("/healthz", async (context) => {
1154
1244
  return context.json({ ok: false }, 503);
1155
1245
  }
1156
1246
  });
1157
- app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (context) => {
1247
+ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (context) => {
1158
1248
  const settings = await loadSettings(context.env);
1159
1249
  const timestamp = context.req.header("x-chatwoot-timestamp");
1160
1250
  if (!timestamp || !isFreshTimestamp(timestamp, Math.floor(Date.now() / 1e3))) {
@@ -1163,7 +1253,7 @@ app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (co
1163
1253
  const body = new Uint8Array(await context.req.arrayBuffer());
1164
1254
  const signature = context.req.header("x-chatwoot-signature");
1165
1255
  let signedBy;
1166
- for (const [accountId2, secret] of Object.entries(settings.secrets.CHATWOOT_WEBHOOK_SECRETS)) {
1256
+ for (const [accountId2, secret] of Object.entries(settings.secrets.CHATWOOT_AGENT_BOT_SECRETS)) {
1167
1257
  if (await verifyChatwootSignature(secret, timestamp, body, signature)) {
1168
1258
  signedBy = Number(accountId2);
1169
1259
  break;
@@ -1181,7 +1271,11 @@ app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (co
1181
1271
  if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1182
1272
  return context.text("account does not match the webhook secret", 403);
1183
1273
  }
1184
- await context.env.ROUTER.getByName(ROUTER_NAME).enqueueConversation(target.accountId, target.conversationId);
1274
+ await context.env.ROUTER.getByName(ROUTER_NAME).enqueueConversation(
1275
+ target.accountId,
1276
+ target.conversationId,
1277
+ target.transition
1278
+ );
1185
1279
  return context.json({ ok: true });
1186
1280
  });
1187
1281
  app.notFound((context) => context.text("not found", 404));