chatwoot-router 0.3.0 → 0.5.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.
@@ -2,118 +2,6 @@
2
2
  import { Hono } from "hono";
3
3
  import { bodyLimit } from "hono/body-limit";
4
4
 
5
- // ../../shared/deadline.ts
6
- async function within(pending, signal) {
7
- let abort;
8
- try {
9
- return await Promise.race([
10
- pending,
11
- new Promise((_resolve, reject) => {
12
- abort = () => reject(signal.reason);
13
- if (signal.aborted) abort();
14
- else signal.addEventListener("abort", abort, { once: true });
15
- })
16
- ]);
17
- } finally {
18
- if (abort) signal.removeEventListener("abort", abort);
19
- }
20
- }
21
- var JobDeadlineError = class extends Error {
22
- requestStarted;
23
- constructor(requestStarted = false) {
24
- super("Job time slice is used up");
25
- this.name = "JobDeadlineError";
26
- this.requestStarted = requestStarted;
27
- }
28
- };
29
-
30
- // ../../shared/budget.ts
31
- var BudgetExhaustedError = class extends Error {
32
- constructor() {
33
- super("Subrequest budget for this invocation is used up");
34
- this.name = "BudgetExhaustedError";
35
- }
36
- };
37
- var METADATA_TIMEOUT_MS = 1500;
38
- var JOB_SLICE_MS = 1e4;
39
- var Budget = class {
40
- constructor(limit, fetchImpl = (request) => fetch(request)) {
41
- this.limit = limit;
42
- this.fetchImpl = fetchImpl;
43
- }
44
- limit;
45
- fetchImpl;
46
- used = 0;
47
- slice;
48
- sliceEnds = Number.POSITIVE_INFINITY;
49
- get remaining() {
50
- return this.limit - this.used;
51
- }
52
- startSlice(ms = JOB_SLICE_MS) {
53
- this.sliceEnds = Date.now() + ms;
54
- this.slice = AbortSignal.timeout(ms);
55
- }
56
- requireTime(ms) {
57
- this.checkpoint();
58
- if (Date.now() + ms > this.sliceEnds) throw new JobDeadlineError();
59
- }
60
- checkpoint() {
61
- if (this.slice?.aborted) throw new JobDeadlineError();
62
- }
63
- consume() {
64
- this.checkpoint();
65
- if (this.used >= this.limit) throw new BudgetExhaustedError();
66
- this.used += 1;
67
- }
68
- require(requests) {
69
- this.checkpoint();
70
- if (this.remaining < requests) throw new BudgetExhaustedError();
71
- }
72
- controlSignal(ms) {
73
- return AbortSignal.any([AbortSignal.timeout(ms), ...this.slice ? [this.slice] : []]);
74
- }
75
- fetchWith(timeoutMs) {
76
- return (request) => this.request(request, timeoutMs);
77
- }
78
- fetch = (request) => this.request(request, METADATA_TIMEOUT_MS);
79
- async request(request, timeoutMs) {
80
- this.consume();
81
- const operation = AbortSignal.timeout(timeoutMs);
82
- const signal = AbortSignal.any([request.signal, operation, ...this.slice ? [this.slice] : []]);
83
- try {
84
- const response = await within(this.fetchImpl(new Request(request, { signal })), signal);
85
- if (!response.body) return response;
86
- const reader = response.body.getReader();
87
- const slice = this.slice;
88
- const body = new ReadableStream({
89
- async pull(controller) {
90
- try {
91
- const { done, value } = await within(reader.read(), signal);
92
- if (done) controller.close();
93
- else controller.enqueue(value);
94
- } catch (error) {
95
- controller.error(slice?.aborted ? new JobDeadlineError(true) : error);
96
- void reader.cancel().catch(() => {
97
- });
98
- }
99
- },
100
- cancel: (reason) => {
101
- void reader.cancel(reason).catch(() => {
102
- });
103
- }
104
- });
105
- return new Response(body, {
106
- status: response.status,
107
- statusText: response.statusText,
108
- headers: response.headers
109
- });
110
- } catch (error) {
111
- if (this.slice?.aborted) throw new JobDeadlineError(true);
112
- throw error;
113
- }
114
- }
115
- };
116
-
117
5
  // ../../shared/chatwoot/signature.ts
118
6
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
119
7
  var encoder = new TextEncoder();
@@ -177,16 +65,42 @@ function errorFields(error) {
177
65
  return error instanceof Error ? { error: error.name, detail: error.message } : { error: String(error) };
178
66
  }
179
67
 
180
- // src/coordinator.ts
181
- import { DurableObject as DurableObject2 } from "cloudflare:workers";
182
- import { z as z7 } from "zod";
68
+ // src/router.ts
69
+ import { DurableObject } from "cloudflare:workers";
70
+ import { z as z6 } from "zod";
183
71
 
