chatwoot-router 0.2.0 → 0.3.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,6 +2,118 @@
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
+
5
117
  // ../../shared/chatwoot/signature.ts
6
118
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
7
119
  var encoder = new TextEncoder();
@@ -65,37 +177,16 @@ function errorFields(error) {
65
177
  return error instanceof Error ? { error: error.name, detail: error.message } : { error: String(error) };
66
178
  }
67
179
 
68
- // src/router.ts
69
- import { DurableObject } from "cloudflare:workers";
70
- import { z as z6 } from "zod";
180
+ // src/coordinator.ts
181
+ import { DurableObject as DurableObject2 } from "cloudflare:workers";
182
+ import { z as z7 } from "zod";
71
183
 
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
- fetch = (request) => {
94
- if (this.used >= this.limit) return Promise.reject(new BudgetExhaustedError());
95
- this.used += 1;
96
- return this.fetchImpl(new Request(request, { signal: AbortSignal.timeout(this.timeoutMs) }));
97
- };
98
- };
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
+ }
99
190
 
100
191
  // ../../shared/chatwoot/api.ts
101
192
  import createClient from "openapi-fetch";
@@ -103,11 +194,13 @@ import { z as z2 } from "zod";
103
194
  var ChatwootError = class extends Error {
104
195
  status;
105
196
  conversationMissing;
106
- constructor(status, operation, conversationMissing = false) {
197
+ retryAfterMs;
198
+ constructor(status, operation, conversationMissing = false, retryAfterMs) {
107
199
  super(`Chatwoot ${operation} failed with HTTP ${status}`);
108
200
  this.name = "ChatwootError";
109
201
  this.status = status;
110
202
  this.conversationMissing = conversationMissing;
203
+ this.retryAfterMs = retryAfterMs;
111
204
  }
112
205
  };
113
206
  var text = z2.string().nullish();
@@ -136,6 +229,7 @@ var emailSchema = z2.object({
136
229
  });
137
230
  var messageSchema = z2.object({
138
231
  id: z2.number(),
232
+ conversation_id: z2.number().optional(),
139
233
  content: text,
140
234
  message_type: z2.number(),
141
235
  content_type: text,
@@ -162,13 +256,29 @@ var MESSAGE_HISTORY_PAGE_SIZE = 20;
162
256
  var inboxBotSchema = z2.object({
163
257
  agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
164
258
  });
165
- function chatwootClient(baseUrl, token, fetch2) {
259
+ function chatwootClient(baseUrl, token, fetch2, limits) {
166
260
  const client = createClient({
167
261
  baseUrl: baseUrl.replace(/\/+$/, ""),
168
262
  headers: { api_access_token: token },
169
263
  // A redirect is an error, never followed: it could carry the token to another host, and each
170
264
  // hop would be a subrequest the budget does not count. Configure Chatwoot's final URL.
171
- fetch: (request) => fetch2(new Request(request, { redirect: "manual" }))
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
+ }
172
282
  });
173
283
  async function data(operation, pending) {
174
284
  const { data: data2, response } = await pending;
@@ -197,15 +307,6 @@ function chatwootClient(baseUrl, token, fetch2) {
197
307
  );
198
308
  return list && messageListSchema.parse(list).payload.toSorted((a, b) => a.id - b.id);
199
309
  }
200
- function setCustomAttributes(accountId2, conversationId, attributes) {
201
- return ensureOk(
202
- "set custom attribute",
203
- client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
204
- params: { path: { account_id: accountId2, conversation_id: conversationId } },
205
- body: { custom_attributes: attributes, merge: true }
206
- })
207
- );
208
- }
209
310
  return {
210
311
  /** The conversation, or undefined when it does not exist (it was deleted). */
211
312
  getConversation(accountId2, conversationId) {
@@ -231,15 +332,6 @@ function chatwootClient(baseUrl, token, fetch2) {
231
332
  const messages = await listMessages(accountId2, conversationId, { after: messageId - 1, before: messageId + 1 });
232
333
  return messages?.find((message) => message.id === messageId);
233
334
  },
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
335
  async inboxBot(accountId2, inboxId) {
244
336
  const value = await dataUnlessNotFound(
245
337
  "get inbox agent bot",
@@ -375,13 +467,21 @@ function chatwootClient(baseUrl, token, fetch2) {
375
467
  })
376
468
  );
377
469
  },
378
- setCustomAttributes,
470
+ setCustomAttributes(accountId2, conversationId, attributes) {
471
+ return ensureOk(
472
+ "set custom attribute",
473
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
474
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
475
+ body: { custom_attributes: attributes, merge: true }
476
+ })
477
+ );
478
+ },
379
479
  /**
380
480
  * Sends an outgoing message (or private note). With files it is sent as the spec's
381
481
  * multipart/form-data body; the generated type renders its `attachments[]` binaries as
382
482
  * strings, so the serializer appends the files itself.
383
483
  */
384
- createMessage(accountId2, conversationId, message) {
484
+ async createMessage(accountId2, conversationId, message) {
385
485
  const fields = {
386
486
  content: message.content,
387
487
  message_type: "outgoing",
@@ -397,14 +497,17 @@ function chatwootClient(baseUrl, token, fetch2) {
397
497
  for (const file of message.files) form.append("attachments[]", file.blob, file.filename);
398
498
  return form;
399
499
  };
400
- return ensureOk(
401
- "create message",
402
- client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages", {
500
+ const { data: data2, response } = await client.POST(
501
+ "/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages",
502
+ {
403
503
  params: { path: { account_id: accountId2, conversation_id: conversationId } },
404
504
  body: fields,
405
505
  ...message.files.length === 0 ? {} : { bodySerializer: () => multipart(fields) }
406
- })
506
+ }
407
507
  );
508
+ if (!response.ok) throw new ChatwootError(response.status, "create message", notFound(response));
509
+ const parsed = messageSchema.safeParse(data2);
510
+ return parsed.success ? parsed.data : void 0;
408
511
  }
409
512
  };
410
513
  }
