chatwoot-router 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/README.md +25 -107
- package/lib/packages/chatwoot-router/src/config.d.ts +1 -1
- package/lib/packages/chatwoot-router/src/env.d.ts +0 -2
- package/lib/packages/chatwoot-router/src/index.d.ts +0 -1
- package/lib/packages/chatwoot-router/src/index.js +171 -372
- package/lib/packages/chatwoot-router/src/reply.d.ts +1 -1
- package/lib/packages/chatwoot-router/src/router.d.ts +5 -1
- package/lib/packages/chatwoot-router/src/turn.d.ts +0 -52
- package/lib/shared/budget.d.ts +3 -14
- package/lib/shared/chatwoot/api.d.ts +6 -5
- package/lib/shared/store.d.ts +8 -11
- package/lib/shared/types.d.ts +6 -2
- package/package.json +1 -1
- package/lib/packages/chatwoot-router/src/coordinator.d.ts +0 -10
- package/lib/shared/alarm.d.ts +0 -2
- package/lib/shared/concurrent.d.ts +0 -4
- package/lib/shared/deadline.d.ts +0 -6
- package/lib/shared/rate-limit.d.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Route a resolved ticket that a customer message reopens. Chatwoot reopens it as pending in a bot inbox and keeps
|
|
15
|
+
its assignee, and the router left every pending ticket with a person assignee alone, so such a ticket stayed
|
|
16
|
+
pending: off people's open view and never handed back. Its turn is now routed with its owner kept: a kind with a
|
|
17
|
+
status still ends it (a thanks-only message can be resolved), and anything else, a greeting included, goes back
|
|
18
|
+
to the owner as open. Only another bot's pending tickets are left alone.
|
|
19
|
+
|
|
20
|
+
## [0.4.0] - 2026-10-04
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Route each conversation in a Durable Object of its own, named `<account>:<conversation>`; one sweeper object
|
|
25
|
+
pages the accounts and hands each conversation to its object. This replaces 0.3.0's `Coordinator` class and
|
|
26
|
+
bounded RPC machinery; a slow Jev or Chatwoot request still delays only its own conversation.
|
|
27
|
+
|
|
28
|
+
### Upgrade
|
|
29
|
+
|
|
30
|
+
- Declare only the `Router` Durable Object (remove `Coordinator`).
|
|
31
|
+
|
|
10
32
|
## [0.3.0] - 2026-10-03
|
|
11
33
|
|
|
12
34
|
### Fixed
|
|
@@ -90,7 +112,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
90
112
|
`routing_kind`.
|
|
91
113
|
- The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
|
|
92
114
|
|
|
93
|
-
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.
|
|
115
|
+
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.0...HEAD
|
|
116
|
+
[0.5.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.4.0...chatwoot-router@0.5.0
|
|
117
|
+
[0.4.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...chatwoot-router@0.4.0
|
|
94
118
|
[0.3.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...chatwoot-router@0.3.0
|
|
95
119
|
[0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
|
|
96
120
|
[0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
|
package/README.md
CHANGED
|
@@ -6,106 +6,26 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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. A resolved
|
|
21
|
+
ticket that a customer message reopens keeps its owner: its kind may still end it (a thanks is resolved),
|
|
22
|
+
otherwise it goes back to the owner.
|
|
23
|
+
4. Anything uncertain hands off to people: an unclear owner, a failed or unconfirmed reply, a human replying,
|
|
24
|
+
three failed attempts. Disconnecting an inbox's bot hands its pending tickets to people.
|
|
25
|
+
5. A sweep every five minutes lists pending and open conversations, so a missed webhook is caught up and a bot
|
|
26
|
+
assignment left on an open ticket is released.
|
|
27
|
+
|
|
28
|
+
[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
29
|
|
|
110
30
|
## Deploy
|
|
111
31
|
|
|
@@ -123,7 +43,7 @@ npx cf deploy --prebuilt --dry-run
|
|
|
123
43
|
Edit `cloudflare.config.ts` with your non-secret settings. Keep required secrets out of source control;
|
|
124
44
|
`.dev.vars.example` lists placeholders. For local development copy it to `.dev.vars` and use `npm run dev`.
|
|
125
45
|
A maintainer deploys with `npx cf deploy --secrets-file <private-file>`.
|
|
126
|
-
The default Worker name is `chatwoot-router`; it exports SQLite Durable
|
|
46
|
+
The default Worker name is `chatwoot-router`; it exports one SQLite Durable Object, `Router`, bound as `ROUTER`.
|
|
127
47
|
Keep the `*/5 * * * *` cron trigger for reconciliation. `GET /healthz` returns 200 for a valid, readable
|
|
128
48
|
configuration and 503 otherwise; it does not test upstream credentials or connectivity.
|
|
129
49
|
|
|
@@ -131,12 +51,12 @@ For a private deployment repository, depend on the public `chatwoot-router` npm
|
|
|
131
51
|
`@cloudflare/vite-plugin`. Its Worker entry is:
|
|
132
52
|
|
|
133
53
|
```ts
|
|
134
|
-
export { default, Router
|
|
54
|
+
export { default, Router } from "chatwoot-router";
|
|
135
55
|
```
|
|
136
56
|
|
|
137
57
|
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`
|
|
139
|
-
`ROUTER
|
|
58
|
+
from the workspace and root manifests. Declare `Router` with `exports.durableObject({ storage: "sqlite" })`, bind it as
|
|
59
|
+
`ROUTER`, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
|
|
140
60
|
|
|
141
61
|
For a configuration larger than a Worker JSON binding, use immutable KV configuration instead:
|
|
142
62
|
|
|
@@ -221,12 +141,10 @@ fail startup; `/healthz` returns 503 without credentials in its response. All se
|
|
|
221
141
|
|
|
222
142
|
## Upgrade and rollback
|
|
223
143
|
|
|
224
|
-
0.2.x was never deployed, so the conversation partition and coordinator require no state migration.
|
|
225
|
-
|
|
226
144
|
Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
|
|
227
145
|
permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot
|
|
228
146
|
rebuild an unknown attempt, an observed reply since deleted, or a missing turn boundary. Do not delete or recreate
|
|
229
|
-
|
|
147
|
+
the Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected
|
|
230
148
|
and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
|
|
231
149
|
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
150
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
export { ConfigError } from "../../../shared/config.ts";
|
|
3
2
|
export declare const configSchema: z.ZodObject<{
|
|
4
3
|
chatwoot: z.ZodObject<{
|
|
5
4
|
baseUrl: z.ZodURL;
|
|
@@ -39,3 +38,4 @@ export interface Settings {
|
|
|
39
38
|
}
|
|
40
39
|
export declare function parseSettings(rawConfig: unknown, rawSecrets: object): Settings;
|
|
41
40
|
export declare function buildSettings(config: Config, secrets: Secrets): Settings;
|
|
41
|
+
export {};
|
|
@@ -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;
|