184
- // ../../shared/alarm.ts
185
- async function scheduleAlarm(state, next) {
186
- if (next === void 0) return;
187
- const current = await state.storage.getAlarm();
188
- if (current === null || current > next) await state.storage.setAlarm(Math.max(Date.now(), next));
189
- }
72
+ // ../../shared/budget.ts
73
+ var BudgetExhaustedError = class extends Error {
74
+ constructor() {
75
+ super("Subrequest budget for this invocation is used up");
76
+ this.name = "BudgetExhaustedError";
77
+ }
78
+ };
79
+ var REQUEST_TIMEOUT_MS = 6e4;
80
+ var Budget = class {
81
+ constructor(limit, fetchImpl = (request) => fetch(request), timeoutMs = REQUEST_TIMEOUT_MS) {
82
+ this.limit = limit;
83
+ this.fetchImpl = fetchImpl;
84
+ this.timeoutMs = timeoutMs;
85
+ }
86
+ limit;
87
+ fetchImpl;
88
+ timeoutMs;
89
+ used = 0;
90
+ get remaining() {
91
+ return this.limit - this.used;
92
+ }
93
+ /** Counts a request that is not a fetch, such as a call to another Durable Object. */
94
+ consume() {
95
+ if (this.used >= this.limit) throw new BudgetExhaustedError();
96
+ this.used += 1;
97
+ }
98
+ fetch = (request) => {
99
+ if (this.used >= this.limit) return Promise.reject(new BudgetExhaustedError());
100
+ this.used += 1;
101
+ return this.fetchImpl(new Request(request, { signal: AbortSignal.timeout(this.timeoutMs) }));
102
+ };
103
+ };
190
104
 
191
105
  // ../../shared/chatwoot/api.ts
192
106
  import createClient from "openapi-fetch";
