chatwoot-router 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-03
11
+
12
+ ### Fixed
13
+
14
+ - End a kind's turn with resolved/snoozed only after its canned reply is confirmed to exist in Chatwoot. Failed,
15
+ unknown or malformed creation outcomes, incomplete history, and attempts without visible replies hand off to
16
+ people without sending again. A reply seen as failed/deleted in the final fresh read also hands off.
17
+ - Recognize historical public outgoing messages from the account's exact brand bot, including the former relay's
18
+ replies, without moving reply ledgers. Preserve the durable attempt/observed guard across turns and upgrades.
19
+ - Disconnecting an inbox hands its pending brand-bot leftovers to people with native bot `status=open`. The sweep
20
+ reads pending/open account conversations, including old disconnected inboxes, and retries failed handoffs.
21
+ - Release the brand bot immediately after a kind's resolved/snoozed status using its standard unassignment API;
22
+ durable retries finish failed releases. Preserve status, turn boundaries and other owners. The open sweep heals
23
+ Chatwoot webhook-failure fallbacks without scanning resolved/snoozed history.
24
+
25
+ ### Changed
26
+
27
+ - Partition routing by account and conversation, with a separate paged `Coordinator`. Each conversation owns its
28
+ queue, decisions and reply/turn guards; slow conversations no longer occupy another conversation's executor.
29
+ - Bound webhook admission and sweep child RPC waits. Persist scan progress independently of failed child
30
+ deliveries, so retries retain unavailable targets while later pages and healthy conversations continue.
31
+ - Reuse message pages only within one fresh phase, combine independent read-only preparation, and scope cached
32
+ decisions to actual redacted input and routing configuration/model. Preserve fresh side-effect checks and the
33
+ existing bounded history reads and 45-request budget; inbox-bot ownership is read fresh instead of cached.
34
+
35
+ ### Upgrade
36
+
37
+ - This minor release changes deployment bindings. Export and declare SQLite `Router` and `Coordinator`, bound as
38
+ `ROUTER` and `COORDINATOR`, and keep the five-minute reconciliation trigger. Conversation Router identities are
39
+ now `<account>:<conversation>`; account for any existing global Router guards before enabling
40
+ new owners. Keep Worker identities, namespaces/storage and immutable configuration keys.
41
+ - Deploy chatwoot-discord-relay 0.31.0 first, completing its separately authorized
42
+ [partition adoption](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-discord-relay/docs/adoption.md)
43
+ when upgrading an existing Hub. Verify the sole routing owner before connecting bots one account at a time;
44
+ health checks and webhook acknowledgements do not establish successful routing or channel delivery.
45
+ - To stop routing, disconnect bots but retain credentials and keep this router running until pending/open account
46
+ passes and durable ending retries clear all brand-bot ownership. Verify resolved/snoozed endings too, preserving
47
+ human and other-bot owners. Only then stop ingress, cron, queued alarms and in-flight work.
48
+ - Preserve permanent reply attempts, observed/deleted-reply records, unknown guards and turn boundaries. Empty
49
+ history cannot reconstruct lost guards or justify another send. The legacy relay's reply ledger is independent;
50
+ restoring old code/configuration does not transfer Router-only replies or unknown attempts. Once new effects
51
+ exist, use forward repair in the current namespaces; direct version rollback to relay 0.27 is not a safe handover
52
+ and Cloudflare rollback cannot cross Durable Object class/lifecycle changes.
53
+
10
54
  ## [0.2.0] - 2026-10-03
11
55
 
12
56
  ### Changed
@@ -46,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
46
90
  `routing_kind`.
47
91
  - The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
48
92
 
