chatwoot-router 0.1.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";
180
+ // src/coordinator.ts
181
+ import { DurableObject as DurableObject2 } from "cloudflare:workers";
70
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,
@@ -148,6 +242,7 @@ var messageSchema = z2.object({
148
242
  email: emailSchema.nullish(),
149
243
  deleted: z2.boolean().nullish(),
150
244
  external_error: text,
245
+ activity: z2.object({ type: text, status: text }).nullish(),
151
246
  submitted_values: z2.unknown().optional(),
152
247
  submitted_email: z2.unknown().optional(),
153
248
  items: z2.unknown().optional()
@@ -157,14 +252,33 @@ var messageSchema = z2.object({
157
252
  attachments: z2.array(attachmentSchema).nullish()
158
253
  });
159
254
  var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
160
- var MESSAGE_PAGE_SIZE = 100;
161
- function chatwootClient(baseUrl, token, fetch2) {
255
+ var MESSAGE_HISTORY_PAGE_SIZE = 20;
256
+ var inboxBotSchema = z2.object({
257
+ agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
258
+ });
259
+ function chatwootClient(baseUrl, token, fetch2, limits) {
162
260
  const client = createClient({
163
261
  baseUrl: baseUrl.replace(/\/+$/, ""),
164
262
  headers: { api_access_token: token },
165
263
  // A redirect is an error, never followed: it could carry the token to another host, and each
166
264
  // hop would be a subrequest the budget does not count. Configure Chatwoot's final URL.
167
- 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
+ }
168
282
  });
169
283
  async function data(operation, pending) {
170
284
  const { data: data2, response } = await pending;
@@ -174,7 +288,7 @@ function chatwootClient(baseUrl, token, fetch2) {
174
288
  async function dataUnlessNotFound(operation, pending) {
175
289
  const { data: data2, response } = await pending;
176
290
  if (notFound(response)) {
177
- log.info("conversation deleted", { operation });
291
+ log.info("Chatwoot resource deleted", { operation });
178
292
  return void 0;
179
293
  }
180
294
  if (!response.ok || data2 === void 0) throw new ChatwootError(response.status, operation);
@@ -193,15 +307,6 @@ function chatwootClient(baseUrl, token, fetch2) {
193
307
  );
194
308
  return list && messageListSchema.parse(list).payload.toSorted((a, b) => a.id - b.id);
195
309
  }
196
- function setCustomAttributes(accountId2, conversationId, attributes) {
197
- return ensureOk(
198
- "set custom attribute",
199
- client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
200
- params: { path: { account_id: accountId2, conversation_id: conversationId } },
201
- body: { custom_attributes: attributes, merge: true }
202
- })
203
- );
204
- }
205
310
  return {
206
311
  /** The conversation, or undefined when it does not exist (it was deleted). */
207
312
  getConversation(accountId2, conversationId) {
@@ -227,6 +332,21 @@ function chatwootClient(baseUrl, token, fetch2) {
227
332
  const messages = await listMessages(accountId2, conversationId, { after: messageId - 1, before: messageId + 1 });
228
333
  return messages?.find((message) => message.id === messageId);
229
334
  },
335
+ async inboxBot(accountId2, inboxId) {
336
+ const value = await dataUnlessNotFound(
337
+ "get inbox agent bot",
338
+ client.GET("/api/v1/accounts/{account_id}/inboxes/{id}/agent_bot", {
339
+ params: { path: { account_id: accountId2, id: inboxId } }
340
+ })
341
+ );
342
+ if (value === void 0) return void 0;
343
+ const parsed = inboxBotSchema.safeParse(value);
344
+ if (!parsed.success) throw new Error("Chatwoot returned an invalid inbox bot");
345
+ const bot = parsed.data.agent_bot;
346
+ if (bot?.id === void 0) return void 0;
347
+ if (bot.account_id !== accountId2) throw new Error("Chatwoot inbox bot does not belong to the account");
348
+ return { id: bot.id };
349
+ },
230
350
  async inboxName(accountId2, inboxId) {
231
351
  const inbox = await data(
232
352
  "get inbox",
@@ -295,12 +415,13 @@ function chatwootClient(baseUrl, token, fetch2) {
295
415
  false
296
416
  );
297
417
  },
298
- assign(accountId2, conversationId, assigneeId) {
418
+ assign(accountId2, conversationId, assigneeId, assigneeType = "User") {
419
+ const body = { assignee_id: assigneeId, assignee_type: assigneeType };
299
420
  return ensureOk(
300
421
  "assign conversation",
301
422
  client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments", {
302
423
  params: { path: { account_id: accountId2, conversation_id: conversationId } },
303
- body: { assignee_id: assigneeId }
424
+ body
304
425
  })
305
426
  );
306
427
  },
@@ -346,13 +467,21 @@ function chatwootClient(baseUrl, token, fetch2) {
346
467
  })
347
468
  );
348
469
  },
349
- 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
+ },
350
479
  /**
351
480
  * Sends an outgoing message (or private note). With files it is sent as the spec's
352
481
  * multipart/form-data body; the generated type renders its `attachments[]` binaries as
353
482
  * strings, so the serializer appends the files itself.
354
483
  */
355
- createMessage(accountId2, conversationId, message) {
484
+ async createMessage(accountId2, conversationId, message) {
356
485
  const fields = {
357
486
  content: message.content,
358
487
  message_type: "outgoing",
@@ -368,23 +497,30 @@ function chatwootClient(baseUrl, token, fetch2) {
368
497
  for (const file of message.files) form.append("attachments[]", file.blob, file.filename);
369
498
  return form;
370
499
  };
371
- return ensureOk(
372
- "create message",
373
- 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
+ {
374
503
  params: { path: { account_id: accountId2, conversation_id: conversationId } },
375
504
  body: fields,
376
505
  ...message.files.length === 0 ? {} : { bodySerializer: () => multipart(fields) }
377
- })
506
+ }
378
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;
379
511
  }
380
512
  };
381
513
  }
