chatwoot-router 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-04
11
+
12
+ ### Changed
13
+
14
+ - Route each conversation in a Durable Object of its own, named `<account>:<conversation>`; one sweeper object
15
+ pages the accounts and hands each conversation to its object. This replaces 0.3.0's `Coordinator` class and
16
+ bounded RPC machinery; a slow Jev or Chatwoot request still delays only its own conversation.
17
+
18
+ ### Upgrade
19
+
20
+ - Declare only the `Router` Durable Object (remove `Coordinator`).
21
+
10
22
  ## [0.3.0] - 2026-10-03
11
23
 
12
24
  ### Fixed
@@ -90,7 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
90
102
  `routing_kind`.
91
103
  - The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
92
104
 
93
- [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...HEAD
105
+ [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.4.0...HEAD
106
+ [0.4.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...chatwoot-router@0.4.0
94
107
  [0.3.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...chatwoot-router@0.3.0
95
108
  [0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
96
109
  [0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
package/README.md CHANGED
@@ -6,106 +6,24 @@ of [chatwoot-discord-relay](https://github.com/Phala-Network/chatwoot-workers/tr
6
6
 
7
7
  ## How it works
8
8
 
9
- Chatwoot owns the lifecycle: a conversation is the bot's while pending, and people's otherwise.
10
-
11
- System One is TypeSafe's class of decision models, and Jev is its first model. The router calls TypeSafe's
12
- System One API (`/v1/systemone`) with the model in `routing.model` (Jev by default). `routing.endpoint`
13
- may point to another deployment of that API, for example a proxy or gateway.
14
-
15
- - Each routed account has a configured brand bot. Routing is limited to inboxes linked to that exact account's
16
- bot, discovered through `GET inboxes/{id}/agent_bot`. The wrapped `agent_bot.id` and `account_id` must match;
17
- `agent_bot: null` or `agent_bot: {}` means unlinked. Foreign-account and system bots are rejected. The user token
18
- must see every routed inbox. Disconnecting an inbox stops classification/replies and hands any pending ticket still assigned to this bot to people. The API does not expose whether the
19
- association is inactive; disconnect to disable it. `routing.botIds` and both bot credential maps require
20
- exactly the routed account keys (see [Configuration reference](#configuration-reference)).
21
- - Each account+conversation has its own SQLite `Router` Durable Object: its queue, memo, reply records and turn
22
- guards have one writer. A slow conversation cannot occupy another conversation’s alarm. A separate `Coordinator`
23
- Durable Object pages the account lists and durably enqueues each conversation in its own object.
24
- - Signed bot webhooks at `/chatwoot/agent-bot` enqueue a deduplicated conversation job. The five-minute sweep
25
- lists only **pending and open conversations in the account**, including disconnected inboxes, without an age
26
- cutoff. Pending tickets route or hand off; open tickets still assigned to the brand bot release that assignment
27
- with `POST assignments {assignee_id: null}`, preserving status after a fresh ownership read. Resolved/snoozed
28
- history is not scanned. Each status has its own persistent page cursor within the request budget. Failed pages
29
- retry. An empty page ends that status's pass; the next starts at page 1 and catches
30
- conversations skipped by changing pages. Neither event payloads nor sweep rows are decision inputs.
31
- - Read messages newest-first, unfiltered (including activities), with `before` paging, at most five pages of 20.
32
- The latest `conversation_status_changed` activity begins the turn; without one or evidence it is missing, use
33
- the conversation's start. Take the first three usable customer texts after the boundary, oldest first. Include
34
- email subjects and reply text without quoted history; omit automatic email, deleted messages and private notes.
35
- Redact identifiers and contact names, then cap the input at 1,600 characters. Memoize Jev's decision by account, conversation, input ids, actual redacted input and routing configuration/model.
36
- - Status activity is asynchronous. The queue's boundary guard remembers an observed/expected transition and the
37
- last boundary needed to reject stale handoff work. An activity read before its first status webhook can match
38
- that webhook's status and time only if no existing expectation requires a newer activity. Merging a webhook
39
- never lowers or removes an expected boundary id. Once matched, a different transition requires an activity
40
- **after that boundary id**, even when the old activity has the same status and second-level timestamp. Repeated
41
- delivery of the same transition timestamp does not start another expectation. Only a status webhook supplies a transition time;
42
- a read observing pending end requires a newer activity without inferring a time from `updated_at`. The last
43
- pending observation survives retries, so a retry confirming resolution after a lost response retains that boundary.
44
- Other non-pending snapshots only release a remaining brand-bot assignment: metadata updates change `updated_at` without creating a status activity.
45
- An expected activity not yet present retries normally; an incomplete read or a deleted known boundary hands off.
46
- Permanently missing activity uses the same three-attempt limit. A new boundary clears the previous turn's failure
47
- count and handoff. Chatwoot exposes no turn API: a lost webhook plus permanently removed, never-observed activity
48
- cannot be reconstructed. Chatwoot writes activity only for a changed status with activity content; no-op status
49
- changes create none. Activities use job execution time, and customer/scheduled reopen usually creates none.
50
- A late activity ordered after new text does not license reading old text across the boundary; empty input hands off.
51
- - Jev chooses owner, topic, kind and whether there is a request. A confident kind takes precedence; otherwise a
52
- confident greeting/no-request stays pending while fewer than three texts exist. Empty or identifier-only input,
53
- three greetings, an unclear owner with an actual request, or a public human reply hands off. Blocked contacts
54
- and person-assigned conversations are untouched during routing; disconnect handoff also clears blocked bot leftovers.
55
- Bot and user assignees are distinguished by `assignee_type`.
56
- - Before **each** action, re-read the inbox link, pending status, assignee, turn boundary, inputs and public human
57
- replies. A changed input defers the job to decide again. Apply topic/kind labels, then the kind's canned reply,
58
- then end the turn: set the kind's resolved/snoozed status and immediately release the bot, assign a confident
59
- owner, or use explicit bot `status=open` handoff. Chatwoot keeps the bot on resolve/snooze, so release is part of
60
- that same ending, with a fresh ownership/status read. A failed release keeps the durable job for retry, even
61
- though the sweep excludes closed history. Clearing only the bot creates no assignment/status activity and does
62
- not move the turn boundary; Chatwoot still dispatches assignment/update events. Preserve human topic labels and
63
- validate that an owner still belongs to the account.
64
- Successful person assignment ends the turn without a bot-handoff reporting event; nothing follows it.
65
- Reopened resolved conversations can lack a bot assignee, in which case assignment would not open
66
- pending: use native handoff instead. Chatwoot has no atomic compare-and-write API for a change racing a mutation.
67
- - Only canned replies have an action record, `reply:<account>:<conversation>`, kept across turns and upgrades.
68
- Before sending, read up to five unfiltered history pages (100 messages), past turn boundaries, for a public outgoing
69
- message (`message_type=1`, `private=false`, `sender.type=agent_bot`) from this account's exact configured bot id.
70
- A confirmed, non-deleted, non-failed reply counts as replied, including replies the old relay sent; preserve an
71
- observed record so later removal cannot permit a resend. A complete read to the beginning with none permits the
72
- first attempt; an incomplete/failed/ambiguous read or an existing attempt without a visible reply hands off.
73
- Record the attempt **before sending**. A failed POST, lost response, malformed receipt or failed/deleted reply
74
- persists handoff and never resolves/snoozes or resends. Only a valid creation receipt for this conversation or
75
- confirmed history permits the kind's ending status. This confirms creation in Chatwoot, not channel delivery;
76
- a delivery failure seen in the final fresh read also hands off. Labels, assignment and status use
77
- current Chatwoot state, without an effects ledger or custom coordination attributes. A missing canned response
78
- hands off immediately. After three processing failures (initial, +5s, +10s), persist handoff mode. Further retries
79
- only revalidate the turn and hand off, with backoff capped at 30 minutes. Handoff failures keep the job; deleted
80
- conversations end it. Disconnected brand-bot pending tickets use native bot `status=open` handoff without Jev
81
- or a turn-history read; failures retry until the handoff succeeds. Other bots and people are untouched. Credentials/service failures need repair before handoff can succeed.
82
- - A Worker 2xx means the job is durable, not that routing succeeded. Chatwoot's own webhook failure fallback cannot
83
- cover later alarm failures. Its fallback opens pending on failed `message_created`/`message_updated` delivery unless
84
- `keep_pending_on_bot_failure` is enabled; it may leave a bot assignee on open. The router releases that assignment on its next job or sweep.
85
- Chatwoot makes three delivery attempts for 429/500 responses. Irrelevant valid bot events are acknowledged.
86
- There is no router account-webhook endpoint.
87
-
88
- After handoff, later customer messages belong to people. Resolved tickets reopen pending in an active bot inbox
89
- and are decided on their **new turn's** messages. Snoozed tickets reopen open and go to people. A canned reply
90
- following a customer message counts as answering it in the relay, even if that message was outside Jev's window.
91
-
92
- The API contracts were checked against Chatwoot v4.18.0:
93
- [assignment service](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/services/conversations/assignment_service.rb)
94
- and [controller](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations/assignments_controller.rb),
95
- [status changes](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations_controller.rb),
96
- [activity creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/concerns/activity_message_handler.rb)
97
- and its [job](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/jobs/conversations/activity_message_job.rb),
98
- [message paging](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/finders/message_finder.rb),
99
- [message creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/builders/messages/message_builder.rb),
100
- [message JSON](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/models/_message.json.jbuilder),
101
- [bot sender](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/agent_bot.rb),
102
- [account access](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/ensure_current_account_helper.rb),
103
- [conversation access](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/policies/conversation_policy.rb),
104
- [inbox bot response](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/accounts/inboxes/agent_bot.json.jbuilder),
105
- [assignees/timestamps](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/presenters/conversations/event_data_presenter.rb),
106
- [token permissions](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/access_token_auth_helper.rb),
107
- [bot events](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/listeners/agent_bot_listener.rb), and
108
- [webhook fallback](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/lib/webhooks/trigger.rb).
9
+ Chatwoot owns the lifecycle: a conversation is the bot's while it is pending, and people's otherwise. The router
10
+ is each routed account's native agent bot, connected to the inboxes it should route.
11
+
12
+ 1. A new ticket in a connected inbox is pending, so Chatwoot sends its events to the router's
13
+ `/chatwoot/agent-bot` webhook. Each conversation is routed by a Durable Object of its own, so a slow request
14
+ for one conversation never delays another.
15
+ 2. The router reads the customer's first messages of this turn (redacted, at most three and 1,600 characters)
16
+ and asks TypeSafe's System One API (`/v1/systemone`, model `routing.model`, Jev by default; `routing.endpoint`
17
+ may point to another deployment) for the owner, topic, kind, and whether there is a request yet.
18
+ 3. It applies the topic and kind labels, sends the kind's canned reply if it has one (at most once per
19
+ conversation), and ends the turn: the kind's resolved or snoozed status, the confident owner, or a handoff to
20
+ people (`status=open`). A greeting with no request yet stays pending until the customer says more.
21
+ 4. Anything uncertain hands off to people: an unclear owner, a failed or unconfirmed reply, a human replying,
22
+ three failed attempts. Disconnecting an inbox's bot hands its pending tickets to people.
23
+ 5. A sweep every five minutes lists pending and open conversations, so a missed webhook is caught up and a bot
24
+ assignment left on an open ticket is released.
25
+
26
+ [Routing behavior](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/docs/behavior.md) has the precise rules and the Chatwoot contracts they rest on.
109
27
 
110
28
  ## Deploy
111
29
 
@@ -123,7 +41,7 @@ npx cf deploy --prebuilt --dry-run
123
41
  Edit `cloudflare.config.ts` with your non-secret settings. Keep required secrets out of source control;
124
42
  `.dev.vars.example` lists placeholders. For local development copy it to `.dev.vars` and use `npm run dev`.
125
43
  A maintainer deploys with `npx cf deploy --secrets-file <private-file>`.
126
- The default Worker name is `chatwoot-router`; it exports SQLite Durable Objects `Router` (bound as `ROUTER`) and `Coordinator` (bound as `COORDINATOR`).
44
+ The default Worker name is `chatwoot-router`; it exports one SQLite Durable Object, `Router`, bound as `ROUTER`.
127
45
  Keep the `*/5 * * * *` cron trigger for reconciliation. `GET /healthz` returns 200 for a valid, readable
128
46
  configuration and 503 otherwise; it does not test upstream credentials or connectivity.
129
47
 
@@ -131,12 +49,12 @@ For a private deployment repository, depend on the public `chatwoot-router` npm
131
49
  `@cloudflare/vite-plugin`. Its Worker entry is:
132
50
 
133
51
  ```ts
134
- export { default, Router, Coordinator } from "chatwoot-router";
52
+ export { default, Router } from "chatwoot-router";
135
53
  ```
136
54
 
137
55
  Use the same `cloudflare.config.ts` and `vite.config.ts` structure as this package. Pin compatible tooling versions
138
- from the workspace and root manifests. Declare `Router` and `Coordinator` with `exports.durableObject({ storage: "sqlite" })`, bind it as
139
- `ROUTER` and `COORDINATOR` respectively, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
56
+ from the workspace and root manifests. Declare `Router` with `exports.durableObject({ storage: "sqlite" })`, bind it as
57
+ `ROUTER`, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
140
58
 
141
59
  For a configuration larger than a Worker JSON binding, use immutable KV configuration instead:
142
60
 
@@ -221,12 +139,10 @@ fail startup; `/healthz` returns 503 without credentials in its response. All se
221
139
 
222
140
  ## Upgrade and rollback
223
141
 
224
- 0.2.x was never deployed, so the conversation partition and coordinator require no state migration.
225
-
226
142
  Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
227
143
  permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot
228
144
  rebuild an unknown attempt, an observed reply since deleted, or a missing turn boundary. Do not delete or recreate
229
- any conversation’s Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected
145
+ the Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected
230
146
  and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
231
147
  prove another send is safe. Existing pending conversations are reconciled; non-pending conversations stay with people. The user token must see all relevant inboxes.
232
148
 
@@ -1,8 +1,6 @@
1
1
  import type { ConfigEnv } from "../../../shared/settings.ts";
2
- import type { Coordinator } from "./coordinator.ts";
3
2
  import type { Router } from "./router.ts";
4
3
  export interface Env extends ConfigEnv {
5
- COORDINATOR: DurableObjectNamespace<Coordinator>;
6
4
  ROUTER: DurableObjectNamespace<Router>;
7
5
  CHATWOOT_TOKEN: string;
8
6
  CHATWOOT_AGENT_BOT_SECRETS: string;
@@ -4,5 +4,4 @@ declare const _default: {
4
4
  scheduled(_controller: ScheduledController, env: Env, context: ExecutionContext<unknown>): Promise<void>;
5
5
  };
6
6
  export default _default;
7
- export { Coordinator } from "./coordinator.ts";
8
7
  export { Router } from "./router.ts";
@@ -2,118 +2,6 @@
2
2
  import { Hono } from "hono";
3
3
  import { bodyLimit } from "hono/body-limit";
4
4
 
5
- // ../../shared/deadline.ts
6
- async function within(pending, signal) {
7
- let abort;
8
- try {
9
- return await Promise.race([
10
- pending,
11
- new Promise((_resolve, reject) => {
12
- abort = () => reject(signal.reason);
13
- if (signal.aborted) abort();
14
- else signal.addEventListener("abort", abort, { once: true });
15
- })
16
- ]);
17
- } finally {
18
- if (abort) signal.removeEventListener("abort", abort);
19
- }
20
- }
21
- var JobDeadlineError = class extends Error {
22
- requestStarted;
23
- constructor(requestStarted = false) {
24
- super("Job time slice is used up");
25
- this.name = "JobDeadlineError";
26
- this.requestStarted = requestStarted;
27
- }
28
- };
29
-
30
- // ../../shared/budget.ts
31
- var BudgetExhaustedError = class extends Error {
32
- constructor() {
33
- super("Subrequest budget for this invocation is used up");
34
- this.name = "BudgetExhaustedError";
35
- }
36
- };
37
- var METADATA_TIMEOUT_MS = 1500;
38
- var JOB_SLICE_MS = 1e4;
39
- var Budget = class {
40
- constructor(limit, fetchImpl = (request) => fetch(request)) {
41
- this.limit = limit;
42
- this.fetchImpl = fetchImpl;
43
- }
44
- limit;
45
- fetchImpl;
46
- used = 0;
47
- slice;
48
- sliceEnds = Number.POSITIVE_INFINITY;
49
- get remaining() {
50
- return this.limit - this.used;
51
- }
52
- startSlice(ms = JOB_SLICE_MS) {
53
- this.sliceEnds = Date.now() + ms;
54
- this.slice = AbortSignal.timeout(ms);
55
- }
56
- requireTime(ms) {
57
- this.checkpoint();
58
- if (Date.now() + ms > this.sliceEnds) throw new JobDeadlineError();
59
- }
60
- checkpoint() {
61
- if (this.slice?.aborted) throw new JobDeadlineError();
62
- }
63
- consume() {
64
- this.checkpoint();
65
- if (this.used >= this.limit) throw new BudgetExhaustedError();
66
- this.used += 1;
67
- }
68
- require(requests) {
69
- this.checkpoint();
70
- if (this.remaining < requests) throw new BudgetExhaustedError();
71
- }
72
- controlSignal(ms) {
73
- return AbortSignal.any([AbortSignal.timeout(ms), ...this.slice ? [this.slice] : []]);
74
- }
75
- fetchWith(timeoutMs) {
76
- return (request) => this.request(request, timeoutMs);
77
- }
78
- fetch = (request) => this.request(request, METADATA_TIMEOUT_MS);
79
- async request(request, timeoutMs) {
80
- this.consume();
81
- const operation = AbortSignal.timeout(timeoutMs);
82
- const signal = AbortSignal.any([request.signal, operation, ...this.slice ? [this.slice] : []]);
83
- try {
84
- const response = await within(this.fetchImpl(new Request(request, { signal })), signal);
85
- if (!response.body) return response;
86
- const reader = response.body.getReader();
87
- const slice = this.slice;
88
- const body = new ReadableStream({
89
- async pull(controller) {
90
- try {
91
- const { done, value } = await within(reader.read(), signal);
92
- if (done) controller.close();
93
- else controller.enqueue(value);
94
- } catch (error) {
95
- controller.error(slice?.aborted ? new JobDeadlineError(true) : error);
96
- void reader.cancel().catch(() => {
97
- });
98
- }
99
- },
100
- cancel: (reason) => {
101
- void reader.cancel(reason).catch(() => {
102
- });
103
- }
104
- });
105
- return new Response(body, {
106
- status: response.status,
107
- statusText: response.statusText,
108
- headers: response.headers
109
- });
110
- } catch (error) {
111
- if (this.slice?.aborted) throw new JobDeadlineError(true);
112
- throw error;
113
- }
114
- }
115
- };
116
-
117
5
  // ../../shared/chatwoot/signature.ts
118
6
  var TIMESTAMP_TOLERANCE_SECONDS = 300;
119
7
  var encoder = new TextEncoder();
@@ -177,16 +65,42 @@ function errorFields(error) {
177
65
  return error instanceof Error ? { error: error.name, detail: error.message } : { error: String(error) };
178
66
  }
179
67
 
180
- // src/coordinator.ts
181
- import { DurableObject as DurableObject2 } from "cloudflare:workers";
182
- import { z as z7 } from "zod";
68
+ // src/router.ts
69
+ import { DurableObject } from "cloudflare:workers";
70
+ import { z as z6 } from "zod";
183
71
 
184
- // ../../shared/alarm.ts
185
- async function scheduleAlarm(state, next) {
186
- if (next === void 0) return;
187
- const current = await state.storage.getAlarm();
188
- if (current === null || current > next) await state.storage.setAlarm(Math.max(Date.now(), next));
189
- }
72
+ // ../../shared/budget.ts
73
+ var BudgetExhaustedError = class extends Error {
74
+ constructor() {
75
+ super("Subrequest budget for this invocation is used up");
76
+ this.name = "BudgetExhaustedError";
77
+ }
78
+ };
79
+ var REQUEST_TIMEOUT_MS = 6e4;
80
+ var Budget = class {
81
+ constructor(limit, fetchImpl = (request) => fetch(request), timeoutMs = REQUEST_TIMEOUT_MS) {
82
+ this.limit = limit;
83
+ this.fetchImpl = fetchImpl;
84
+ this.timeoutMs = timeoutMs;
85
+ }
86
+ limit;
87
+ fetchImpl;
88
+ timeoutMs;
89
+ used = 0;
90
+ get remaining() {
91
+ return this.limit - this.used;
92
+ }
93
+ /** Counts a request that is not a fetch, such as a call to another Durable Object. */
94
+ consume() {
95
+ if (this.used >= this.limit) throw new BudgetExhaustedError();
96
+ this.used += 1;
97
+ }
98
+ fetch = (request) => {
99
+ if (this.used >= this.limit) return Promise.reject(new BudgetExhaustedError());
100
+ this.used += 1;
101
+ return this.fetchImpl(new Request(request, { signal: AbortSignal.timeout(this.timeoutMs) }));
102
+ };
103
+ };
190
104
 
191
105
  // ../../shared/chatwoot/api.ts
192
106
  import createClient from "openapi-fetch";
@@ -194,13 +108,11 @@ import { z as z2 } from "zod";
194
108
  var ChatwootError = class extends Error {
195
109
  status;
196
110
  conversationMissing;
197
- retryAfterMs;
198
- constructor(status, operation, conversationMissing = false, retryAfterMs) {
111
+ constructor(status, operation, conversationMissing = false) {
199
112
  super(`Chatwoot ${operation} failed with HTTP ${status}`);
200
113
  this.name = "ChatwootError";
201
114
  this.status = status;
202
115
  this.conversationMissing = conversationMissing;
203
- this.retryAfterMs = retryAfterMs;
204
116
  }
205
117
  };
206
118
  var text = z2.string().nullish();
@@ -252,33 +164,18 @@ var messageSchema = z2.object({
252
164
  attachments: z2.array(attachmentSchema).nullish()
253
165
  });
254
166
  var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
167
+ var CONVERSATIONS_PER_PAGE = 25;
255
168
  var MESSAGE_HISTORY_PAGE_SIZE = 20;
256
169
  var inboxBotSchema = z2.object({
257
170
  agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
258
171
  });
259
- function chatwootClient(baseUrl, token, fetch2, limits) {
172
+ function chatwootClient(baseUrl, token, fetch2) {
260
173
  const client = createClient({
261
174
  baseUrl: baseUrl.replace(/\/+$/, ""),
262
175
  headers: { api_access_token: token },
263
176
  // A redirect is an error, never followed: it could carry the token to another host, and each
264
177
  // hop would be a subrequest the budget does not count. Configure Chatwoot's final URL.
265
- fetch: async (request) => {
266
- const account = /\/accounts\/(\d+)\//.exec(new URL(request.url).pathname)?.[1] ?? "profile";
267
- const key = `chatwoot:limit:${baseUrl}:${account}`;
268
- const wait = Number(limits?.get(key) ?? 0) - Date.now();
269
- if (wait > 0) throw new ChatwootError(429, "rate limited", false, wait);
270
- const response = await fetch2(new Request(request, { redirect: "manual" }));
271
- if (response.status === 429) {
272
- const header = response.headers.get("retry-after");
273
- const seconds = Number(header);
274
- const delay = header === null ? 5e3 : Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
275
- const retryAfterMs = Number.isFinite(delay) ? Math.max(1, delay) : 5e3;
276
- limits?.set(key, String(Date.now() + retryAfterMs), retryAfterMs);
277
- await response.body?.cancel();
278
- throw new ChatwootError(429, "rate limited", false, retryAfterMs);
279
- }
280
- return response;
281
- }
178
+ fetch: (request) => fetch2(new Request(request, { redirect: "manual" }))
282
179
  });
283
180
  async function data(operation, pending) {
284
181
  const { data: data2, response } = await pending;
@@ -559,17 +456,12 @@ function retryDelay(attempts) {
559
456
  return Math.min(5e3 * 2 ** attempts, 30 * 60 * 1e3);
560
457
  }
561
458
  var QueueStore = class {
562
- constructor(sql, now = Date.now, atomic = (write2) => write2()) {
459
+ constructor(sql, now = Date.now) {
563
460
  this.sql = sql;
564
461
  this.now = now;
565
- this.atomic = atomic;
566
462
  }
567
463
  sql;
568
464
  now;
569
- atomic;
570
- transaction(write2) {
571
- return this.atomic(write2);
572
- }
573
465
  migrate() {
574
466
  this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
575
467
  key TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL,
@@ -578,10 +470,6 @@ var QueueStore = class {
578
470
  );
579
471
  CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
580
472
  CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);`);
581
- const columns = this.sql.exec("PRAGMA table_info(jobs)").toArray();
582
- if (!columns.some((column) => column.name === "suspended")) {
583
- this.sql.exec("ALTER TABLE jobs ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0");
584
- }
585
473
  }
586
474
  // Cache
587
475
  get(key) {
@@ -608,7 +496,7 @@ var QueueStore = class {
608
496
  enqueue(key, priority, payload, notBefore = this.now()) {
609
497
  this.sql.exec(
610
498
  `INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
611
- ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload, suspended = 0,
499
+ ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
612
500
  not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
613
501
  key,
614
502
  priority,
@@ -618,24 +506,18 @@ var QueueStore = class {
618
506
  );
619
507
  }
620
508
  nextDueJob() {
621
- return this.nextDueJobs(1)[0];
622
- }
623
- /** A bounded due batch; independent scan and delivery work can select their own keys. */
624
- nextDueJobs(limit, prefix = "") {
625
- return this.sql.exec(
626
- `SELECT key, payload, version, attempts, created_at AS createdAt FROM jobs
627
- WHERE suspended != 2 AND not_before <= ? AND substr(key, 1, ?) = ?
628
- ORDER BY MAX(0, priority - CAST((? - created_at) / 30000 AS INTEGER)), not_before, created_at LIMIT ?`,
629
- this.now(),
630
- prefix.length,
631
- prefix,
632
- this.now(),
633
- limit
634
- ).toArray();
509
+ const row = this.sql.exec(
510
+ `SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
511
+ ORDER BY priority, not_before, created_at LIMIT 1`,
512
+ this.now()
513
+ ).toArray()[0];
514
+ if (!row) return void 0;
515
+ const { created_at: createdAt, ...job } = row;
516
+ return { ...job, createdAt };
635
517
  }
636
518
  /** Earliest due time. */
637
519
  nextWakeup() {
638
- const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs WHERE suspended != 2").toArray()[0];
520
+ const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
639
521
  return row?.at ?? void 0;
640
522
  }
641
523
  /** Removes a finished job unless it was enqueued again while running. */
@@ -660,47 +542,35 @@ var QueueStore = class {
660
542
  deferJob(job, delayMs = 0) {
661
543
  this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
662
544
  }
663
- /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
664
- holdJob(job) {
545
+ /**
546
+ * Makes a job due again after `delayMs`, unless it was queued again while it ran (it is due
547
+ * then), clearing its failures.
548
+ */
549
+ postponeJob(job, delayMs) {
665
550
  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,
551
+ "UPDATE jobs SET not_before = ?, attempts = 0 WHERE key = ? AND version = ?",
552
+ this.now() + delayMs,
669
553
  job.key,
670
554
  job.version
671
555
  );
672
556
  }
673
- /** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
674
- wakeHeldJobs() {
675
- this.sql.exec("UPDATE jobs SET suspended = 0, not_before = ? WHERE suspended = 2", this.now());
676
- }
677
557
  prune() {
678
558
  this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
679
559
  }
680
560
  };
681
561
 
682
- // src/router.ts
683
- import { DurableObject } from "cloudflare:workers";
684
- import { z as z6 } from "zod";
685
-
686
562
  // src/routing.ts
687
563
  import ipRegex from "ip-regex";
688
564
  import { z as z4 } from "zod";
689
565
 
690
- // ../../shared/concurrent.ts
691
- async function parallel(...reads) {
692
- await Promise.allSettled(reads);
693
- return Promise.all(reads);
694
- }
695
-
696
566
  // src/reply.ts
697
567
  function confirmedReply(message, botId) {
698
568
  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
569
  }
700
- async function replyHistory(chatwoot, accountId2, conversationId, botId, freshPages = []) {
570
+ async function replyHistory(chatwoot, accountId2, conversationId, botId) {
701
571
  let before;
702
572
  for (let page = 0; page < 5; page += 1) {
703
- const messages = freshPages[page] ?? await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
573
+ const messages = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
704
574
  for (const message of messages) {
705
575
  if (confirmedReply(message, botId)) return "found";
706
576
  if (message.message_type !== 1 || message.private === true) continue;
@@ -778,14 +648,12 @@ var ActivityPendingError = class extends Error {
778
648
  };
779
649
  async function readTurn(chatwoot, store, accountId2, conversationId) {
780
650
  const messages = [];
781
- const pages = [];
782
651
  let before;
783
652
  let boundary;
784
653
  let complete = false;
785
654
  let deleted = false;
786
655
  for (let page = 0; page < 5; page += 1) {
787
656
  const batch = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
788
- pages.push(batch);
789
657
  for (const message of batch.toReversed()) {
790
658
  if (message.message_type === 2 && message.content_attributes?.deleted) {
791
659
  deleted = true;
@@ -823,7 +691,7 @@ async function readTurn(chatwoot, store, accountId2, conversationId) {
823
691
  }
824
692
  if (deleted || missing || !complete) guard.handoff = true;
825
693
  saveGuard(store, accountId2, conversationId, guard);
826
- return { pages, messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
694
+ return { messages: messages.toReversed(), boundary: boundary?.id ?? 0, handoff: guard.handoff };
827
695
  }
828
696
 
829
697
  // src/routing.ts
@@ -953,9 +821,8 @@ async function routeConversation(ctx, accountId2, conversationId) {
953
821
  const current2 = await snapshot(initial.raw.inbox_id);
954
822
  if (!current2) return;
955
823
  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) };
824
+ if (latest.boundary !== turn.boundary || inputs(latest.messages).key !== input.key) return "defer";
825
+ return { ...current2, handoff: latest.handoff || requiresHandoff(latest.messages) };
959
826
  };
960
827
  const handoff = async (current2) => {
961
828
  requestHandoff(store, accountId2, conversationId);
@@ -965,12 +832,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
965
832
  return void 0;
966
833
  };
967
834
  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}`;
835
+ const memoKey = `decision:${accountId2}:${conversationId}:${input.key}`;
974
836
  const memo = decisionSchema.safeParse(parseJson(store.get(memoKey)));
975
837
  let decision = memo.success ? memo.data : void 0;
976
838
  if (!decision) {
@@ -979,14 +841,6 @@ async function routeConversation(ctx, accountId2, conversationId) {
979
841
  }
980
842
  const kind = decision.kind && decision.kindConfidence >= routing.minConfidence ? kinds[decision.kind] : void 0;
981
843
  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
844
  let current = await fresh();
991
845
  if (current === "defer" || !current) return current;
992
846
  if (current.handoff) return handoff(current);
@@ -998,21 +852,12 @@ async function routeConversation(ctx, accountId2, conversationId) {
998
852
  ...[topic, kind ? decision.kind : null].filter((label) => label !== null)
999
853
  ])
1000
854
  ];
1001
- let changed = false;
1002
- if (labels.length !== current.conversation.labels.length) {
1003
- await bot.setLabels(accountId2, conversationId, labels);
1004
- changed = true;
1005
- }
855
+ if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
856
+ const replyKey = `reply:${accountId2}:${conversationId}`;
1006
857
  if (kind?.cannedResponse) {
1007
858
  let history;
1008
859
  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);
860
+ history = await replyHistory(chatwoot, accountId2, conversationId, botId);
1016
861
  } catch (error) {
1017
862
  log.warn("reply history unavailable; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1018
863
  return handoff();
@@ -1020,6 +865,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
1020
865
  if (history === "unknown" || history === "complete-none" && store.get(replyKey) !== void 0) return handoff();
1021
866
  if (history === "found" && store.get(replyKey) === void 0) store.set(replyKey, "observed");
1022
867
  if (history === "complete-none") {
868
+ const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
1023
869
  if (!content?.trim()) return handoff();
1024
870
  current = await fresh();
1025
871
  if (current === "defer" || !current) return current;
@@ -1028,7 +874,6 @@ async function routeConversation(ctx, accountId2, conversationId) {
1028
874
  try {
1029
875
  const message = await bot.createMessage(accountId2, conversationId, { content, private: false, files: [] });
1030
876
  if (!confirmedReply(message, botId) || message?.conversation_id !== conversationId) return handoff();
1031
- changed = true;
1032
877
  } catch (error) {
1033
878
  log.warn("reply creation unconfirmed; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
1034
879
  return handoff();
@@ -1036,13 +881,12 @@ async function routeConversation(ctx, accountId2, conversationId) {
1036
881
  }
1037
882
  }
1038
883
  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);
884
+ const agents = await chatwoot.listAgents(accountId2);
885
+ if (!agents.some((agent) => agent.id === assignee)) return handoff();
1045
886
  }
887
+ current = await fresh();
888
+ if (current === "defer" || !current) return current;
889
+ if (current.handoff) return handoff(current);
1046
890
  if (kind?.status) {
1047
891
  await bot.setStatus(accountId2, conversationId, { status: kind.status });
1048
892
  expectActivity(store, accountId2, conversationId, { status: kind.status });
@@ -1112,10 +956,7 @@ async function decide(ctx, owners, kinds, text2) {
1112
956
  body: JSON.stringify({ model: routing.model, state: { ticket: text2 }, questions })
1113
957
  })
1114
958
  );
1115
- if (!response.ok) {
1116
- await response.body?.cancel();
1117
- throw new JevError(`HTTP ${response.status}`);
1118
- }
959
+ if (!response.ok) throw new JevError(`HTTP ${response.status}`);
1119
960
  const parsed = jevResponseSchema.safeParse(await response.json().catch(() => void 0));
1120
961
  if (!parsed.success) throw new JevError("invalid response");
1121
962
  const answer = (question) => {
@@ -1157,7 +998,7 @@ async function readConfig(env) {
1157
998
  if (env.CONFIG_KEY === void 0) return typeof env.CONFIG === "string" ? parseJson(env.CONFIG) : env.CONFIG;
1158
999
  if (env.CONFIG !== void 0) throw new ConfigError("Invalid CONFIG: set either CONFIG or CONFIG_KEY");
1159
1000
  if (!env.CONFIG_STORE) throw new ConfigError("Invalid CONFIG_KEY: requires the CONFIG_STORE KV namespace");
1160
- const config = await within(env.CONFIG_STORE.get(env.CONFIG_KEY, "json"), AbortSignal.timeout(1500));
1001
+ const config = await env.CONFIG_STORE.get(env.CONFIG_KEY, "json");
1161
1002
  if (config === null) throw new ConfigError("Invalid CONFIG_KEY: not in CONFIG_STORE");
1162
1003
  return config;
1163
1004
  }
@@ -1263,14 +1104,19 @@ function buildSettings(config, secrets) {
1263
1104
  var loadSettings = settingsLoader(parseSettings);
1264
1105
 
1265
1106
  // src/router.ts
1266
- function conversationName(accountId2, conversationId) {
1267
- return `${accountId2}:${conversationId}`;
1107
+ var SWEEP_NAME = "global";
1108
+ var BUDGET = { route: 45, sweep: 1 + CONVERSATIONS_PER_PAGE };
1109
+ function sweeper(env) {
1110
+ return env.ROUTER.getByName(SWEEP_NAME);
1111
+ }
1112
+ function conversationRouter(env, accountId2, conversationId) {
1113
+ return env.ROUTER.getByName(`${accountId2}:${conversationId}`);
1268
1114
  }
1269
- var ROUTE_BUDGET = 45;
1270
1115
  var RUN_WALL_MS = 5 * 60 * 1e3;
1271
1116
  var id = z6.number().int().positive();
1272
1117
  var jobSchema = z6.discriminatedUnion("type", [
1273
- z6.object({ type: z6.literal("route"), accountId: id, conversationId: id })
1118
+ z6.object({ type: z6.literal("route"), accountId: id, conversationId: id }),
1119
+ z6.object({ type: z6.literal("sweep"), accountId: id, status: z6.enum(["pending", "open"]) })
1274
1120
  ]);
1275
1121
  var Router = class extends DurableObject {
1276
1122
  store;
@@ -1284,7 +1130,14 @@ var Router = class extends DurableObject {
1284
1130
  if (!routesAccount(settings, accountId2)) return;
1285
1131
  if (transition) expectActivity(this.store, accountId2, conversationId, transition);
1286
1132
  this.enqueue({ type: "route", accountId: accountId2, conversationId });
1287
- await scheduleAlarm(this.ctx, this.store.nextWakeup());
1133
+ await this.schedule();
1134
+ }
1135
+ async requestSweep() {
1136
+ for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1137
+ for (const status of ["pending", "open"])
1138
+ this.enqueue({ type: "sweep", accountId: Number(accountId2), status });
1139
+ }
1140
+ await this.schedule();
1288
1141
  }
1289
1142
  async alarm() {
1290
1143
  const settings = await loadSettings(this.env);
@@ -1301,18 +1154,28 @@ var Router = class extends DurableObject {
1301
1154
  continue;
1302
1155
  }
1303
1156
  const payload = parsed.data;
1304
- if (budget.remaining < ROUTE_BUDGET || Date.now() - started > RUN_WALL_MS) {
1157
+ if (budget.remaining < BUDGET[payload.type] || Date.now() - started > RUN_WALL_MS) {
1305
1158
  yielded = true;
1306
1159
  break;
1307
1160
  }
1308
1161
  try {
1309
- const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetchWith(JOB_SLICE_MS) };
1310
- if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1311
- this.store.deferJob(job);
1312
- yielded = true;
1313
- break;
1162
+ const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetch };
1163
+ if (payload.type === "route") {
1164
+ if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1165
+ this.store.deferJob(job);
1166
+ yielded = true;
1167
+ break;
1168
+ }
1169
+ clearFailures(this.store, payload.accountId, payload.conversationId);
1170
+ } else if (routesAccount(settings, payload.accountId)) {
1171
+ await this.sweep(
1172
+ chatwoot,
1173
+ budget,
1174
+ payload.accountId,
1175
+ payload.status,
1176
+ settings.config.routing.botIds[String(payload.accountId)]
1177
+ );
1314
1178
  }
1315
- clearFailures(this.store, payload.accountId, payload.conversationId);
1316
1179
  this.store.completeJob(job);
1317
1180
  } catch (error) {
1318
1181
  if (error instanceof ChatwootError && error.conversationMissing) {
@@ -1325,7 +1188,7 @@ var Router = class extends DurableObject {
1325
1188
  yielded = true;
1326
1189
  break;
1327
1190
  }
1328
- const attempts = recordFailure(this.store, payload.accountId, payload.conversationId);
1191
+ const attempts = payload.type === "route" ? recordFailure(this.store, payload.accountId, payload.conversationId) : job.attempts + 1;
1329
1192
  const delay = retryDelay(attempts - 1);
1330
1193
  const logAt = attempts >= 3 ? log.error : log.warn;
1331
1194
  logAt("job failed; will retry", {
@@ -1337,126 +1200,62 @@ var Router = class extends DurableObject {
1337
1200
  this.store.retryJob(job, delay);
1338
1201
  }
1339
1202
  }
1340
- await scheduleAlarm(this.ctx, yielded ? Date.now() : this.store.nextWakeup());
1203
+ await this.schedule(yielded ? Date.now() : void 0);
1204
+ }
1205
+ async sweep(chatwoot, budget, accountId2, status, botId) {
1206
+ const key = `sweep:${accountId2}:${status}`;
1207
+ const saved = id.safeParse(parseJson(this.store.get(key)));
1208
+ const page = saved.success ? saved.data : 1;
1209
+ const conversations = await chatwoot.listConversations(accountId2, page, status);
1210
+ const routed = conversations.flatMap(
1211
+ (conversation) => conversation.id !== void 0 && (status === "pending" || conversation.meta?.assignee_type === "AgentBot" && conversation.meta.assignee?.id === botId) ? [conversation.id] : []
1212
+ );
1213
+ for (const _ of routed) budget.consume();
1214
+ const results = await Promise.allSettled(
1215
+ routed.map(
1216
+ (conversationId) => conversationRouter(this.env, accountId2, conversationId).enqueueConversation(accountId2, conversationId)
1217
+ )
1218
+ );
1219
+ results.forEach((result, index) => {
1220
+ if (result.status === "rejected")
1221
+ log.warn("sweep could not hand over a conversation", {
1222
+ accountId: accountId2,
1223
+ conversationId: routed[index],
1224
+ ...errorFields(result.reason)
1225
+ });
1226
+ });
1227
+ if (conversations.length === 0) {
1228
+ this.store.delete(key);
1229
+ return;
1230
+ }
1231
+ this.store.set(key, String(page + 1));
1232
+ this.enqueue({ type: "sweep", accountId: accountId2, status });
1341
1233
  }
1342
1234
  enqueue(payload) {
1343
- this.store.enqueue(`route:${payload.accountId}:${payload.conversationId}`, 0, JSON.stringify(payload));
1235
+ const key = payload.type === "sweep" ? `sweep:${payload.accountId}:${payload.status}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
1236
+ this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
1344
1237
  }
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());
1238
+ async schedule(at) {
1239
+ const next = at ?? this.store.nextWakeup();
1240
+ if (next === void 0) return;
1241
+ const current = await this.ctx.storage.getAlarm();
1242
+ if (current === null || current > next) await this.ctx.storage.setAlarm(next);
1444
1243
  }
1445
1244
  };
1446
1245
 
1447
1246
  // src/webhook.ts
1448
- import { z as z8 } from "zod";
1449
- var id2 = z8.number().int().positive();
1450
- var eventSchema = z8.object({
1451
- event: z8.string(),
1452
- account: z8.object({ id: id2 }),
1247
+ import { z as z7 } from "zod";
1248
+ var id2 = z7.number().int().positive();
1249
+ var eventSchema = z7.object({
1250
+ event: z7.string(),
1251
+ account: z7.object({ id: id2 }),
1453
1252
  id: id2.optional(),
1454
- conversation: z8.object({ id: id2 }).optional(),
1455
- message_type: z8.union([z8.string(), z8.number()]).optional(),
1456
- private: z8.boolean().optional(),
1457
- updated_at: z8.number().optional(),
1458
- changed_attributes: z8.array(z8.object({ status: z8.object({ previous_value: z8.string(), current_value: z8.string() }).optional() })).optional(),
1459
- sender: z8.object({ type: z8.string().optional() }).nullish()
1253
+ conversation: z7.object({ id: id2 }).optional(),
1254
+ message_type: z7.union([z7.string(), z7.number()]).optional(),
1255
+ private: z7.boolean().optional(),
1256
+ updated_at: z7.number().optional(),
1257
+ changed_attributes: z7.array(z7.object({ status: z7.object({ previous_value: z7.string(), current_value: z7.string() }).optional() })).optional(),
1258
+ sender: z7.object({ type: z7.string().optional() }).nullish()
1460
1259
  });
1461
1260
  var CONVERSATION_EVENTS = /* @__PURE__ */ new Set([
1462
1261
  "conversation_opened",
@@ -1520,13 +1319,10 @@ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (
1520
1319
  if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1521
1320
  return context.text("account does not match the webhook secret", 403);
1522
1321
  }
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)
1322
+ await conversationRouter(context.env, target.accountId, target.conversationId).enqueueConversation(
1323
+ target.accountId,
1324
+ target.conversationId,
1325
+ target.transition
1530
1326
  );
1531
1327
  return context.json({ ok: true });
1532
1328
  });
@@ -1538,13 +1334,10 @@ app.onError((error, context) => {
1538
1334
  var src_default = {
1539
1335
  fetch: app.fetch,
1540
1336
  async scheduled(_controller, env, context) {
1541
- context.waitUntil(
1542
- within(env.COORDINATOR.getByName(COORDINATOR_NAME).requestSweep(), AbortSignal.timeout(METADATA_TIMEOUT_MS))
1543
- );
1337
+ context.waitUntil(sweeper(env).requestSweep());
1544
1338
  }
1545
1339
  };
1546
1340
  export {
1547
- Coordinator,
1548
1341
  Router,
1549
1342
  src_default as default
1550
1343
  };
@@ -2,4 +2,4 @@ import { type ChatwootClient, type ChatwootMessage } from "../../../shared/chatw
2
2
  /** A Chatwoot creation receipt, not a channel delivery receipt. */
3
3
  export declare function confirmedReply(message: ChatwootMessage | undefined, botId: number | undefined): boolean;
4
4
  /** Read past turn boundaries, including replies made by the former relay's brand bot. */
5
- export declare function replyHistory(chatwoot: ChatwootClient, accountId: number, conversationId: number, botId: number | undefined, freshPages?: readonly ChatwootMessage[][]): Promise<"found" | "complete-none" | "unknown">;
5
+ export declare function replyHistory(chatwoot: ChatwootClient, accountId: number, conversationId: number, botId: number | undefined): Promise<"found" | "complete-none" | "unknown">;
@@ -1,11 +1,15 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import type { Env } from "./env.ts";
3
3
  import type { Transition } from "./webhook.ts";
4
- export declare function conversationName(accountId: number, conversationId: number): string;
4
+ export declare function sweeper(env: Env): DurableObjectStub<Router>;
5
+ export declare function conversationRouter(env: Env, accountId: number, conversationId: number): DurableObjectStub<Router>;
5
6
  export declare class Router extends DurableObject<Env> {
6
7
  private readonly store;
7
8
  constructor(context: DurableObjectState, env: Env);
8
9
  enqueueConversation(accountId: number, conversationId: number, transition?: Transition): Promise<void>;
10
+ requestSweep(): Promise<void>;
9
11
  alarm(): Promise<void>;
12
+ private sweep;
10
13
  private enqueue;
14
+ private schedule;
11
15
  }
@@ -9,58 +9,6 @@ export declare function requestHandoff(store: RoutingStore, accountId: number, c
9
9
  export declare function recordFailure(store: RoutingStore, accountId: number, conversationId: number): number;
10
10
  export declare function clearFailures(store: RoutingStore, accountId: number, conversationId: number): void;
11
11
  export declare function readTurn(chatwoot: ChatwootClient, store: RoutingStore, accountId: number, conversationId: number): Promise<{
12
- pages: {
13
- id: number;
14
- conversation_id?: number | undefined;
15
- content?: string | null | undefined;
16
- message_type: number;
17
- content_type?: string | null | undefined;
18
- status?: string | null | undefined;
19
- created_at?: number | null | undefined;
20
- private?: boolean | null | undefined;
21
- content_attributes?: {
22
- email?: {
23
- subject?: string | null | undefined;
24
- auto_reply?: boolean | null | undefined;
25
- text_content?: {
26
- quoted?: string | null | undefined;
27
- } | null | undefined;
28
- html_content?: {
29
- quoted?: string | null | undefined;
30
- } | null | undefined;
31
- } | null | undefined;
32
- deleted?: boolean | null | undefined;
33
- external_error?: string | null | undefined;
34
- activity?: {
35
- type?: string | null | undefined;
36
- status?: string | null | undefined;
37
- } | null | undefined;
38
- submitted_values?: unknown;
39
- submitted_email?: unknown;
40
- items?: unknown;
41
- } | null | undefined;
42
- sender?: {
43
- id?: number | null | undefined;
44
- name?: string | null | undefined;
45
- email?: string | null | undefined;
46
- type?: string | null | undefined;
47
- thumbnail?: string | null | undefined;
48
- avatar_url?: string | null | undefined;
49
- } | null | undefined;
50
- attachments?: {
51
- file_type?: string | null | undefined;
52
- data_url?: string | null | undefined;
53
- fallback_title?: string | null | undefined;
54
- coordinates_lat?: number | null | undefined;
55
- coordinates_long?: number | null | undefined;
56
- meta?: {
57
- firstName?: string | null | undefined;
58
- lastName?: string | null | undefined;
59
- first_name?: string | null | undefined;
60
- last_name?: string | null | undefined;
61
- } | null | undefined;
62
- }[] | null | undefined;
63
- }[][];
64
12
  messages: {
65
13
  id: number;
66
14
  conversation_id?: number | undefined;
@@ -2,25 +2,14 @@ import type { Fetch } from "./chatwoot/api.ts";
2
2
  export declare class BudgetExhaustedError extends Error {
3
3
  constructor();
4
4
  }
5
- export { JobDeadlineError } from "./deadline.ts";
6
- export declare const METADATA_TIMEOUT_MS = 1500;
7
- export declare const TRANSFER_TIMEOUT_MS = 8000;
8
- export declare const JOB_SLICE_MS = 10000;
9
5
  export declare class Budget {
10
6
  readonly limit: number;
11
7
  private readonly fetchImpl;
8
+ private readonly timeoutMs;
12
9
  private used;
13
- private slice;
14
- private sliceEnds;
15
- constructor(limit: number, fetchImpl?: Fetch);
10
+ constructor(limit: number, fetchImpl?: Fetch, timeoutMs?: number);
16
11
  get remaining(): number;
17
- startSlice(ms?: number): void;
18
- requireTime(ms: number): void;
19
- checkpoint(): void;
12
+ /** Counts a request that is not a fetch, such as a call to another Durable Object. */
20
13
  consume(): void;
21
- require(requests: number): void;
22
- controlSignal(ms: number): AbortSignal;
23
- fetchWith(timeoutMs: number): Fetch;
24
14
  readonly fetch: Fetch;
25
- private request;
26
15
  }
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import type { RateLimitStore } from "../rate-limit.ts";
3
2
  import type { RelayConversation, RelayMessage } from "../types.ts";
4
3
  import type { components, operations } from "./schema.ts";
5
4
  export type Fetch = (input: Request) => Promise<Response>;
@@ -7,8 +6,7 @@ export type Fetch = (input: Request) => Promise<Response>;
7
6
  export declare class ChatwootError extends Error {
8
7
  readonly status: number;
9
8
  readonly conversationMissing: boolean;
10
- readonly retryAfterMs: number | undefined;
11
- constructor(status: number, operation: string, conversationMissing?: boolean, retryAfterMs?: number);
9
+ constructor(status: number, operation: string, conversationMissing?: boolean);
12
10
  }
13
11
  /**
14
12
  * A conversation as returned by GET conversations/{id} and in the conversation list. The spec's
@@ -99,7 +97,7 @@ export type ChatwootClient = ReturnType<typeof chatwootClient>;
99
97
  type MessageQuery = NonNullable<operations["list-all-messages"]["parameters"]["query"]> & {
100
98
  filter_internal_messages?: true;
101
99
  };
102
- export declare function chatwootClient(baseUrl: string, token: string, fetch: Fetch, limits?: RateLimitStore): {
100
+ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fetch): {
103
101
  /** The conversation, or undefined when it does not exist (it was deleted). */
104
102
  getConversation(accountId: number, conversationId: number): Promise<ChatwootConversation | undefined>;
105
103
  /** Messages with an id above `after`, oldest first; without `after`, the latest page. */
@@ -1,18 +1,16 @@
1
1
  export declare function retryDelay(attempts: number): number;
2
- export type Job = {
2
+ export interface Job {
3
3
  key: string;
4
4
  payload: string;
5
5
  version: number;
6
6
  attempts: number;
7
7
  /** When the job was first queued (ms since the epoch). */
8
8
  createdAt: number;
9
- };
9
+ }
10
10
  export declare class QueueStore {
11
11
  protected readonly sql: SqlStorage;
12
12
  protected readonly now: () => number;
13
- private readonly atomic;
14
- constructor(sql: SqlStorage, now?: () => number, atomic?: <T>(write: () => T) => T);
15
- transaction<T>(write: () => T): T;
13
+ constructor(sql: SqlStorage, now?: () => number);
16
14
  migrate(): void;
17
15
  get(key: string): string | undefined;
18
16
  set(key: string, value: string, ttlMs?: number): void;
@@ -23,8 +21,6 @@ export declare class QueueStore {
23
21
  */
24
22
  enqueue(key: string, priority: number, payload: string, notBefore?: number): void;
25
23
  nextDueJob(): Job | undefined;
26
- /** A bounded due batch; independent scan and delivery work can select their own keys. */
27
- nextDueJobs(limit: number, prefix?: string): Job[];
28
24
  /** Earliest due time. */
29
25
  nextWakeup(): number | undefined;
30
26
  /** Removes a finished job unless it was enqueued again while running. */
@@ -36,9 +32,10 @@ export declare class QueueStore {
36
32
  * budget, or Discord rate limits it.
37
33
  */
38
34
  deferJob(job: Job, delayMs?: number): void;
39
- /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
40
- holdJob(job: Job): void;
41
- /** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
42
- wakeHeldJobs(): void;
35
+ /**
36
+ * Makes a job due again after `delayMs`, unless it was queued again while it ran (it is due
37
+ * then), clearing its failures.
38
+ */
39
+ postponeJob(job: Job, delayMs: number): void;
43
40
  prune(): void;
44
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatwoot-router",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "chatwoot",
@@ -1,10 +0,0 @@
1
- import { DurableObject } from "cloudflare:workers";
2
- import type { Env } from "./env.ts";
3
- export declare const COORDINATOR_NAME = "global";
4
- /** Lists one page per alarm. Conversation state belongs exclusively to the conversation's Router. */
5
- export declare class Coordinator extends DurableObject<Env> {
6
- private readonly store;
7
- constructor(ctx: DurableObjectState, env: Env);
8
- requestSweep(): Promise<void>;
9
- alarm(): Promise<void>;
10
- }
@@ -1,2 +0,0 @@
1
- /** Set the one Durable Object alarm for the earliest durable wake-up. */
2
- export declare function scheduleAlarm(state: DurableObjectState, next: number | undefined): Promise<void>;
@@ -1,4 +0,0 @@
1
- /** Finish or cancel every independent read before the caller can yield to another job. */
2
- export declare function parallel<T extends readonly unknown[]>(...reads: {
3
- [K in keyof T]: Promise<T[K]>;
4
- }): Promise<T>;
@@ -1,6 +0,0 @@
1
- /** Bounds non-fetch dependencies too, including an RPC whose enqueue outcome can be unknown. */
2
- export declare function within<T>(pending: Promise<T>, signal: AbortSignal): Promise<T>;
3
- export declare class JobDeadlineError extends Error {
4
- readonly requestStarted: boolean;
5
- constructor(requestStarted?: boolean);
6
- }
@@ -1,5 +0,0 @@
1
- /** Cache-aside state shared by upstream clients' durable cooldown stores. */
2
- export interface RateLimitStore {
3
- get(key: string): string | undefined;
4
- set(key: string, value: string, ttlMs?: number): void;
5
- }