@@ -456,12 +559,17 @@ function retryDelay(attempts) {
456
559
  return Math.min(5e3 * 2 ** attempts, 30 * 60 * 1e3);
457
560
  }
458
561
  var QueueStore = class {
459
- constructor(sql, now = Date.now) {
562
+ constructor(sql, now = Date.now, atomic = (write2) => write2()) {
460
563
  this.sql = sql;
461
564
  this.now = now;
565
+ this.atomic = atomic;
462
566
  }
463
567
  sql;
464
568
  now;
569
+ atomic;
570
+ transaction(write2) {
571
+ return this.atomic(write2);
572
+ }
465
573
  migrate() {
466
574
  this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
467
575
  key TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL,
@@ -510,14 +618,20 @@ var QueueStore = class {
510
618
  );
511
619
  }
512
620
  nextDueJob() {
513
- const row = this.sql.exec(
514
- `SELECT key, payload, version, attempts, created_at FROM jobs WHERE suspended != 2 AND not_before <= ?
515
- ORDER BY priority, not_before, created_at LIMIT 1`,
516
- this.now()
517
- ).toArray()[0];
518
- if (!row) return void 0;
519
- const { created_at: createdAt, ...job } = row;
520
- return { ...job, createdAt };
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();
521
635
  }
522
636
  /** Earliest due time. */
523
637
  nextWakeup() {
@@ -565,10 +679,42 @@ var QueueStore = class {
565
679
  }
566
680
  };
567
681
 
682
+ // src/router.ts
683
+ import { DurableObject } from "cloudflare:workers";
684
+ import { z as z6 } from "zod";
685
+
568
686
  // src/routing.ts
569
687
  import ipRegex from "ip-regex";
570
688
  import { z as z4 } from "zod";
571
689
 
690
+ // ../../shared/concurrent.ts
691
+ async function parallel(...reads) {
692
+ await Promise.allSettled(reads);
693
+ return Promise.all(reads);
694
+ }
695
+
696
+ // src/reply.ts
697
+ function confirmedReply(message, botId) {
698
+ 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
+ }
700
+ async function replyHistory(chatwoot, accountId2, conversationId, botId, freshPages = []) {
701
+ let before;
702
+ for (let page = 0; page < 5; page += 1) {
703
+ const messages = freshPages[page] ?? await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
704
+ for (const message of messages) {
705
+ if (confirmedReply(message, botId)) return "found";
706
+ if (message.message_type !== 1 || message.private === true) continue;
707
+ if (message.sender?.type == null || message.sender.type === "agent_bot" && (message.sender.id == null || message.sender.id === botId))
708
+ return "unknown";
709
+ }
710
+ if (messages.length < MESSAGE_HISTORY_PAGE_SIZE) return "complete-none";
711
+ const next = messages[0]?.id;
712
+ if (next === void 0 || before !== void 0 && next >= before) return "unknown";
713
+ before = next;
714
+ }
715
+ return "unknown";
716
+ }
717
+
572
718
  // src/turn.ts
573
719
  import { z as z3 } from "zod";
574
720
  var guardSchema = z3.object({
@@ -632,12 +778,14 @@ var ActivityPendingError = class extends Error {
632
778
  };
633
779
  async function readTurn(chatwoot, store, accountId2, conversationId) {
634
780
  const messages = [];
781
+ const pages = [];
635
782
  let before;
636
783
  let boundary;
637
784
  let complete = false;
638
785
  let deleted = false;
639
786
  for (let page = 0; page < 5; page += 1) {
640
787
  const batch = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
788
+ pages.push(batch);
641
789
  for (const message of batch.toReversed()) {
642
790
  if (message.message_type === 2 && message.content_attributes?.deleted) {
643
791
  deleted = true;
@@ -675,7 +823,7 @@ async function readTurn(chatwoot, store, accountId2, conversationId) {
675
823
  }
676
824
  if (deleted || missing || !complete) guard.handoff = true;
677
825
  saveGuard(store, accountId2, conversationId, guard);
678
- return { messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
826
+ return { pages, messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
679
827
  }
680
828
 
681
829
  // src/routing.ts
@@ -749,17 +897,33 @@ async function routeConversation(ctx, accountId2, conversationId) {
749
897
  const bot = chatwootClient(settings.config.chatwoot.baseUrl, token, ctx.fetch);
750
898
  const kinds = routing.kinds?.[String(accountId2)] ?? {};
751
899
  const botId = routing.botIds[String(accountId2)];
900
+ const owns = (conversation) => conversation?.meta?.assignee_type === "AgentBot" && conversation.meta.assignee?.id === botId;
901
+ const release = async () => {
902
+ const latest = await chatwoot.getConversation(accountId2, conversationId);
903
+ if (latest?.status !== void 0 && latest.status !== "pending" && owns(latest)) {
904
+ await bot.unassign(accountId2, conversationId);
905
+ }
906
+ };
752
907
  const snapshot = async (inboxId) => {
753
908
  const raw = await chatwoot.getConversation(accountId2, conversationId);
754
909
  if (!raw || raw.inbox_id === void 0 || inboxId !== void 0 && raw.inbox_id !== inboxId) return;
755
910
  if (raw.status !== "pending") {
756
911
  observeStatus(store, accountId2, conversationId, raw.status ?? "open");
912
+ if (raw.status !== void 0 && owns(raw)) await release();
757
913
  return;
758
914
  }
759
915
  const conversation = toRelayConversation(conversationId, raw);
760
- if (conversation.contact.blocked || conversation.assignee) return;
916
+ if (conversation.assignee) return;
761
917
  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;
918
+ if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId) {
919
+ if (owns(raw)) {
920
+ const latest = await chatwoot.getConversation(accountId2, conversationId);
921
+ if (latest?.status === "pending" && latest.inbox_id === raw.inbox_id && owns(latest))
922
+ await bot.setStatus(accountId2, conversationId, { status: "open" });
923
+ }
924
+ return;
925
+ }
926
+ if (conversation.contact.blocked) return;
763
927
  observeStatus(store, accountId2, conversationId, "pending");
764
928
  return { raw, conversation };
765
929
  };
@@ -782,15 +946,16 @@ async function routeConversation(ctx, accountId2, conversationId) {
782
946
  };
783
947
  const turn = await readTurn(chatwoot, store, accountId2, conversationId);
784
948
  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
949
+ const requiresHandoff = (messages) => messages.some(
950
+ (message) => message.message_type === 1 && !message.private && (message.sender?.type === "user" && !message.content_attributes?.deleted || message.sender?.type === "agent_bot" && message.sender.id === botId && (message.status === "failed" || message.content_attributes?.deleted))
787
951
  );
788
952
  const fresh = async () => {
789
953
  const current2 = await snapshot(initial.raw.inbox_id);
790
954
  if (!current2) return;
791
955
  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) };
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) };
794
959
  };
795
960
  const handoff = async (current2) => {
796
961
  requestHandoff(store, accountId2, conversationId);
@@ -799,15 +964,29 @@ async function routeConversation(ctx, accountId2, conversationId) {
799
964
  if (current2) await bot.setStatus(accountId2, conversationId, { status: "open" });
800
965
  return void 0;
801
966
  };
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));
967
+ 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}`;
974
+ const memo = decisionSchema.safeParse(parseJson(store.get(memoKey)));
975
+ let decision = memo.success ? memo.data : void 0;
805
976
  if (!decision) {
806
977
  decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], input.text);
807
978
  store.set(memoKey, JSON.stringify(decision));
808
979
  }
809
980
  const kind = decision.kind && decision.kindConfidence >= routing.minConfidence ? kinds[decision.kind] : void 0;
810
981
  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
+ );
811
990
  let current = await fresh();
812
991
  if (current === "defer" || !current) return current;
813
992
  if (current.handoff) return handoff(current);
@@ -819,27 +998,55 @@ async function routeConversation(ctx, accountId2, conversationId) {
819
998
  ...[topic, kind ? decision.kind : null].filter((label) => label !== null)
820
999
  ])
821
1000
  ];
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();
1001
+ let changed = false;
1002
+ if (labels.length !== current.conversation.labels.length) {
1003
+ await bot.setLabels(accountId2, conversationId, labels);
1004
+ changed = true;
1005
+ }
1006
+ if (kind?.cannedResponse) {
1007
+ let history;
1008
+ 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);
1016
+ } catch (error) {
1017
+ log.warn("reply history unavailable; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1018
+ return handoff();
1019
+ }
1020
+ if (history === "unknown" || history === "complete-none" && store.get(replyKey) !== void 0) return handoff();
1021
+ if (history === "found" && store.get(replyKey) === void 0) store.set(replyKey, "observed");
1022
+ if (history === "complete-none") {
1023
+ if (!content?.trim()) return handoff();
1024
+ current = await fresh();
1025
+ if (current === "defer" || !current) return current;
1026
+ if (current.handoff) return handoff(current);
1027
+ store.set(replyKey, "attempted");
1028
+ try {
1029
+ const message = await bot.createMessage(accountId2, conversationId, { content, private: false, files: [] });
1030
+ if (!confirmedReply(message, botId) || message?.conversation_id !== conversationId) return handoff();
1031
+ changed = true;
1032
+ } catch (error) {
1033
+ log.warn("reply creation unconfirmed; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1034
+ return handoff();
1035
+ }
1036
+ }
1037
+ }
1038
+ if (assignee !== void 0 && !kind?.status) {
1039
+ if (!agents?.some((agent) => agent.id === assignee)) return handoff();
1040
+ }
1041
+ if (changed) {
827
1042
  current = await fresh();
828
1043
  if (current === "defer" || !current) return current;
829
1044
  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
1045
  }
837
- current = await fresh();
838
- if (current === "defer" || !current) return current;
839
- if (current.handoff) return handoff(current);
840
1046
  if (kind?.status) {
841
1047
  await bot.setStatus(accountId2, conversationId, { status: kind.status });
842
1048
  expectActivity(store, accountId2, conversationId, { status: kind.status });
1049
+ await release();
843
1050
  } else if (assignee !== void 0 && current.raw.meta?.assignee_type === "AgentBot") {
844
1051
  await bot.assign(accountId2, conversationId, assignee);
845
1052
  expectActivity(store, accountId2, conversationId, { status: "open" });
@@ -905,7 +1112,10 @@ async function decide(ctx, owners, kinds, text2) {
905
1112
  body: JSON.stringify({ model: routing.model, state: { ticket: text2 }, questions })
906
1113
  })
907
1114
  );
908
- if (!response.ok) throw new JevError(`HTTP ${response.status}`);
1115
+ if (!response.ok) {
1116
+ await response.body?.cancel();
1117
+ throw new JevError(`HTTP ${response.status}`);
1118
+ }
909
1119
  const parsed = jevResponseSchema.safeParse(await response.json().catch(() => void 0));
910
1120
  if (!parsed.success) throw new JevError("invalid response");
911
1121
  const answer = (question) => {
@@ -929,10 +1139,6 @@ async function decide(ctx, owners, kinds, text2) {
929
1139
  noRequest: request.choice === "none" && request.confidence >= routing.minConfidence
930
1140
  };
931
1141
  }
932
- function readDecision(stored) {
933
- const parsed = decisionSchema.safeParse(parseJson(stored));
934
- return parsed.success ? parsed.data : void 0;
935
- }
936
1142
 
937
1143
  // ../../shared/settings.ts
938
1144
  function settingsLoader(parse) {
@@ -951,7 +1157,7 @@ async function readConfig(env) {
951
1157
  if (env.CONFIG_KEY === void 0) return typeof env.CONFIG === "string" ? parseJson(env.CONFIG) : env.CONFIG;
952
1158
  if (env.CONFIG !== void 0) throw new ConfigError("Invalid CONFIG: set either CONFIG or CONFIG_KEY");
953
1159
  if (!env.CONFIG_STORE) throw new ConfigError("Invalid CONFIG_KEY: requires the CONFIG_STORE KV namespace");
954
- const config = await env.CONFIG_STORE.get(env.CONFIG_KEY, "json");
1160
+ const config = await within(env.CONFIG_STORE.get(env.CONFIG_KEY, "json"), AbortSignal.timeout(1500));
955
1161
  if (config === null) throw new ConfigError("Invalid CONFIG_KEY: not in CONFIG_STORE");
956
1162
  return config;
957
1163
  }
@@ -1057,48 +1263,28 @@ function buildSettings(config, secrets) {
1057
1263
  var loadSettings = settingsLoader(parseSettings);
1058
1264
 
1059
1265
  // src/router.ts
1060
- var ROUTER_NAME = "global";
1266
+ function conversationName(accountId2, conversationId) {
1267
+ return `${accountId2}:${conversationId}`;
1268
+ }
1061
1269
  var ROUTE_BUDGET = 45;
1062
- var BUDGET = { route: ROUTE_BUDGET, sweep: 2 };
1063
1270
  var RUN_WALL_MS = 5 * 60 * 1e3;
1064
1271
  var id = z6.number().int().positive();
1065
1272
  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 })
1273
+ z6.object({ type: z6.literal("route"), accountId: id, conversationId: id })
1068
1274
  ]);
1069
- var passSchema = z6.object({
1070
- page: z6.number().int().positive(),
1071
- remaining: z6.array(id),
1072
- routed: z6.array(id)
1073
- });
1074
1275
  var Router = class extends DurableObject {
1075
1276
  store;
1076
1277
  constructor(context, env) {
1077
1278
  super(context, env);
1078
1279
  this.store = new QueueStore(context.storage.sql);
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
- });
1280
+ this.store.migrate();
1089
1281
  }
1090
1282
  async enqueueConversation(accountId2, conversationId, transition) {
1091
1283
  const settings = await loadSettings(this.env);
1092
1284
  if (!routesAccount(settings, accountId2)) return;
1093
1285
  if (transition) expectActivity(this.store, accountId2, conversationId, transition);
1094
1286
  this.enqueue({ type: "route", accountId: accountId2, conversationId });
1095
- await this.schedule();
1096
- }
1097
- async requestSweep() {
1098
- for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1099
- this.enqueue({ type: "sweep", accountId: Number(accountId2) });
1100
- }
1101
- await this.schedule();
1287
+ await scheduleAlarm(this.ctx, this.store.nextWakeup());
1102
1288
  }
1103
1289
  async alarm() {
1104
1290
  const settings = await loadSettings(this.env);
@@ -1115,22 +1301,18 @@ var Router = class extends DurableObject {
1115
1301
  continue;
1116
1302
  }
1117
1303
  const payload = parsed.data;
1118
- if (budget.remaining < BUDGET[payload.type] || Date.now() - started > RUN_WALL_MS) {
1304
+ if (budget.remaining < ROUTE_BUDGET || Date.now() - started > RUN_WALL_MS) {
1119
1305
  yielded = true;
1120
1306
  break;
1121
1307
  }
1122
1308
  try {
1123
- const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetch };
1124
- if (payload.type === "route") {
1125
- if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1126
- this.store.deferJob(job);
1127
- yielded = true;
1128
- break;
1129
- }
1130
- clearFailures(this.store, payload.accountId, payload.conversationId);
1131
- } else if (routesAccount(settings, payload.accountId)) {
1132
- await this.sweep(settings, chatwoot, payload.accountId);
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;
1133
1314
  }
1315
+ clearFailures(this.store, payload.accountId, payload.conversationId);
1134
1316
  this.store.completeJob(job);
1135
1317
  } catch (error) {
1136
1318
  if (error instanceof ChatwootError && error.conversationMissing) {
@@ -1143,7 +1325,7 @@ var Router = class extends DurableObject {
1143
1325
  yielded = true;
1144
1326
  break;
1145
1327
  }
1146
- const attempts = payload.type === "route" ? recordFailure(this.store, payload.accountId, payload.conversationId) : job.attempts + 1;
1328
+ const attempts = recordFailure(this.store, payload.accountId, payload.conversationId);
1147
1329
  const delay = retryDelay(attempts - 1);
1148
1330
  const logAt = attempts >= 3 ? log.error : log.warn;
1149
1331
  logAt("job failed; will retry", {
@@ -1155,59 +1337,126 @@ var Router = class extends DurableObject {
1155
1337
  this.store.retryJob(job, delay);
1156
1338
  }
1157
1339
  }
1158
- await this.schedule(yielded ? Date.now() : void 0);
1159
- }
1160
- async sweep(settings, chatwoot, accountId2) {
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);
1340
+ await scheduleAlarm(this.ctx, yielded ? Date.now() : this.store.nextWakeup());
1341
+ }
1342
+ enqueue(payload) {
1343
+ this.store.enqueue(`route:${payload.accountId}:${payload.conversationId}`, 0, JSON.stringify(payload));
1344
+ }
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 }));
1168
1368
  }
1169
- pass.remaining.shift();
1170
- } else {
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 });
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) });
1175
1412
  }
1176
1413
  }
1177
- if (conversations.length === 0) {
1178
- this.store.delete(key);
1179
- return;
1180
- }
1181
- pass.page += 1;
1182
1414
  }
1183
- this.store.set(key, JSON.stringify(pass));
1184
- this.enqueue({ type: "sweep", accountId: accountId2 });
1185
- }
1186
- enqueue(payload) {
1187
- const key = payload.type === "sweep" ? `sweep:${payload.accountId}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
1188
- this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
1189
- }
1190
- async schedule(at) {
1191
- const next = at ?? this.store.nextWakeup();
1192
- if (next === void 0) return;
1193
- const current = await this.ctx.storage.getAlarm();
1194
- if (current === null || current > next) await this.ctx.storage.setAlarm(next);
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());
1195
1444
  }
1196
1445
  };