382
514
  function notFound(response) {
383
515
  return response.status === 404 && (response.headers.get("content-type")?.includes("application/json") ?? false);
384
516
  }
517
+ function personAssignee(conversation) {
518
+ const type = conversation.meta?.assignee_type;
519
+ return type == null || type === "User" ? conversation.meta?.assignee : void 0;
520
+ }
385
521
  function toRelayConversation(conversationId, conversation) {
386
522
  const meta = conversation.meta;
387
- const assignee = meta?.assignee;
523
+ const assignee = personAssignee(conversation);
388
524
  return {
389
525
  id: conversationId,
390
526
  status: conversation.status,
@@ -399,6 +535,7 @@ function toRelayConversation(conversationId, conversation) {
399
535
  avatarUrl: meta?.sender?.thumbnail ?? null
400
536
  },
401
537
  assignee: assignee ? { id: assignee.id, name: assignee.name } : null,
538
+ assigneeType: meta?.assignee_type ?? null,
402
539
  customAttributes: conversation.custom_attributes ?? {}
403
540
  };
404
541
  }
@@ -422,12 +559,17 @@ function retryDelay(attempts) {
422
559
  return Math.min(5e3 * 2 ** attempts, 30 * 60 * 1e3);
423
560
  }
424
561
  var QueueStore = class {
425
- constructor(sql, now = Date.now) {
562
+ constructor(sql, now = Date.now, atomic = (write2) => write2()) {
426
563
  this.sql = sql;
427
564
  this.now = now;
565
+ this.atomic = atomic;
428
566
  }
429
567
  sql;
430
568
  now;
569
+ atomic;
570
+ transaction(write2) {
571
+ return this.atomic(write2);
572
+ }
431
573
  migrate() {
432
574
  this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
433
575
  key TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL,
@@ -436,6 +578,10 @@ var QueueStore = class {
436
578
  );
437
579
  CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
438
580
  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
+ }
439
585
  }
440
586
  // Cache
441
587
  get(key) {
@@ -454,14 +600,6 @@ var QueueStore = class {
454
600
  delete(key) {
455
601
  this.sql.exec("DELETE FROM cache WHERE key = ?", key);
456
602
  }
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
603
  // Jobs
466
604
  /**
467
605
  * Adds a job, or marks an existing one as needing another run (its version changes). A job
@@ -470,7 +608,7 @@ var QueueStore = class {
470
608
  enqueue(key, priority, payload, notBefore = this.now()) {
471
609
  this.sql.exec(
472
610
  `INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
473
- ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
611
+ ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload, suspended = 0,
474
612
  not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
475
613
  key,
476
614
  priority,
@@ -480,18 +618,24 @@ var QueueStore = class {
480
618
  );
481
619
  }
482
620
  nextDueJob() {
483
- const row = this.sql.exec(
484
- `SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
485
- ORDER BY priority, not_before, created_at LIMIT 1`,
486
- this.now()
487
- ).toArray()[0];
488
- if (!row) return void 0;
489
- const { created_at: createdAt, ...job } = row;
490
- 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();
491
635
  }
492
636
  /** Earliest due time. */
493
637
  nextWakeup() {
494
- const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
638
+ const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs WHERE suspended != 2").toArray()[0];
495
639
  return row?.at ?? void 0;
496
640
  }
497
641
  /** Removes a finished job unless it was enqueued again while running. */
@@ -516,73 +660,170 @@ var QueueStore = class {
516
660
  deferJob(job, delayMs = 0) {
517
661
  this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
518
662
  }
663
+ /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
664
+ holdJob(job) {
665
+ 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,
669
+ job.key,
670
+ job.version
671
+ );
672
+ }
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
+ }
519
677
  prune() {
520
678
  this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
521
679
  }
522
680
  };
523
681
 
524
- // ../../shared/sweep.ts
525
- 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 };
537
- }
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);
545
- }
546
- }
682
+ // src/router.ts
683
+ import { DurableObject } from "cloudflare:workers";
684
+ import { z as z6 } from "zod";
547
685
 
548
686
  // src/routing.ts