@@ -194,15 +108,18 @@ import { z as z2 } from "zod";
194
108
  var ChatwootError = class extends Error {
195
109
  status;
196
110
  conversationMissing;
197
- retryAfterMs;
198
- constructor(status, operation, conversationMissing = false, retryAfterMs) {
111
+ constructor(status, operation, conversationMissing = false) {
199
112
  super(`Chatwoot ${operation} failed with HTTP ${status}`);
200
113
  this.name = "ChatwootError";
201
114
  this.status = status;
202
115
  this.conversationMissing = conversationMissing;
203
- this.retryAfterMs = retryAfterMs;
204
116
  }
205
117
  };
118
+ function snoozedUntil(conversation) {
119
+ const value = conversation.snoozed_until;
120
+ const ms = typeof value === "string" ? Date.parse(value) : Number.NaN;
121
+ return Number.isNaN(ms) ? null : Math.floor(ms / 1e3);
122
+ }
206
123
  var text = z2.string().nullish();
207
124
  var attachmentSchema = z2.object({
208
125
  file_type: text,
@@ -252,33 +169,18 @@ var messageSchema = z2.object({
252
169
  attachments: z2.array(attachmentSchema).nullish()
253
170
  });
254
171
  var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
172
+ var CONVERSATIONS_PER_PAGE = 25;
255
173
  var MESSAGE_HISTORY_PAGE_SIZE = 20;
256
174
  var inboxBotSchema = z2.object({
257
175
  agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
258
176
  });
259
- function chatwootClient(baseUrl, token, fetch2, limits) {
177
+ function chatwootClient(baseUrl, token, fetch2) {
260
178
  const client = createClient({
261
179
  baseUrl: baseUrl.replace(/\/+$/, ""),
262
180
  headers: { api_access_token: token },
263
181
  // A redirect is an error, never followed: it could carry the token to another host, and each
264
182
  // hop would be a subrequest the budget does not count. Configure Chatwoot's final URL.
265
- fetch: async (request) => {
266
- const account = /\/accounts\/(\d+)\//.exec(new URL(request.url).pathname)?.[1] ?? "profile";
267
- const key = `chatwoot:limit:${baseUrl}:${account}`;
268
- const wait = Number(limits?.get(key) ?? 0) - Date.now();
269
- if (wait > 0) throw new ChatwootError(429, "rate limited", false, wait);
270
- const response = await fetch2(new Request(request, { redirect: "manual" }));
271
- if (response.status === 429) {
272
- const header = response.headers.get("retry-after");
273
- const seconds = Number(header);
274
- const delay = header === null ? 5e3 : Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
275
- const retryAfterMs = Number.isFinite(delay) ? Math.max(1, delay) : 5e3;
276
- limits?.set(key, String(Date.now() + retryAfterMs), retryAfterMs);
277
- await response.body?.cancel();
278
- throw new ChatwootError(429, "rate limited", false, retryAfterMs);
279
- }
280
- return response;
281
- }
183
+ fetch: (request) => fetch2(new Request(request, { redirect: "manual" }))
282
184
  });
283
185
  async function data(operation, pending) {
284
186
  const { data: data2, response } = await pending;
@@ -536,6 +438,8 @@ function toRelayConversation(conversationId, conversation) {
536
438
  },
537
439
  assignee: assignee ? { id: assignee.id, name: assignee.name } : null,
538
440
  assigneeType: meta?.assignee_type ?? null,
441
+ waitingSince: conversation.waiting_since || null,
442
+ snoozedUntil: snoozedUntil(conversation),
539
443
  customAttributes: conversation.custom_attributes ?? {}
540
444
  };
541
445
  }
@@ -559,17 +463,12 @@ function retryDelay(attempts) {
559
463
  return Math.min(5e3 * 2 ** attempts, 30 * 60 * 1e3);
560
464
  }
561
465
  var QueueStore = class {
562
- constructor(sql, now = Date.now, atomic = (write2) => write2()) {
466
+ constructor(sql, now = Date.now) {
563
467
  this.sql = sql;
564
468
  this.now = now;
565
- this.atomic = atomic;
566
469
  }
567
470
  sql;
568
471
  now;
569
- atomic;
570
- transaction(write2) {
571
- return this.atomic(write2);
572
- }
573
472
  migrate() {
574
473
  this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
575
474
  key TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL,
@@ -578,10 +477,6 @@ var QueueStore = class {
578
477
  );
579
478
  CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
580
479
  CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);`);
581
- const columns = this.sql.exec("PRAGMA table_info(jobs)").toArray();
582
- if (!columns.some((column) => column.name === "suspended")) {
583
- this.sql.exec("ALTER TABLE jobs ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0");
584
- }
585
480
  }
586
481
  // Cache
587
482
  get(key) {
@@ -608,7 +503,7 @@ var QueueStore = class {
608
503
  enqueue(key, priority, payload, notBefore = this.now()) {
609
504
  this.sql.exec(
610
505
  `INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
611
- ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload, suspended = 0,
506
+ ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
612
507
  not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
613
508
  key,
614
509
  priority,
@@ -618,24 +513,18 @@ var QueueStore = class {
618
513
  );
619
514
  }
620
515
  nextDueJob() {
621
- return this.nextDueJobs(1)[0];
622
- }
623
- /** A bounded due batch; independent scan and delivery work can select their own keys. */
624
- nextDueJobs(limit, prefix = "") {
625
- return this.sql.exec(
626
- `SELECT key, payload, version, attempts, created_at AS createdAt FROM jobs
627
- WHERE suspended != 2 AND not_before <= ? AND substr(key, 1, ?) = ?
628
- ORDER BY MAX(0, priority - CAST((? - created_at) / 30000 AS INTEGER)), not_before, created_at LIMIT ?`,
629
- this.now(),
630
- prefix.length,
631
- prefix,
632
- this.now(),
633
- limit
634
- ).toArray();
516
+ const row = this.sql.exec(
517
+ `SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
518
+ ORDER BY priority, not_before, created_at LIMIT 1`,
519
+ this.now()
520
+ ).toArray()[0];
521
+ if (!row) return void 0;
522
+ const { created_at: createdAt, ...job } = row;
523
+ return { ...job, createdAt };
635
524
  }
636
525
  /** Earliest due time. */
637
526
  nextWakeup() {
638
- const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs WHERE suspended != 2").toArray()[0];
527
+ const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
639
528
  return row?.at ?? void 0;
640
529
  }
641
530
  /** Removes a finished job unless it was enqueued again while running. */
@@ -660,47 +549,35 @@ var QueueStore = class {
660
549
  deferJob(job, delayMs = 0) {
661
550
  this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
662
551
  }
663
- /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
664
- holdJob(job) {
552
+ /**
553
+ * Makes a job due again after `delayMs`, unless it was queued again while it ran (it is due
554
+ * then), clearing its failures.
555
+ */
556
+ postponeJob(job, delayMs) {
665
557
  this.sql.exec(
666
- `UPDATE jobs SET suspended = CASE WHEN suspended = 0 THEN 1 ELSE 2 END,
667
- not_before = ?, attempts = 0 WHERE key = ? AND version = ?`,
668
- this.now() + 1e3,
558
+ "UPDATE jobs SET not_before = ?, attempts = 0 WHERE key = ? AND version = ?",
559
+ this.now() + delayMs,
669
560
  job.key,
670
561
  job.version
671
562
  );
672
563
  }
673
- /** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
674
- wakeHeldJobs() {
675
- this.sql.exec("UPDATE jobs SET suspended = 0, not_before = ? WHERE suspended = 2", this.now());
676
- }
677
564
  prune() {
678
565
  this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
679
566
  }
680
567
  };
681
568
 
682
- // src/router.ts
683
- import { DurableObject } from "cloudflare:workers";
684
- import { z as z6 } from "zod";
685
-
686
569
  // src/routing.ts
687
570
  import ipRegex from "ip-regex";
688
571
  import { z as z4 } from "zod";
689
572
 
690
- // ../../shared/concurrent.ts
691
- async function parallel(...reads) {
692
- await Promise.allSettled(reads);
693
- return Promise.all(reads);
694
- }
695
-
696
573
  // src/reply.ts
697
574
  function confirmedReply(message, botId) {
698
575
  return message !== void 0 && Number.isSafeInteger(message.id) && message.id > 0 && message.message_type === 1 && message.private === false && message.sender?.type === "agent_bot" && message.sender.id === botId && message.status !== "failed" && !message.content_attributes?.deleted;
699
576
  }
700
- async function replyHistory(chatwoot, accountId2, conversationId, botId, freshPages = []) {
577
+ async function replyHistory(chatwoot, accountId2, conversationId, botId) {
701
578
  let before;
702
579
  for (let page = 0; page < 5; page += 1) {
703
- const messages = freshPages[page] ?? await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
580
+ const messages = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
704
581
  for (const message of messages) {
705
582
  if (confirmedReply(message, botId)) return "found";
706
583
  if (message.message_type !== 1 || message.private === true) continue;
@@ -778,14 +655,12 @@ var ActivityPendingError = class extends Error {
778
655
  };
779
656
  async function readTurn(chatwoot, store, accountId2, conversationId) {
780
657
  const messages = [];
781
- const pages = [];
782
658
  let before;
783
659
  let boundary;
784
660
  let complete = false;
785
661
  let deleted = false;
786
662
  for (let page = 0; page < 5; page += 1) {
787
663
  const batch = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
788
- pages.push(batch);
789
664
  for (const message of batch.toReversed()) {
790
665
  if (message.message_type === 2 && message.content_attributes?.deleted) {
791
666
  deleted = true;
@@ -823,7 +698,7 @@ async function readTurn(chatwoot, store, accountId2, conversationId) {
823
698
  }
824
699
  if (deleted || missing || !complete) guard.handoff = true;
825
700
  saveGuard(store, accountId2, conversationId, guard);
826
- return { pages, messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
701
+ return { messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
827
702
  }
828
703
 
829
704
  // src/routing.ts
@@ -913,8 +788,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
913
788
  return;
914
789
  }
915
790
  const conversation = toRelayConversation(conversationId, raw);
916
- if (conversation.assignee) return;
917
- if (raw.meta?.assignee && (raw.meta.assignee_type !== "AgentBot" || raw.meta.assignee.id !== botId)) return;
791
+ if (raw.meta?.assignee_type === "AgentBot" && raw.meta.assignee?.id !== botId) return;
918
792
  if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId) {
919
793
  if (owns(raw)) {
920
794
  const latest = await chatwoot.getConversation(accountId2, conversationId);
@@ -953,9 +827,8 @@ async function routeConversation(ctx, accountId2, conversationId) {
953
827
  const current2 = await snapshot(initial.raw.inbox_id);
954
828
  if (!current2) return;
955
829
  const latest = await readTurn(chatwoot, store, accountId2, conversationId);
956
- if (latest.boundary !== turn.boundary || inputs(latest.messages).key !== input.key || inputs(latest.messages).text !== input.text)
957
- return "defer";
958
- return { ...current2, pages: latest.pages, handoff: latest.handoff || requiresHandoff(latest.messages) };
830
+ if (latest.boundary !== turn.boundary || inputs(latest.messages).key !== input.key) return "defer";
831
+ return { ...current2, handoff: latest.handoff || requiresHandoff(latest.messages) };
959
832
  };
960
833
  const handoff = async (current2) => {
961
834
  requestHandoff(store, accountId2, conversationId);
@@ -965,12 +838,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
965
838
  return void 0;
966
839
  };
967
840
  if (turn.handoff || requiresHandoff(turn.messages) || input.count === 0) return handoff();
968
- const fingerprint = await crypto.subtle.digest(
969
- "SHA-256",
970
- new TextEncoder().encode(JSON.stringify({ text: input.text, routing }))
971
- );
972
- const hash = Array.from(new Uint8Array(fingerprint), (byte) => byte.toString(16).padStart(2, "0")).join("");
973
- const memoKey = `decision:${accountId2}:${conversationId}:${input.key}:${hash}`;
841
+ const memoKey = `decision:${accountId2}:${conversationId}:${input.key}`;
974
842
  const memo = decisionSchema.safeParse(parseJson(store.get(memoKey)));
975
843
  let decision = memo.success ? memo.data : void 0;
976
844
  if (!decision) {
@@ -979,18 +847,10 @@ async function routeConversation(ctx, accountId2, conversationId) {
979
847
  }
980
848
  const kind = decision.kind && decision.kindConfidence >= routing.minConfidence ? kinds[decision.kind] : void 0;
981
849
  const assignee = decision.owner && decision.ownerConfidence >= routing.minConfidence ? owners[decision.owner]?.assignee : void 0;
982
- const replyKey = `reply:${accountId2}:${conversationId}`;
983
- if (store.get(replyKey) === void 0 && turn.pages.some((page) => page.some((message) => confirmedReply(message, botId))))
984
- store.set(replyKey, "observed");
985
- const actionable = kind !== void 0 || !decision.noRequest || input.count >= MAX_MESSAGES;
986
- const [content, agents] = await parallel(
987
- kind?.cannedResponse && store.get(replyKey) === void 0 ? chatwoot.cannedResponse(accountId2, kind.cannedResponse) : Promise.resolve(void 0),
988
- actionable && assignee !== void 0 && !kind?.status ? chatwoot.listAgents(accountId2) : Promise.resolve(void 0)
989
- );
990
850
  let current = await fresh();
991
851
  if (current === "defer" || !current) return current;
992
852
  if (current.handoff) return handoff(current);
993
- if (!kind && decision.noRequest && input.count < MAX_MESSAGES) return;
853
+ if (!kind && decision.noRequest && input.count < MAX_MESSAGES && !current.conversation.assignee) return;
994
854
  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;
995
855
  const labels = [
996
856
  .../* @__PURE__ */ new Set([
@@ -998,21 +858,12 @@ async function routeConversation(ctx, accountId2, conversationId) {
998
858
  ...[topic, kind ? decision.kind : null].filter((label) => label !== null)
999
859
  ])
1000
860
  ];
1001
- let changed = false;
1002
- if (labels.length !== current.conversation.labels.length) {
1003
- await bot.setLabels(accountId2, conversationId, labels);
1004
- changed = true;
1005
- }
861
+ if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
862
+ const replyKey = `reply:${accountId2}:${conversationId}`;
1006
863
  if (kind?.cannedResponse) {
1007
864
  let history;
1008
865
  try {
1009
- if (changed) {
1010
- current = await fresh();
1011
- if (current === "defer" || !current) return current;
1012
- if (current.handoff) return handoff(current);
1013
- changed = false;
1014
- }
1015
- history = await replyHistory(chatwoot, accountId2, conversationId, botId, current.pages);
866
+ history = await replyHistory(chatwoot, accountId2, conversationId, botId);
1016
867
  } catch (error) {
1017
868
  log.warn("reply history unavailable; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1018
869
  return handoff();
@@ -1020,6 +871,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
1020
871
  if (history === "unknown" || history === "complete-none" && store.get(replyKey) !== void 0) return handoff();
1021
872
  if (history === "found" && store.get(replyKey) === void 0) store.set(replyKey, "observed");
1022
873
  if (history === "complete-none") {
874
+ const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
1023
875
  if (!content?.trim()) return handoff();
1024
876
  current = await fresh();
1025
877
  if (current === "defer" || !current) return current;
@@ -1028,7 +880,6 @@ async function routeConversation(ctx, accountId2, conversationId) {
1028
880
  try {
1029
881
  const message = await bot.createMessage(accountId2, conversationId, { content, private: false, files: [] });
1030
882
  if (!confirmedReply(message, botId) || message?.conversation_id !== conversationId) return handoff();
1031
- changed = true;
1032
883
  } catch (error) {
1033
884
  log.warn("reply creation unconfirmed; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1034
885
  return handoff();
@@ -1036,13 +887,12 @@ async function routeConversation(ctx, accountId2, conversationId) {
1036
887
  }
1037
888
  }
1038
889
  if (assignee !== void 0 && !kind?.status) {
1039
- if (!agents?.some((agent) => agent.id === assignee)) return handoff();
1040
- }
1041
- if (changed) {
1042
- current = await fresh();
1043
- if (current === "defer" || !current) return current;
1044
- if (current.handoff) return handoff(current);
890
+ const agents = await chatwoot.listAgents(accountId2);
891
+ if (!agents.some((agent) => agent.id === assignee)) return handoff();
1045
892
  }
893
+ current = await fresh();
894
+ if (current === "defer" || !current) return current;
895
+ if (current.handoff) return handoff(current);
1046
896
  if (kind?.status) {
1047
897
  await bot.setStatus(accountId2, conversationId, { status: kind.status });
1048
898
  expectActivity(store, accountId2, conversationId, { status: kind.status });
@@ -1112,10 +962,7 @@ async function decide(ctx, owners, kinds, text2) {
1112
962
  body: JSON.stringify({ model: routing.model, state: { ticket: text2 }, questions })
1113
963
  })
1114
964
  );
1115
- if (!response.ok) {
1116
- await response.body?.cancel();
1117
- throw new JevError(`HTTP ${response.status}`);
1118
- }
965
+ if (!response.ok) throw new JevError(`HTTP ${response.status}`);
1119
966
  const parsed = jevResponseSchema.safeParse(await response.json().catch(() => void 0));
1120
967
  if (!parsed.success) throw new JevError("invalid response");
1121
968
  const answer = (question) => {
@@ -1157,7 +1004,7 @@ async function readConfig(env) {
1157
1004
  if (env.CONFIG_KEY === void 0) return typeof env.CONFIG === "string" ? parseJson(env.CONFIG) : env.CONFIG;
1158
1005
  if (env.CONFIG !== void 0) throw new ConfigError("Invalid CONFIG: set either CONFIG or CONFIG_KEY");
1159
1006
  if (!env.CONFIG_STORE) throw new ConfigError("Invalid CONFIG_KEY: requires the CONFIG_STORE KV namespace");
1160
- const config = await within(env.CONFIG_STORE.get(env.CONFIG_KEY, "json"), AbortSignal.timeout(1500));
1007
+ const config = await env.CONFIG_STORE.get(env.CONFIG_KEY, "json");
1161
1008
  if (config === null) throw new ConfigError("Invalid CONFIG_KEY: not in CONFIG_STORE");
1162
1009
  return config;
1163
1010
  }
@@ -1263,14 +1110,19 @@ function buildSettings(config, secrets) {
1263
1110
  var loadSettings = settingsLoader(parseSettings);
1264
1111
 
1265
1112
  // src/router.ts
1266
- function conversationName(accountId2, conversationId) {
1267
- return `${accountId2}:${conversationId}`;
1113
+ var SWEEP_NAME = "global";
1114
+ var BUDGET = { route: 45, sweep: 1 + CONVERSATIONS_PER_PAGE };
1115
+ function sweeper(env) {
1116
+ return env.ROUTER.getByName(SWEEP_NAME);
1117
+ }
1118
+ function conversationRouter(env, accountId2, conversationId) {
1119
+ return env.ROUTER.getByName(`${accountId2}:${conversationId}`);
1268
1120
  }
1269
- var ROUTE_BUDGET = 45;
1270
1121
  var RUN_WALL_MS = 5 * 60 * 1e3;
1271
1122
  var id = z6.number().int().positive();
1272
1123
  var jobSchema = z6.discriminatedUnion("type", [
1273
- z6.object({ type: z6.literal("route"), accountId: id, conversationId: id })
1124
+ z6.object({ type: z6.literal("route"), accountId: id, conversationId: id }),
1125
+ z6.object({ type: z6.literal("sweep"), accountId: id, status: z6.enum(["pending", "open"]) })
1274
1126
  ]);
1275
1127
  var Router = class extends DurableObject {
1276
1128
  store;
@@ -1284,7 +1136,14 @@ var Router = class extends DurableObject {
1284
1136
  if (!routesAccount(settings, accountId2)) return;
1285
1137
  if (transition) expectActivity(this.store, accountId2, conversationId, transition);
1286
1138
  this.enqueue({ type: "route", accountId: accountId2, conversationId });
1287
- await scheduleAlarm(this.ctx, this.store.nextWakeup());
1139
+ await this.schedule();
1140
+ }
1141
+ async requestSweep() {
1142
+ for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1143
+ for (const status of ["pending", "open"])
1144
+ this.enqueue({ type: "sweep", accountId: Number(accountId2), status });
1145
+ }
1146
+ await this.schedule();
1288
1147
  }
1289
1148
  async alarm() {
1290
1149
  const settings = await loadSettings(this.env);
@@ -1301,18 +1160,28 @@ var Router = class extends DurableObject {
1301
1160
  continue;
1302
1161
  }
1303
1162
  const payload = parsed.data;
1304
- if (budget.remaining < ROUTE_BUDGET || Date.now() - started > RUN_WALL_MS) {
1163
+ if (budget.remaining < BUDGET[payload.type] || Date.now() - started > RUN_WALL_MS) {
1305
1164
  yielded = true;
1306
1165
  break;
1307
1166
  }
1308
1167
  try {
1309
- const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetchWith(JOB_SLICE_MS) };
1310
- if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1311
- this.store.deferJob(job);
1312
- yielded = true;
1313
- break;
1168
+ const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetch };
1169
+ if (payload.type === "route") {
1170
+ if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1171
+ this.store.deferJob(job);
1172
+ yielded = true;
1173
+ break;
1174
+ }
1175
+ clearFailures(this.store, payload.accountId, payload.conversationId);
1176
+ } else if (routesAccount(settings, payload.accountId)) {
1177
+ await this.sweep(
1178
+ chatwoot,
1179
+ budget,
1180
+ payload.accountId,
1181
+ payload.status,
1182
+ settings.config.routing.botIds[String(payload.accountId)]
1183
+ );
1314
1184
  }
1315
- clearFailures(this.store, payload.accountId, payload.conversationId);
1316
1185
  this.store.completeJob(job);
1317
1186
  } catch (error) {
1318
1187
  if (error instanceof ChatwootError && error.conversationMissing) {
@@ -1325,7 +1194,7 @@ var Router = class extends DurableObject {
1325
1194
  yielded = true;
1326
1195
  break;
1327
1196
  }
1328
- const attempts = recordFailure(this.store, payload.accountId, payload.conversationId);
1197
+ const attempts = payload.type === "route" ? recordFailure(this.store, payload.accountId, payload.conversationId) : job.attempts + 1;
1329
1198
  const delay = retryDelay(attempts - 1);
1330
1199
  const logAt = attempts >= 3 ? log.error : log.warn;
1331
1200
  logAt("job failed; will retry", {
@@ -1337,126 +1206,62 @@ var Router = class extends DurableObject {
1337
1206
  this.store.retryJob(job, delay);
1338
1207
  }
1339
1208
  }
1340
- await scheduleAlarm(this.ctx, yielded ? Date.now() : this.store.nextWakeup());
1209
+ await this.schedule(yielded ? Date.now() : void 0);
1210
+ }
1211
+ async sweep(chatwoot, budget, accountId2, status, botId) {
1212
+ const key = `sweep:${accountId2}:${status}`;
1213
+ const saved = id.safeParse(parseJson(this.store.get(key)));
1214
+ const page = saved.success ? saved.data : 1;
1215
+ const conversations = await chatwoot.listConversations(accountId2, page, status);
1216
+ const routed = conversations.flatMap(
1217
+ (conversation) => conversation.id !== void 0 && (status === "pending" || conversation.meta?.assignee_type === "AgentBot" && conversation.meta.assignee?.id === botId) ? [conversation.id] : []
1218
+ );
1219
+ for (const _ of routed) budget.consume();
1220
+ const results = await Promise.allSettled(
1221
+ routed.map(
1222
+ (conversationId) => conversationRouter(this.env, accountId2, conversationId).enqueueConversation(accountId2, conversationId)
1223
+ )
1224
+ );
1225
+ results.forEach((result, index) => {
1226
+ if (result.status === "rejected")
1227
+ log.warn("sweep could not hand over a conversation", {
1228
+ accountId: accountId2,
1229
+ conversationId: routed[index],
1230
+ ...errorFields(result.reason)
1231
+ });
1232
+ });
1233
+ if (conversations.length === 0) {
1234
+ this.store.delete(key);
1235
+ return;
1236
+ }
1237
+ this.store.set(key, String(page + 1));
1238
+ this.enqueue({ type: "sweep", accountId: accountId2, status });
1341
1239
  }
1342
1240
  enqueue(payload) {
1343
- this.store.enqueue(`route:${payload.accountId}:${payload.conversationId}`, 0, JSON.stringify(payload));
1241
+ const key = payload.type === "sweep" ? `sweep:${payload.accountId}:${payload.status}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
1242
+ this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
1344
1243
  }
1345
- };
1346
-
1347
- // src/coordinator.ts
1348
- var COORDINATOR_NAME = "global";
1349
- var sweepSchema = z7.object({ accountId: z7.number().int().positive(), status: z7.enum(["pending", "open"]) });
1350
- var pageSchema = z7.object({
1351
- page: z7.number().int().positive()
1352
- });
1353
- var deliverySchema = z7.object({
1354
- accountId: z7.number().int().positive(),
1355
- conversationId: z7.number().int().positive()
1356
- });
1357
- var Coordinator = class extends DurableObject2 {
1358
- store;
1359
- constructor(ctx, env) {
1360
- super(ctx, env);
1361
- this.store = new QueueStore(ctx.storage.sql);
1362
- this.store.migrate();
1363
- }
1364
- async requestSweep() {
1365
- for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1366
- for (const status of ["pending", "open"]) {
1367
- this.store.enqueue(`sweep:${accountId2}:${status}`, 0, JSON.stringify({ accountId: Number(accountId2), status }));
1368
- }
1369
- }
1370
- await scheduleAlarm(this.ctx, this.store.nextWakeup());
1371
- }
1372
- async alarm() {
1373
- const settings = await loadSettings(this.env);
1374
- const budget = new Budget(settings.config.subrequestBudget);
1375
- budget.startSlice();
1376
- const job = this.store.nextDueJobs(1, "sweep:")[0];
1377
- if (job) {
1378
- const parsed = sweepSchema.safeParse(parseJson(job.payload));
1379
- if (!parsed.success) this.store.deleteJob(job.key);
1380
- else {
1381
- try {
1382
- const { accountId: accountId2, status } = parsed.data;
1383
- const chatwoot = chatwootClient(
1384
- settings.config.chatwoot.baseUrl,
1385
- settings.secrets.CHATWOOT_TOKEN,
1386
- budget.fetch
1387
- );
1388
- const saved = pageSchema.safeParse(parseJson(this.store.get(job.key)));
1389
- const checkpoint = saved.success ? saved.data : { page: 1 };
1390
- const botId = settings.config.routing.botIds[String(accountId2)];
1391
- const conversations = await chatwoot.listConversations(accountId2, checkpoint.page, status);
1392
- this.ctx.storage.transactionSync(() => {
1393
- for (const conversation of conversations) {
1394
- if (conversation.id !== void 0 && (status === "pending" || conversation.meta?.assignee_type === "AgentBot" && conversation.meta.assignee?.id === botId))
1395
- this.store.enqueue(
1396
- `delivery:${accountId2}:${conversation.id}`,
1397
- 1,
1398
- JSON.stringify({ accountId: accountId2, conversationId: conversation.id })
1399
- );
1400
- }
1401
- if (conversations.length === 0) {
1402
- this.store.delete(job.key);
1403
- this.store.completeJob(job);
1404
- } else {
1405
- this.store.set(job.key, JSON.stringify({ page: checkpoint.page + 1 }));
1406
- this.store.deferJob(job);
1407
- }
1408
- });
1409
- } catch (error) {
1410
- this.store.retryJob(job, retryDelay(job.attempts));
1411
- log.warn("sweep page failed; will retry", { job: job.key, ...errorFields(error) });
1412
- }
1413
- }
1414
- }
1415
- const deliveries = this.store.nextDueJobs(12, "delivery:");
1416
- for (let start = 0; start < deliveries.length; start += 3) {
1417
- if (budget.remaining < 3) break;
1418
- await Promise.all(
1419
- deliveries.slice(start, start + 3).map(async (child) => {
1420
- const target = deliverySchema.safeParse(parseJson(child.payload));
1421
- if (!target.success) {
1422
- this.store.deleteJob(child.key);
1423
- return;
1424
- }
1425
- const { accountId: accountId2, conversationId } = target.data;
1426
- try {
1427
- budget.consume();
1428
- await within(
1429
- this.env.ROUTER.getByName(conversationName(accountId2, conversationId)).enqueueConversation(
1430
- accountId2,
1431
- conversationId
1432
- ),
1433
- budget.controlSignal(200)
1434
- );
1435
- this.store.completeJob(child);
1436
- } catch (error) {
1437
- this.store.retryJob(child, retryDelay(child.attempts));
1438
- log.warn("sweep child delivery delayed", { accountId: accountId2, conversationId, ...errorFields(error) });
1439
- }
1440
- })
1441
- );
1442
- }
1443
- await scheduleAlarm(this.ctx, this.store.nextWakeup());
1244
+ async schedule(at) {
1245
+ const next = at ?? this.store.nextWakeup();
1246
+ if (next === void 0) return;
1247
+ const current = await this.ctx.storage.getAlarm();
1248
+ if (current === null || current > next) await this.ctx.storage.setAlarm(next);
1444
1249
  }
1445
1250
  };
1446
1251
 
1447
1252
  // src/webhook.ts
1448
- import { z as z8 } from "zod";
1449
- var id2 = z8.number().int().positive();
1450
- var eventSchema = z8.object({
1451
- event: z8.string(),
1452
- account: z8.object({ id: id2 }),
1253
+ import { z as z7 } from "zod";
1254
+ var id2 = z7.number().int().positive();
1255
+ var eventSchema = z7.object({
1256
+ event: z7.string(),
1257
+ account: z7.object({ id: id2 }),
1453
1258
  id: id2.optional(),
1454
- conversation: z8.object({ id: id2 }).optional(),
1455
- message_type: z8.union([z8.string(), z8.number()]).optional(),
1456
- private: z8.boolean().optional(),
1457
- updated_at: z8.number().optional(),
1458
- changed_attributes: z8.array(z8.object({ status: z8.object({ previous_value: z8.string(), current_value: z8.string() }).optional() })).optional(),
1459
- sender: z8.object({ type: z8.string().optional() }).nullish()
1259
+ conversation: z7.object({ id: id2 }).optional(),
1260
+ message_type: z7.union([z7.string(), z7.number()]).optional(),
1261
+ private: z7.boolean().optional(),
1262
+ updated_at: z7.number().optional(),
1263
+ changed_attributes: z7.array(z7.object({ status: z7.object({ previous_value: z7.string(), current_value: z7.string() }).optional() })).optional(),
1264
+ sender: z7.object({ type: z7.string().optional() }).nullish()
1460
1265
  });
1461
1266
  var CONVERSATION_EVENTS = /* @__PURE__ */ new Set([
1462
1267
  "conversation_opened",
@@ -1520,13 +1325,10 @@ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (
1520
1325
  if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1521
1326
  return context.text("account does not match the webhook secret", 403);
1522
1327
  }
1523
- await within(
1524
- context.env.ROUTER.getByName(conversationName(target.accountId, target.conversationId)).enqueueConversation(
1525
- target.accountId,
1526
- target.conversationId,
1527
- target.transition
1528
- ),
1529
- AbortSignal.timeout(METADATA_TIMEOUT_MS)
1328
+ await conversationRouter(context.env, target.accountId, target.conversationId).enqueueConversation(
1329
+ target.accountId,
1330
+ target.conversationId,
1331
+ target.transition
1530
1332
  );
1531
1333
  return context.json({ ok: true });
1532
1334
  });
@@ -1538,13 +1340,10 @@ app.onError((error, context) => {
1538
1340
  var src_default = {
1539
1341
  fetch: app.fetch,
1540
1342
  async scheduled(_controller, env, context) {
1541
- context.waitUntil(
1542
- within(env.COORDINATOR.getByName(COORDINATOR_NAME).requestSweep(), AbortSignal.timeout(METADATA_TIMEOUT_MS))
1543
- );
1343
+ context.waitUntil(sweeper(env).requestSweep());
1544
1344
  }
1545
1345
  };
1546
1346
  export {
1547
- Coordinator,
1548
1347
  Router,
1549
1348
  src_default as default
1550
1349
  };