1197
1446
 
1198
1447
  // src/webhook.ts
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 }),
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 }),
1204
1453
  id: id2.optional(),
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()
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()
1211
1460
  });
1212
1461
  var CONVERSATION_EVENTS = /* @__PURE__ */ new Set([
1213
1462
  "conversation_opened",
@@ -1271,10 +1520,13 @@ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (
1271
1520
  if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1272
1521
  return context.text("account does not match the webhook secret", 403);
1273
1522
  }
1274
- await context.env.ROUTER.getByName(ROUTER_NAME).enqueueConversation(
1275
- target.accountId,
1276
- target.conversationId,
1277
- target.transition
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)
1278
1530
  );
1279
1531
  return context.json({ ok: true });
1280
1532
  });
@@ -1286,10 +1538,13 @@ app.onError((error, context) => {
1286
1538
  var src_default = {
1287
1539
  fetch: app.fetch,
1288
1540
  async scheduled(_controller, env, context) {
1289
- context.waitUntil(env.ROUTER.getByName(ROUTER_NAME).requestSweep());
1541
+ context.waitUntil(
1542
+ within(env.COORDINATOR.getByName(COORDINATOR_NAME).requestSweep(), AbortSignal.timeout(METADATA_TIMEOUT_MS))
1543
+ );
1290
1544
  }
1291
1545
  };
1292
1546
  export {
1547
+ Coordinator,
1293
1548
  Router,
1294
1549
  src_default as default
1295
1550
  };