549
687
  import ipRegex from "ip-regex";
550
- import { z as z5 } from "zod";
688
+ import { z as z4 } from "zod";
551
689
 
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;
690
+ // ../../shared/concurrent.ts
691
+ async function parallel(...reads) {
692
+ await Promise.allSettled(reads);
693
+ return Promise.all(reads);
561
694
  }
562
695
 
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)
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
+
718
+ // src/turn.ts
719
+ import { z as z3 } from "zod";
720
+ var guardSchema = z3.object({
721
+ boundary: z3.number().optional(),
722
+ claimed: z3.boolean().optional(),
723
+ pending: z3.boolean().optional(),
724
+ transitionAt: z3.number().optional(),
725
+ expected: z3.object({ status: z3.string(), at: z3.number().optional(), after: z3.number().optional() }).optional(),
726
+ handoff: z3.boolean().default(false),
727
+ failures: z3.number().int().min(0).default(0)
569
728
  });
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));
729
+ function readGuard(store, accountId2, conversationId) {
730
+ const parsed = guardSchema.safeParse(parseJson(store.get(`turn:${accountId2}:${conversationId}`)));
731
+ return parsed.success ? parsed.data : { handoff: false, failures: 0 };
575
732
  }
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);
733
+ function saveGuard(store, accountId2, conversationId, guard) {
734
+ store.set(`turn:${accountId2}:${conversationId}`, JSON.stringify(guard));
735
+ }
736
+ function expectActivity(store, accountId2, conversationId, transition) {
737
+ const guard = readGuard(store, accountId2, conversationId);
738
+ if (transition.at !== void 0 && transition.at <= (guard.transitionAt ?? 0)) return;
739
+ if (transition.at === void 0 && guard.expected?.status === transition.status) return;
740
+ const claimed = guard.claimed ?? guard.transitionAt !== void 0;
741
+ const after = transition.at === void 0 || claimed ? guard.boundary ?? 0 : 0;
742
+ saveGuard(store, accountId2, conversationId, {
743
+ ...guard,
744
+ // A first webhook must not relax a newer activity requirement already observed locally.
745
+ expected: { ...transition, after: Math.max(guard.expected?.after ?? 0, after) },
746
+ transitionAt: transition.at ?? guard.transitionAt,
747
+ handoff: false,
748
+ failures: 0
749
+ });
750
+ }
751
+ function observeStatus(store, accountId2, conversationId, status) {
752
+ const guard = readGuard(store, accountId2, conversationId);
753
+ const pending = status === "pending";
754
+ if (guard.pending && !pending) expectActivity(store, accountId2, conversationId, { status });
755
+ if (guard.pending !== pending) {
756
+ saveGuard(store, accountId2, conversationId, { ...readGuard(store, accountId2, conversationId), pending });
757
+ }
758
+ }
759
+ function requestHandoff(store, accountId2, conversationId) {
760
+ saveGuard(store, accountId2, conversationId, { ...readGuard(store, accountId2, conversationId), handoff: true });
761
+ }
762
+ function recordFailure(store, accountId2, conversationId) {
763
+ const guard = readGuard(store, accountId2, conversationId);
764
+ guard.failures += 1;
765
+ if (guard.failures >= 3) guard.handoff = true;
766
+ saveGuard(store, accountId2, conversationId, guard);
767
+ return guard.failures;
768
+ }
769
+ function clearFailures(store, accountId2, conversationId) {
770
+ const guard = readGuard(store, accountId2, conversationId);
771
+ if (guard.failures) saveGuard(store, accountId2, conversationId, { ...guard, failures: 0 });
772
+ }
773
+ var ActivityPendingError = class extends Error {
774
+ constructor() {
775
+ super("Chatwoot status activity is not available yet");
776
+ this.name = "ActivityPendingError";
777
+ }
778
+ };
779
+ async function readTurn(chatwoot, store, accountId2, conversationId) {
780
+ const messages = [];
781
+ const pages = [];
782
+ let before;
783
+ let boundary;
784
+ let complete = false;
785
+ let deleted = false;
786
+ for (let page = 0; page < 5; page += 1) {
787
+ const batch = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
788
+ pages.push(batch);
789
+ for (const message of batch.toReversed()) {
790
+ if (message.message_type === 2 && message.content_attributes?.deleted) {
791
+ deleted = true;
792
+ break;
793
+ }
794
+ if (message.message_type === 2 && message.content_attributes?.activity?.type === "conversation_status_changed") {
795
+ boundary = message;
796
+ break;
797
+ }
798
+ messages.push(message);
799
+ }
800
+ complete = boundary !== void 0 || batch.length < MESSAGE_HISTORY_PAGE_SIZE;
801
+ if (complete || deleted) break;
802
+ const next = batch[0]?.id;
803
+ if (next === void 0 || before !== void 0 && next >= before) break;
804
+ before = next;
805
+ }
806
+ const guard = readGuard(store, accountId2, conversationId);
807
+ const missing = guard.boundary !== void 0 && (boundary?.id ?? 0) < guard.boundary;
808
+ const expected = guard.expected;
809
+ const activity = boundary?.content_attributes?.activity;
810
+ 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);
811
+ if (late && !guard.handoff && !deleted && !missing) throw new ActivityPendingError();
812
+ if (boundary && boundary.id !== guard.boundary && guard.boundary !== void 0 && !missing && !late) {
813
+ guard.handoff = false;
814
+ guard.failures = 0;
815
+ }
816
+ if (!missing && complete) {
817
+ if (guard.boundary !== (boundary?.id ?? 0)) guard.claimed = false;
818
+ guard.boundary = boundary?.id ?? 0;
819
+ }
820
+ if (expected && !late) {
821
+ guard.claimed = expected.at !== void 0;
822
+ delete guard.expected;
823
+ }
824
+ if (deleted || missing || !complete) guard.handoff = true;
825
+ saveGuard(store, accountId2, conversationId, guard);
826
+ return { pages, messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
586
827
  }