49
- [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...HEAD
93
+ [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...HEAD
94
+ [0.3.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...chatwoot-router@0.3.0
50
95
  [0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
51
96
  [0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
package/README.md CHANGED
@@ -15,18 +15,24 @@ may point to another deployment of that API, for example a proxy or gateway.
15
15
  - Each routed account has a configured brand bot. Routing is limited to inboxes linked to that exact account's
16
16
  bot, discovered through `GET inboxes/{id}/agent_bot`. The wrapped `agent_bot.id` and `account_id` must match;
17
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 routing. The API does not expose whether the
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
19
  association is inactive; disconnect to disable it. `routing.botIds` and both bot credential maps require
20
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.
21
24
  - Signed bot webhooks at `/chatwoot/agent-bot` enqueue a deduplicated conversation job. The five-minute sweep
22
- lists **pending** conversations without an age cutoff. Inbox discovery and page cursors persist across alarms
23
- within the request budget. Failed pages retry. An empty page ends a pass; the next starts at page 1 and catches
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
24
30
  conversations skipped by changing pages. Neither event payloads nor sweep rows are decision inputs.
25
31
  - Read messages newest-first, unfiltered (including activities), with `before` paging, at most five pages of 20.
26
32
  The latest `conversation_status_changed` activity begins the turn; without one or evidence it is missing, use
27
33
  the conversation's start. Take the first three usable customer texts after the boundary, oldest first. Include
28
34
  email subjects and reply text without quoted history; omit automatic email, deleted messages and private notes.
29
- Redact identifiers and contact names, then cap the input at 1,600 characters. Memoize Jev's decision by input ids.
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.
30
36
  - Status activity is asynchronous. The queue's boundary guard remembers an observed/expected transition and the
31
37
  last boundary needed to reject stale handoff work. An activity read before its first status webhook can match
32
38
  that webhook's status and time only if no existing expectation requires a newer activity. Merging a webhook
@@ -35,7 +41,7 @@ may point to another deployment of that API, for example a proxy or gateway.
35
41
  delivery of the same transition timestamp does not start another expectation. Only a status webhook supplies a transition time;
36
42
  a read observing pending end requires a newer activity without inferring a time from `updated_at`. The last
37
43
  pending observation survives retries, so a retry confirming resolution after a lost response retains that boundary.
38
- Other non-pending snapshots exit: metadata updates change `updated_at` without creating a status activity.
44
+ Other non-pending snapshots only release a remaining brand-bot assignment: metadata updates change `updated_at` without creating a status activity.
39
45
  An expected activity not yet present retries normally; an incomplete read or a deleted known boundary hands off.
40
46
  Permanently missing activity uses the same three-attempt limit. A new boundary clears the previous turn's failure
41
47
  count and handoff. Chatwoot exposes no turn API: a lost webhook plus permanently removed, never-observed activity
@@ -45,23 +51,37 @@ may point to another deployment of that API, for example a proxy or gateway.
45
51
  - Jev chooses owner, topic, kind and whether there is a request. A confident kind takes precedence; otherwise a
46
52
  confident greeting/no-request stays pending while fewer than three texts exist. Empty or identifier-only input,
47
53
  three greetings, an unclear owner with an actual request, or a public human reply hands off. Blocked contacts
48
- and person-assigned conversations are untouched. Bot and user assignees are distinguished by `assignee_type`.
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`.
49
56
  - Before **each** action, re-read the inbox link, pending status, assignee, turn boundary, inputs and public human
50
57
  replies. A changed input defers the job to decide again. Apply topic/kind labels, then the kind's canned reply,
51
- then exactly one ending action: the kind's resolved/snoozed status, a confident owner's assignment, or explicit
52
- bot `status=open` handoff. Preserve human topic labels and validate that an owner still belongs to the account.
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.
53
64
  Successful person assignment ends the turn without a bot-handoff reporting event; nothing follows it.
54
65
  Reopened resolved conversations can lack a bot assignee, in which case assignment would not open
55
66
  pending: use native handoff instead. Chatwoot has no atomic compare-and-write API for a change racing a mutation.
56
67
  - Only canned replies have an action record, `reply:<account>:<conversation>`, kept across turns and upgrades.
57
- Record it **before sending**: a failure or unknown outcome is never resent. Labels, assignment and status use
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
58
77
  current Chatwoot state, without an effects ledger or custom coordination attributes. A missing canned response
59
78
  hands off immediately. After three processing failures (initial, +5s, +10s), persist handoff mode. Further retries
60
79
  only revalidate the turn and hand off, with backoff capped at 30 minutes. Handoff failures keep the job; deleted
61
- conversations and disconnected inboxes end it. Credentials/service failures need repair before handoff can succeed.
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.
62
82
  - A Worker 2xx means the job is durable, not that routing succeeded. Chatwoot's own webhook failure fallback cannot
63
83
  cover later alarm failures. Its fallback opens pending on failed `message_created`/`message_updated` delivery unless
64
- `keep_pending_on_bot_failure` is enabled; it may leave a bot assignee on open. The relay shows that as unassigned.
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.
65
85
  Chatwoot makes three delivery attempts for 429/500 responses. Irrelevant valid bot events are acknowledged.
66
86
  There is no router account-webhook endpoint.
67
87
 
@@ -70,11 +90,17 @@ and are decided on their **new turn's** messages. Snoozed tickets reopen open an
70
90
  following a customer message counts as answering it in the relay, even if that message was outside Jev's window.
71
91
 
72
92
  The API contracts were checked against Chatwoot v4.18.0:
73
- [assignment](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/services/conversations/assignment_service.rb),
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),
74
95
  [status changes](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations_controller.rb),
75
96
  [activity creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/concerns/activity_message_handler.rb)
76
97
  and its [job](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/jobs/conversations/activity_message_job.rb),
77
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),
78
104
  [inbox bot response](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/accounts/inboxes/agent_bot.json.jbuilder),
79
105
  [assignees/timestamps](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/presenters/conversations/event_data_presenter.rb),
80
106
  [token permissions](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/access_token_auth_helper.rb),
@@ -97,7 +123,7 @@ npx cf deploy --prebuilt --dry-run
97
123
  Edit `cloudflare.config.ts` with your non-secret settings. Keep required secrets out of source control;
98
124
  `.dev.vars.example` lists placeholders. For local development copy it to `.dev.vars` and use `npm run dev`.
99
125
  A maintainer deploys with `npx cf deploy --secrets-file <private-file>`.
100
- The default Worker name is `chatwoot-router`; it exports one SQLite Durable Object, `Router`, bound as `ROUTER`.
126
+ The default Worker name is `chatwoot-router`; it exports SQLite Durable Objects `Router` (bound as `ROUTER`) and `Coordinator` (bound as `COORDINATOR`).
101
127
  Keep the `*/5 * * * *` cron trigger for reconciliation. `GET /healthz` returns 200 for a valid, readable
102
128
  configuration and 503 otherwise; it does not test upstream credentials or connectivity.
103
129
 
@@ -105,12 +131,12 @@ For a private deployment repository, depend on the public `chatwoot-router` npm
105
131
  `@cloudflare/vite-plugin`. Its Worker entry is:
106
132
 
107
133
  ```ts
108
- export { default, Router } from "chatwoot-router";
134
+ export { default, Router, Coordinator } from "chatwoot-router";
109
135
  ```
110
136
 
111
137
  Use the same `cloudflare.config.ts` and `vite.config.ts` structure as this package. Pin compatible tooling versions
112
- from the workspace and root manifests. Declare `Router` with `exports.durableObject({ storage: "sqlite" })`, bind it as
113
- `ROUTER`, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
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(...)`.
114
140
 
115
141
  For a configuration larger than a Worker JSON binding, use immutable KV configuration instead:
116
142
 
@@ -195,21 +221,45 @@ fail startup; `/healthz` returns 503 without credentials in its response. All se
195
221
 
196
222
  ## Upgrade and rollback
197
223
 
198
- 1. Verify user-token inbox visibility and count existing pending tickets. Keep both existing Worker names,
199
- Durable Object namespaces and reply records. Existing pending tickets will be routed; open tickets stay human-owned.
200
- 2. Deploy the new relay first. Remove its `router.accounts` and `router.waitSeconds`; keep every kind name in
201
- `router.keepLabels`. During the transition existing open tickets can go directly to triage.
202
- 3. Stop the old router's account webhook and cron; replace it in place. Never run both routers. Remove
203
- `startAfterConversationId`, `reconcile`, `routing.snoozeUnclear`, `CHATWOOT_WEBHOOK_SECRETS` and
204
- `CHATWOOT_BOT_TOKENS` from its configuration. Add `routing.botIds` and the two new required bot secrets.
205
- The one-time storage migration removes old jobs/checkpoints/non-reply effects and decision memos. Reply records
206
- survive; the pending sweep reconstructs jobs. The Worker never deletes Chatwoot attributes.
207
- 4. Connect the bots one account at a time after both Workers are ready. Keep existing `routing_*` attributes and
208
- definitions until the owner's rollback window ends, then remove them manually.
209
- 5. To roll back, **disconnect bots first**, stop the new router/cron, then restore old code, config and account
210
- webhook if needed. Do not run old and new routing together. Choose old cutover ids deliberately; retained
211
- reply records prevent resends, but lifecycle changes already made are not reversible automatically.
212
- Relay sweeps wake held jobs after disconnect; people can open ordinary pending conversations.
224
+ 0.2.x was never deployed, so the conversation partition and coordinator require no state migration.
225
+
226
+ Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
227
+ permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot
228
+ 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
230
+ and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
231
+ 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
+
233
+ For the first move from the relay's built-in routing (production baseline 0.27.0):
234
+
235
+ 1. Record the live relay deployment/version id and CONFIG_KEY before rollout; retain its Hub and old secrets.
236
+ Disconnect all brand bots and prevent competing old deployments. Verify bot identities, owner membership,
237
+ labels, canned responses and inbox visibility. Historical confirmed brand-bot replies need no ledger migration.
238
+ 2. Deploy and verify relay 0.30 first. Remove `routing`, `router.accounts` and `router.waitSeconds`; keep every kind
239
+ in `router.keepLabels`. Verify the actual version and that built-in routing/in-flight old work stopped.
240
+ 3. Bootstrap this Worker by hand with all four secrets in a protected secrets file while bots remain disconnected.
241
+ Verify KV/domain, upstream permissions and test tickets; health/2xx alone do not prove routing success.
242
+ If using an infra PR whose push deploys both Workers independently, merge only after both hand deployments pass.
243
+ 4. Connect bots one account at a time and verify real routing, handoff, failed/unknown reply handling, historical
244
+ reply deduplication and Discord/triage behavior, including at least a complete sweep.
245
+ 5. To roll back, disconnect bots first and **keep this router running until no pending ticket remains assigned to
246
+ a disconnected brand bot and no non-pending ticket remains assigned to that bot**. Complete pending/open
247
+ account passes and let failed ending releases finish through their durable retries: pending uses native bot
248
+ handoff, open leftovers use explicit unassignment, and kind endings release resolved/snoozed tickets in the
249
+ same turn. Confirm cleared bot ownership across statuses; preserve human/other-bot owners. Then stop
250
+ webhook entry, cron and queued/in-flight router work; stopping cron alone does not cancel DO alarms. Preserve DO
251
+ namespace and storage, including reply and turn guards. Restore the recorded live 0.27 version **and its
252
+ CONFIG_KEY**, without overlapping old/new routing. Relay 0.27 uses its original Hub's
253
+ `kind-reply:<account>:<conversation>` records; the Router uses separate `reply:<account>:<conversation>`
254
+ records. Restoring code/config does not copy Router attempts into the Hub or reset either ledger. Only replies
255
+ already recorded by 0.27 retain its own reply-once protection; Router-only replies/unknown attempts can be
256
+ attempted again by 0.27. Keep both DOs and review/isolate those tickets before restoring old routing. Rollback
257
+ does not guarantee reply-once across this switch or undo sent messages or other mutations.
258
+
259
+ Current router upgrades need no custom coordination attributes, compatibility effects or one-time lifecycle
260
+ cleanup. Older unreadable job payloads are dropped; live snapshots and the account sweep reconstruct queued work, not durable reply/turn guards.
261
+ If handoff credentials fail, repair them or let a real owner take the tickets; do not bulk-open with the integration
262
+ user token, which can assign every ticket to that user. Keep old attributes through the owner's rollback window.
213
263
 
214
264
  Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Logs contain
215
265
  ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
@@ -0,0 +1,10 @@
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,6 +1,8 @@
1
1
  import type { ConfigEnv } from "../../../shared/settings.ts";
2
+ import type { Coordinator } from "./coordinator.ts";
2
3
  import type { Router } from "./router.ts";
3
4
  export interface Env extends ConfigEnv {
5
+ COORDINATOR: DurableObjectNamespace<Coordinator>;
4
6
  ROUTER: DurableObjectNamespace<Router>;
5
7
  CHATWOOT_TOKEN: string;
6
8
  CHATWOOT_AGENT_BOT_SECRETS: string;
@@ -4,4 +4,5 @@ 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";
7
8
  export { Router } from "./router.ts";