587
828
 
588
829
  // src/routing.ts
@@ -594,7 +835,6 @@ var REQUEST_CRITERIA = {
594
835
  };
595
836
  var NO_KIND = "none";
596
837
  var NO_KIND_CRITERION = "None of the other kinds.";
597
- var JEV_URL = "https://api.typesafe.ai/v1/systemone";
598
838
  var MAX_MESSAGES = 3;
599
839
  var MAX_TEXT = 1600;
600
840
  var REDACTIONS = [
@@ -619,23 +859,23 @@ var REDACTIONS = [
619
859
  /(?<!\w)@[A-Za-z0-9_.-]{2,}/g
620
860
  // handles
621
861
  ];
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)
862
+ var decisionSchema = z4.object({
863
+ owner: z4.string().nullable(),
864
+ ownerConfidence: z4.number(),
865
+ topic: z4.string().nullable(),
866
+ topicConfidence: z4.number(),
867
+ kind: z4.string().nullable().default(null),
868
+ kindConfidence: z4.number().default(0),
869
+ /** Jev is confident the customer asked for nothing yet. */
870
+ noRequest: z4.boolean().default(false)
631
871
  });
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()
872
+ var jevResponseSchema = z4.object({
873
+ answers: z4.record(
874
+ z4.string(),
875
+ z4.object({
876
+ choice: z4.string(),
877
+ probabilities: z4.record(z4.string(), z4.number()).optional(),
878
+ confidence: z4.number().optional()
639
879
  })
640
880
  )
641
881
  });
@@ -652,143 +892,171 @@ async function routeConversation(ctx, accountId2, conversationId) {
652
892
  const { settings, store, chatwoot } = ctx;
653
893
  const routing = settings.config.routing;
654
894
  const owners = routing.accounts[String(accountId2)];
655
- if (!owners) return;
895
+ const token = settings.secrets.CHATWOOT_AGENT_BOT_TOKENS[String(accountId2)];
896
+ if (!owners || !token) return;
897
+ const bot = chatwootClient(settings.config.chatwoot.baseUrl, token, ctx.fetch);
656
898
  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));
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);
679
905
  }
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
- );
906
+ };
907
+ const snapshot = async (inboxId) => {
908
+ const raw = await chatwoot.getConversation(accountId2, conversationId);
909
+ if (!raw || raw.inbox_id === void 0 || inboxId !== void 0 && raw.inbox_id !== inboxId) return;
910
+ if (raw.status !== "pending") {
911
+ observeStatus(store, accountId2, conversationId, raw.status ?? "open");
912
+ if (raw.status !== void 0 && owns(raw)) await release();
913
+ return;
914
+ }
915
+ 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;
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" });
738
923
  }
924
+ return;
739
925
  }
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 }
926
+ if (conversation.contact.blocked) return;
927
+ observeStatus(store, accountId2, conversationId, "pending");
928
+ return { raw, conversation };
756
929
  };
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);
930
+ const initial = await snapshot();
931
+ if (!initial) return;
932
+ const identities = [initial.conversation.contact.name, initial.conversation.contact.email];
933
+ const inputs = (messages) => {
934
+ const texts = messages.filter(isCustomer).flatMap((message) => {
778
935
  const text2 = sanitize(
779
936
  [message.content_attributes?.email?.subject ?? "", messageContent(message)].join("\n"),
780
937
  identities
781
938
  );
782
- if (ids.length < MAX_MESSAGES && text2.replaceAll("[REDACTED]", "").trim()) {
783
- ids.push(message.id);
784
- parts.push(text2);
939
+ return text2.replaceAll("[REDACTED]", "").trim() ? [{ id: message.id, text: text2 }] : [];
940
+ }).slice(0, MAX_MESSAGES);
941
+ return {
942
+ key: texts.map((entry) => entry.id).join(","),
943
+ text: texts.map((entry) => entry.text).join(" ").slice(0, MAX_TEXT),
944
+ count: texts.length
945
+ };
946
+ };
947
+ const turn = await readTurn(chatwoot, store, accountId2, conversationId);
948
+ const input = inputs(turn.messages);
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))
951
+ );
952
+ const fresh = async () => {
953
+ const current2 = await snapshot(initial.raw.inbox_id);
954
+ if (!current2) return;
955
+ 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) };
959
+ };
960
+ const handoff = async (current2) => {
961
+ requestHandoff(store, accountId2, conversationId);
962
+ current2 ??= await fresh();
963
+ if (current2 === "defer") return "defer";
964
+ if (current2) await bot.setStatus(accountId2, conversationId, { status: "open" });
965
+ return void 0;
966
+ };
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;
976
+ if (!decision) {
977
+ decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], input.text);
978
+ store.set(memoKey, JSON.stringify(decision));
979
+ }
980
+ const kind = decision.kind && decision.kindConfidence >= routing.minConfidence ? kinds[decision.kind] : void 0;
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
+ );
990
+ let current = await fresh();
991
+ if (current === "defer" || !current) return current;
992
+ if (current.handoff) return handoff(current);
993
+ if (!kind && decision.noRequest && input.count < MAX_MESSAGES) return;
994
+ 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
+ const labels = [
996
+ .../* @__PURE__ */ new Set([
997
+ ...current.conversation.labels,
998
+ ...[topic, kind ? decision.kind : null].filter((label) => label !== null)
999
+ ])
1000
+ ];
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();
785
1035
  }
786
1036
  }
787
- complete = ids.length === MAX_MESSAGES || messages.length < MESSAGE_PAGE_SIZE;
788
- if (complete) break;
789
- after = messages.at(-1)?.id ?? after;
790
1037
  }
791
- return { ids, text: parts.join(" ").slice(0, MAX_TEXT), complete, seen };
1038
+ 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);
1045
+ }
1046
+ if (kind?.status) {
1047
+ await bot.setStatus(accountId2, conversationId, { status: kind.status });
1048
+ expectActivity(store, accountId2, conversationId, { status: kind.status });
1049
+ await release();
1050
+ } else if (assignee !== void 0 && current.raw.meta?.assignee_type === "AgentBot") {
1051
+ await bot.assign(accountId2, conversationId, assignee);
1052
+ expectActivity(store, accountId2, conversationId, { status: "open" });
1053
+ } else {
1054
+ return handoff(current);
1055
+ }
1056
+ return void 0;
1057
+ }
1058
+ function isCustomer(message) {
1059
+ 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
1060
  }
793
1061
  function sanitize(text2, identities) {
794
1062
  let value = text2.normalize("NFKC");
@@ -803,7 +1071,6 @@ function sanitize(text2, identities) {
803
1071
  async function decide(ctx, owners, kinds, text2) {
804
1072
  const routing = ctx.settings.config.routing;
805
1073
  const apiKey = ctx.settings.secrets.TYPESAFE_API_KEY;
806
- if (!routing || !apiKey) throw new JevError("routing is not configured");
807
1074
  const ownerCriteria = Object.fromEntries(
808
1075
  Object.entries(owners).map(([route, owner2]) => [route, owner2.covers])
809
1076
  );
@@ -822,13 +1089,11 @@ async function decide(ctx, owners, kinds, text2) {
822
1089
  criteria: routing.topics
823
1090
  };
824
1091
  }
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
- }
1092
+ questions.request = {
1093
+ type: "choice",
1094
+ instructions: "Select whether the customer asks for anything in this support ticket, using only the ticket.",
1095
+ criteria: REQUEST_CRITERIA
1096
+ };
832
1097
  if (kinds) {
833
1098
  questions.kind = {
834
1099
  type: "choice",
@@ -840,14 +1105,17 @@ async function decide(ctx, owners, kinds, text2) {
840
1105
  };
841
1106
  }
842
1107
  const response = await ctx.fetch(
843
- new Request(JEV_URL, {
1108
+ new Request(routing.endpoint, {
844
1109
  method: "POST",
845
1110
  redirect: "manual",
846
1111
  headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
847
1112
  body: JSON.stringify({ model: routing.model, state: { ticket: text2 }, questions })
848
1113
  })
849
1114
  );
850
- 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
+ }
851
1119
  const parsed = jevResponseSchema.safeParse(await response.json().catch(() => void 0));
852
1120
  if (!parsed.success) throw new JevError("invalid response");
853
1121
  const answer = (question) => {
@@ -871,10 +1139,6 @@ async function decide(ctx, owners, kinds, text2) {
871
1139
  noRequest: request.choice === "none" && request.confidence >= routing.minConfidence
872
1140
  };
873
1141
  }
874
- function readDecision(stored) {
875
- const parsed = decisionSchema.safeParse(parseJson(stored));
876
- return parsed.success ? parsed.data : void 0;
877
- }
878
1142
 
879
1143
  // ../../shared/settings.ts
880
1144
  function settingsLoader(parse) {
@@ -893,89 +1157,90 @@ async function readConfig(env) {
893
1157
  if (env.CONFIG_KEY === void 0) return typeof env.CONFIG === "string" ? parseJson(env.CONFIG) : env.CONFIG;
894
1158
  if (env.CONFIG !== void 0) throw new ConfigError("Invalid CONFIG: set either CONFIG or CONFIG_KEY");
895
1159
  if (!env.CONFIG_STORE) throw new ConfigError("Invalid CONFIG_KEY: requires the CONFIG_STORE KV namespace");
896
- 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));
897
1161
  if (config === null) throw new ConfigError("Invalid CONFIG_KEY: not in CONFIG_STORE");
898
1162
  return config;
899
1163
  }
900
1164
 
901
1165
  // 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"),
1166
+ import { z as z5 } from "zod";
1167
+ 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");
1168
+ var configSchema = z5.strictObject({
1169
+ chatwoot: z5.strictObject({ baseUrl: z5.url({ protocol: /^https?$/ }) }),
1170
+ subrequestBudget: z5.number().int().min(45).max(1e3).default(45),
1171
+ routing: z5.strictObject({
1172
+ /** TypeSafe's System One API (/v1/systemone), or another deployment such as a proxy or gateway. */
1173
+ endpoint: z5.url({ protocol: /^https$/ }).default("https://api.typesafe.ai/v1/systemone"),
1174
+ /** Model sent to TypeSafe's System One API; Jev by default. */
1175
+ model: z5.string().min(1).default("jev-1.13.0"),
911
1176
  /** 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),
1177
+ minConfidence: z5.number().min(0.5).max(1).default(0.7),
1178
+ /** The account's native Chatwoot agent bot. */
1179
+ botIds: z5.record(accountId, z5.number().int().positive()),
918
1180
  /** Per Chatwoot account id: the owners Jev chooses from, by a short name. */
919
- accounts: z6.record(
1181
+ accounts: z5.record(
920
1182
  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({
1183
+ z5.record(
1184
+ z5.string().regex(/^[a-z0-9_]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "unclear", "unclear is reserved"),
1185
+ z5.strictObject({
924
1186
  /** Chatwoot user id to assign. */
925
- assignee: z6.number().int().positive(),
1187
+ assignee: z5.number().int().positive(),
926
1188
  /** What the owner handles, as Jev's criterion for choosing them. */
927
- covers: z6.string().min(1).max(1e3)
1189
+ covers: z5.string().min(1).max(1e3)
928
1190
  })
929
1191
  ).refine((owners) => Object.keys(owners).length > 0, "needs at least one owner")
930
1192
  ),
931
1193
  /** 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)
1194
+ topics: z5.record(
1195
+ z5.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
1196
+ z5.string().min(1).max(1e3)
935
1197
  ).optional(),
936
1198
  /**
937
1199
  * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
938
1200
  * once, when it does (see src/routing.ts). Unset: none.
939
1201
  */
940
- kinds: z6.record(
1202
+ kinds: z5.record(
941
1203
  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({
1204
+ z5.record(
1205
+ z5.string().regex(/^[a-z0-9_-]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "none", "none is reserved"),
1206
+ z5.strictObject({
945
1207
  /** What the kind is, as Jev's criterion for recognizing it. */
946
- covers: z6.string().min(1).max(1e3),
1208
+ covers: z5.string().min(1).max(1e3),
947
1209
  /**
948
1210
  * 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.
1211
+ * the account's agent bot (CHATWOOT_AGENT_BOT_TOKENS). Missing responses hand off to people.
950
1212
  */
951
- cannedResponse: z6.string().trim().min(1).max(255).optional(),
1213
+ cannedResponse: z5.string().trim().min(1).max(255).optional(),
952
1214
  /**
953
1215
  * Set instead of routing the ticket (after the reply, with cannedResponse): `resolved`,
954
1216
  * or `snoozed` until the customer's next message. A new message from the customer
955
1217
  * reopens either.
956
1218
  */
957
- status: z6.enum(["resolved", "snoozed"]).optional()
1219
+ status: z5.enum(["resolved", "snoozed"]).optional()
958
1220
  })
959
1221
  )
960
1222
  ).optional()
961
1223
  })
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]), {
1224
+ }).refine(
1225
+ (config) => {
1226
+ const accounts = Object.keys(config.routing.accounts);
1227
+ return accounts.length > 0 && accounts.length === Object.keys(config.routing.botIds).length && accounts.every((id3) => config.routing.botIds[id3] !== void 0);
1228
+ },
1229
+ { path: ["routing", "botIds"], message: "must name exactly the routed accounts" }
1230
+ ).refine((config) => Object.keys(config.routing.kinds ?? {}).every((id3) => config.routing.accounts[id3]), {
966
1231
  path: ["routing", "kinds"],
967
1232
  message: "must only name routed accounts"
968
1233
  }).refine(
969
- (config) => Object.values(config.routing?.kinds ?? {}).every(
970
- (kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing?.topics ?? {}, kind))
1234
+ (config) => Object.values(config.routing.kinds ?? {}).every(
1235
+ (kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing.topics ?? {}, kind))
971
1236
  ),
972
1237
  { path: ["routing", "kinds"], message: "a kind cannot be named as a topic: they are labels of two families" }
973
1238
  );
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({})
1239
+ var secretsSchema = z5.object({
1240
+ CHATWOOT_TOKEN: z5.string().min(1),
1241
+ CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
1242
+ TYPESAFE_API_KEY: z5.string().min(1),
1243
+ CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
979
1244
  });
980
1245
  function parseSettings(rawConfig, rawSecrets) {
981
1246
  const config = configSchema.safeParse(rawConfig);
@@ -985,50 +1250,41 @@ function parseSettings(rawConfig, rawSecrets) {
985
1250
  return buildSettings(config.data, secrets.data);
986
1251
  }
987
1252
  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`);
1253
+ const accounts = Object.keys(config.routing.accounts);
1254
+ for (const name of ["CHATWOOT_AGENT_BOT_SECRETS", "CHATWOOT_AGENT_BOT_TOKENS"]) {
1255
+ if (Object.keys(secrets[name]).length !== accounts.length || accounts.some((id3) => !secrets[name][id3]?.trim())) {
1256
+ throw new ConfigError(`Invalid secrets: ${name}: needs exactly the routed accounts`);
996
1257
  }
997
1258
  }
998
- return { config, secrets, botToken: (accountId2) => secrets.CHATWOOT_BOT_TOKENS[String(accountId2)] };
1259
+ return { config, secrets };
999
1260
  }
1000
1261
 
1001
1262
  // src/settings.ts
1002
1263
  var loadSettings = settingsLoader(parseSettings);
1003
1264
 
1004
1265
  // src/router.ts
1005
- var ROUTER_NAME = "global";
1006
- var ROUTE_BUDGET = 15;
1007
- var BUDGET = { route: ROUTE_BUDGET, sweep: 1 };
1266
+ function conversationName(accountId2, conversationId) {
1267
+ return `${accountId2}:${conversationId}`;
1268
+ }
1269
+ var ROUTE_BUDGET = 45;
1008
1270
  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 })
1271
+ var id = z6.number().int().positive();
1272
+ var jobSchema = z6.discriminatedUnion("type", [
1273
+ z6.object({ type: z6.literal("route"), accountId: id, conversationId: id })
1013
1274
  ]);
1014
1275
  var Router = class extends DurableObject {
1015
1276
  store;
1016
1277
  constructor(context, env) {
1017
1278
  super(context, env);
1018
1279
  this.store = new QueueStore(context.storage.sql);
1019
- context.blockConcurrencyWhile(async () => this.store.migrate());
1280
+ this.store.migrate();
1020
1281
  }
1021
- async enqueueConversation(accountId2, conversationId) {
1282
+ async enqueueConversation(accountId2, conversationId, transition) {
1022
1283
  const settings = await loadSettings(this.env);
1023
1284
  if (!routesAccount(settings, accountId2)) return;
1285
+ if (transition) expectActivity(this.store, accountId2, conversationId, transition);
1024
1286
  this.enqueue({ type: "route", accountId: accountId2, conversationId });
1025
- await this.schedule();
1026
- }
1027
- async requestSweep() {
1028
- for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1029
- this.enqueue({ type: "sweep", accountId: Number(accountId2) });
1030
- }
1031
- await this.schedule();
1287
+ await scheduleAlarm(this.ctx, this.store.nextWakeup());
1032
1288
  }
1033
1289
  async alarm() {
1034
1290
  const settings = await loadSettings(this.env);
@@ -1045,21 +1301,18 @@ var Router = class extends DurableObject {
1045
1301
  continue;
1046
1302
  }
1047
1303
  const payload = parsed.data;
1048
- if (budget.remaining < BUDGET[payload.type] || Date.now() - started > RUN_WALL_MS) {
1304
+ if (budget.remaining < ROUTE_BUDGET || Date.now() - started > RUN_WALL_MS) {
1049
1305
  yielded = true;
1050
1306
  break;
1051
1307
  }
1052
1308
  try {
1053
- const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetch };
1054
- if (payload.type === "route") {
1055
- if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1056
- this.store.deferJob(job);
1057
- yielded = true;
1058
- break;
1059
- }
1060
- } else if (routesAccount(settings, payload.accountId)) {
1061
- 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;
1062
1314
  }
1315
+ clearFailures(this.store, payload.accountId, payload.conversationId);
1063
1316
  this.store.completeJob(job);
1064
1317
  } catch (error) {
1065
1318
  if (error instanceof ChatwootError && error.conversationMissing) {
@@ -1072,48 +1325,122 @@ var Router = class extends DurableObject {
1072
1325
  yielded = true;
1073
1326
  break;
1074
1327
  }
1075
- const delay = retryDelay(job.attempts);
1076
- const logAt = job.attempts + 1 >= 3 ? log.error : log.warn;
1328
+ const attempts = recordFailure(this.store, payload.accountId, payload.conversationId);
1329
+ const delay = retryDelay(attempts - 1);
1330
+ const logAt = attempts >= 3 ? log.error : log.warn;
1077
1331
  logAt("job failed; will retry", {
1078
1332
  job: job.key,
1079
- attempts: job.attempts + 1,
1333
+ attempts,
1080
1334
  delayMs: delay,
1081
1335
  ...errorFields(error)
1082
1336
  });
1083
1337
  this.store.retryJob(job, delay);
1084
1338
  }
1085
1339
  }
1086
- await this.schedule(yielded ? Date.now() : void 0);
1087
- }
1088
- 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;
1096
- }
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);
1103
- } else {
1104
- saveSweepPass(this.store, accountId2, pass, pass.page + 1);
1105
- this.enqueue({ type: "sweep", accountId: accountId2 });
1106
- }
1340
+ await scheduleAlarm(this.ctx, yielded ? Date.now() : this.store.nextWakeup());
1107
1341
  }
1108
1342
  enqueue(payload) {
1109
- const key = payload.type === "sweep" ? `sweep:${payload.accountId}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
1110
- this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
1343
+ this.store.enqueue(`route:${payload.accountId}:${payload.conversationId}`, 0, JSON.stringify(payload));
1111
1344
  }
1112
- async schedule(at) {
1113
- const next = at ?? this.store.nextWakeup();
1114
- if (next === void 0) return;
1115
- const current = await this.ctx.storage.getAlarm();
1116
- if (current === null || current > next) await this.ctx.storage.setAlarm(next);
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());
1117
1444
  }
1118
1445
  };
1119
1446
 
@@ -1127,15 +1454,27 @@ var eventSchema = z8.object({
1127
1454
  conversation: z8.object({ id: id2 }).optional(),
1128
1455
  message_type: z8.union([z8.string(), z8.number()]).optional(),
1129
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(),
1130
1459
  sender: z8.object({ type: z8.string().optional() }).nullish()
1131
1460
  });
1132
- var CONVERSATION_EVENTS = /* @__PURE__ */ new Set(["conversation_created", "conversation_updated", "conversation_status_changed"]);
1461
+ var CONVERSATION_EVENTS = /* @__PURE__ */ new Set([
1462
+ "conversation_opened",
1463
+ "conversation_resolved",
1464
+ "conversation_updated",
1465
+ "conversation_status_changed"
1466
+ ]);
1133
1467
  function eventTarget(payload) {
1134
1468
  const parsed = eventSchema.safeParse(payload);
1135
1469
  if (!parsed.success) return void 0;
1136
1470
  const event = parsed.data;
1137
1471
  if (CONVERSATION_EVENTS.has(event.event) && event.id !== void 0) {
1138
- return { accountId: event.account.id, conversationId: event.id };
1472
+ const change = event.changed_attributes?.find((entry) => entry.status)?.status;
1473
+ return {
1474
+ accountId: event.account.id,
1475
+ conversationId: event.id,
1476
+ ...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 } } : {}
1477
+ };
1139
1478
  }
1140
1479
  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
1480
  return { accountId: event.account.id, conversationId: event.conversation.id };
@@ -1154,7 +1493,7 @@ app.get("/healthz", async (context) => {
1154
1493
  return context.json({ ok: false }, 503);
1155
1494
  }
1156
1495
  });
1157
- app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (context) => {
1496
+ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (context) => {
1158
1497
  const settings = await loadSettings(context.env);
1159
1498
  const timestamp = context.req.header("x-chatwoot-timestamp");
1160
1499
  if (!timestamp || !isFreshTimestamp(timestamp, Math.floor(Date.now() / 1e3))) {
@@ -1163,7 +1502,7 @@ app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (co
1163
1502
  const body = new Uint8Array(await context.req.arrayBuffer());
1164
1503
  const signature = context.req.header("x-chatwoot-signature");
1165
1504
  let signedBy;
1166
- for (const [accountId2, secret] of Object.entries(settings.secrets.CHATWOOT_WEBHOOK_SECRETS)) {
1505
+ for (const [accountId2, secret] of Object.entries(settings.secrets.CHATWOOT_AGENT_BOT_SECRETS)) {
1167
1506
  if (await verifyChatwootSignature(secret, timestamp, body, signature)) {
1168
1507
  signedBy = Number(accountId2);
1169
1508
  break;
@@ -1181,7 +1520,14 @@ app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (co
1181
1520
  if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1182
1521
  return context.text("account does not match the webhook secret", 403);
1183
1522
  }
1184
- await context.env.ROUTER.getByName(ROUTER_NAME).enqueueConversation(target.accountId, target.conversationId);
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)
1530
+ );
1185
1531
  return context.json({ ok: true });
1186
1532
  });
1187
1533
  app.notFound((context) => context.text("not found", 404));
@@ -1192,10 +1538,13 @@ app.onError((error, context) => {
1192
1538
  var src_default = {
1193
1539
  fetch: app.fetch,
1194
1540
  async scheduled(_controller, env, context) {
1195
- 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
+ );
1196
1544
  }
1197
1545
  };
1198
1546
  export {
1547
+ Coordinator,
1199
1548
  Router,
1200
1549
  src_default as default
1201
1550
  };