chatwoot-router 0.1.0 → 0.2.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 +29 -1
- package/README.md +124 -204
- package/lib/packages/chatwoot-router/scripts/store-config.js +18 -17
- package/lib/packages/chatwoot-router/scripts/stored-config.js +18 -17
- package/lib/packages/chatwoot-router/src/config.d.ts +4 -9
- package/lib/packages/chatwoot-router/src/env.d.ts +2 -2
- package/lib/packages/chatwoot-router/src/index.js +414 -320
- package/lib/packages/chatwoot-router/src/router.d.ts +3 -2
- package/lib/packages/chatwoot-router/src/routing.d.ts +0 -1
- package/lib/packages/chatwoot-router/src/turn.d.ts +86 -0
- package/lib/packages/chatwoot-router/src/webhook.d.ts +5 -0
- package/lib/shared/chatwoot/api.d.ts +54 -3
- package/lib/shared/store.d.ts +4 -1
- package/lib/shared/types.d.ts +4 -0
- package/package.json +1 -1
- package/lib/packages/chatwoot-router/src/effects.d.ts +0 -14
- package/lib/shared/attributes.d.ts +0 -6
- package/lib/shared/sweep.d.ts +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The router is each routed inbox's Chatwoot agent bot (the account's brand bot). It works on `pending` conversations:
|
|
15
|
+
it decides on the customer's first three messages of the bot's turn, adds the topic and kind labels, sends the kind's
|
|
16
|
+
canned reply as the bot, and ends the turn with the kind's status, the owner's assignment, or a handoff to people.
|
|
17
|
+
A person who takes, replies to, or reopens the ticket owns it; a resolved conversation the customer reopens comes
|
|
18
|
+
back to the bot and is decided on its new messages (a snoozed one reopens for people). After three failed attempts,
|
|
19
|
+
or when the kind's canned response does not exist, it hands the ticket to people. See "How it works" in the README.
|
|
20
|
+
- Replaces the `routing_*` conversation attributes, `startAfterConversationId`, the account webhook, `reconcile` (the
|
|
21
|
+
sweep lists every `pending` conversation), and `routing.snoozeUnclear`: a greeting stays with the bot until the
|
|
22
|
+
customer asks something or has sent three messages with text, then goes to people.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `routing.endpoint`: the URL of TypeSafe's System One API (default `https://api.typesafe.ai/v1/systemone`), for
|
|
27
|
+
example a proxy or gateway.
|
|
28
|
+
|
|
29
|
+
### Upgrade
|
|
30
|
+
|
|
31
|
+
- Deploy chatwoot-discord-relay 0.30.0 first. Raise `subrequestBudget` to at least 45 (was 20), remove `reconcile`
|
|
32
|
+
and `routing.snoozeUnclear`, add `routing.botIds`, and replace `CHATWOOT_WEBHOOK_SECRETS` and
|
|
33
|
+
`CHATWOOT_BOT_TOKENS` with `CHATWOOT_AGENT_BOT_SECRETS` and `CHATWOOT_AGENT_BOT_TOKENS`. Then set each bot's webhook
|
|
34
|
+
URL to `/chatwoot/agent-bot` and connect it to the routed inboxes, one account at a time; existing `pending`
|
|
35
|
+
conversations become the bot's. Roll back by disconnecting the bots ([Upgrade and rollback](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/README.md#upgrade-and-rollback)).
|
|
36
|
+
|
|
10
37
|
## [0.1.0] - 2026-10-02
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -19,5 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
46
|
`routing_kind`.
|
|
20
47
|
- The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
|
|
21
48
|
|
|
22
|
-
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.
|
|
49
|
+
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...HEAD
|
|
50
|
+
[0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
|
|
23
51
|
[0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
|
package/README.md
CHANGED
|
@@ -1,45 +1,85 @@
|
|
|
1
1
|
# chatwoot-router
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
Neither Worker calls the other: Chatwoot is their only coordination surface.
|
|
3
|
+
A native Chatwoot agent bot that uses [TypeSafe's System One API](https://docs.typesafe.ai) to assign owners, add topic and
|
|
4
|
+
kind labels, send canned responses, and resolve or snooze tickets. Runs on Cloudflare Workers independently
|
|
5
|
+
of [chatwoot-discord-relay](https://github.com/Phala-Network/chatwoot-workers/tree/main/packages/chatwoot-discord-relay). The Workers coordinate through Chatwoot status.
|
|
7
6
|
|
|
8
7
|
## How it works
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 routing. 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
|
+
- 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
|
|
24
|
+
conversations skipped by changing pages. Neither event payloads nor sweep rows are decision inputs.
|
|
25
|
+
- Read messages newest-first, unfiltered (including activities), with `before` paging, at most five pages of 20.
|
|
26
|
+
The latest `conversation_status_changed` activity begins the turn; without one or evidence it is missing, use
|
|
27
|
+
the conversation's start. Take the first three usable customer texts after the boundary, oldest first. Include
|
|
28
|
+
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.
|
|
30
|
+
- Status activity is asynchronous. The queue's boundary guard remembers an observed/expected transition and the
|
|
31
|
+
last boundary needed to reject stale handoff work. An activity read before its first status webhook can match
|
|
32
|
+
that webhook's status and time only if no existing expectation requires a newer activity. Merging a webhook
|
|
33
|
+
never lowers or removes an expected boundary id. Once matched, a different transition requires an activity
|
|
34
|
+
**after that boundary id**, even when the old activity has the same status and second-level timestamp. Repeated
|
|
35
|
+
delivery of the same transition timestamp does not start another expectation. Only a status webhook supplies a transition time;
|
|
36
|
+
a read observing pending end requires a newer activity without inferring a time from `updated_at`. The last
|
|
37
|
+
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.
|
|
39
|
+
An expected activity not yet present retries normally; an incomplete read or a deleted known boundary hands off.
|
|
40
|
+
Permanently missing activity uses the same three-attempt limit. A new boundary clears the previous turn's failure
|
|
41
|
+
count and handoff. Chatwoot exposes no turn API: a lost webhook plus permanently removed, never-observed activity
|
|
42
|
+
cannot be reconstructed. Chatwoot writes activity only for a changed status with activity content; no-op status
|
|
43
|
+
changes create none. Activities use job execution time, and customer/scheduled reopen usually creates none.
|
|
44
|
+
A late activity ordered after new text does not license reading old text across the boundary; empty input hands off.
|
|
45
|
+
- Jev chooses owner, topic, kind and whether there is a request. A confident kind takes precedence; otherwise a
|
|
46
|
+
confident greeting/no-request stays pending while fewer than three texts exist. Empty or identifier-only input,
|
|
47
|
+
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`.
|
|
49
|
+
- Before **each** action, re-read the inbox link, pending status, assignee, turn boundary, inputs and public human
|
|
50
|
+
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.
|
|
53
|
+
Successful person assignment ends the turn without a bot-handoff reporting event; nothing follows it.
|
|
54
|
+
Reopened resolved conversations can lack a bot assignee, in which case assignment would not open
|
|
55
|
+
pending: use native handoff instead. Chatwoot has no atomic compare-and-write API for a change racing a mutation.
|
|
56
|
+
- 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
|
|
58
|
+
current Chatwoot state, without an effects ledger or custom coordination attributes. A missing canned response
|
|
59
|
+
hands off immediately. After three processing failures (initial, +5s, +10s), persist handoff mode. Further retries
|
|
60
|
+
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.
|
|
62
|
+
- A Worker 2xx means the job is durable, not that routing succeeded. Chatwoot's own webhook failure fallback cannot
|
|
63
|
+
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.
|
|
65
|
+
Chatwoot makes three delivery attempts for 429/500 responses. Irrelevant valid bot events are acknowledged.
|
|
66
|
+
There is no router account-webhook endpoint.
|
|
67
|
+
|
|
68
|
+
After handoff, later customer messages belong to people. Resolved tickets reopen pending in an active bot inbox
|
|
69
|
+
and are decided on their **new turn's** messages. Snoozed tickets reopen open and go to people. A canned reply
|
|
70
|
+
following a customer message counts as answering it in the relay, even if that message was outside Jev's window.
|
|
71
|
+
|
|
72
|
+
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),
|
|
74
|
+
[status changes](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations_controller.rb),
|
|
75
|
+
[activity creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/concerns/activity_message_handler.rb)
|
|
76
|
+
and its [job](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/jobs/conversations/activity_message_job.rb),
|
|
77
|
+
[message paging](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/finders/message_finder.rb),
|
|
78
|
+
[inbox bot response](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/accounts/inboxes/agent_bot.json.jbuilder),
|
|
79
|
+
[assignees/timestamps](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/presenters/conversations/event_data_presenter.rb),
|
|
80
|
+
[token permissions](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/access_token_auth_helper.rb),
|
|
81
|
+
[bot events](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/listeners/agent_bot_listener.rb), and
|
|
82
|
+
[webhook fallback](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/lib/webhooks/trigger.rb).
|
|
43
83
|
|
|
44
84
|
## Deploy
|
|
45
85
|
|
|
@@ -91,38 +131,16 @@ no unpublished shared package needs installing.
|
|
|
91
131
|
|
|
92
132
|
## Chatwoot setup
|
|
93
133
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
| `routing_seen` | Number | Latest customer message id observed by a completed run, including skipped tickets, written after applicable actions. The relay's sole completion signal. |
|
|
105
|
-
| `routing_handled` | Number | Latest customer message id handled by a kind's successful reply or set-aside action. |
|
|
106
|
-
| `routing_kind` | Text | Kind label added by the router, preserved by the relay's Manage card. |
|
|
107
|
-
|
|
108
|
-
Every public incoming contact `message_created` in a routed account queues reconciliation, even for an
|
|
109
|
-
assigned, closed, pre-cutover, or already-routed ticket. Skipped messages and no-owner decisions also advance
|
|
110
|
-
`routing_seen`; only eligible tickets ask Jev. When a kind handles messages, `routing_handled`
|
|
111
|
-
and `routing_kind` are included in the same update as `routing_seen`, after its reply/status/label actions.
|
|
112
|
-
|
|
113
|
-
Attributes are updated through Chatwoot's `custom_attributes` API with `merge: true`. Chatwoot v4.18 implements
|
|
114
|
-
this as read-merge-save, not an atomic merge: concurrent relay/router writes can still lose keys. Every run
|
|
115
|
-
projects its durable `routing_seen` checkpoint and effects ledger again. This repairs assigned, resolved, and
|
|
116
|
-
snoozed tickets too, without a separate repair job. The checkpoint retains the greatest observed customer id
|
|
117
|
-
and existing `routing_seen` value, even after that message leaves the bounded read window. The handled id and
|
|
118
|
-
kind label come from ledger history, independently of current labels or visible inputs. Greater existing
|
|
119
|
-
watermarks are retained in Chatwoot; unchanged attributes are not written again.
|
|
120
|
-
The relay similarly repairs its missing or different post URL on sync. Recovery is eventual, not a cross-Worker
|
|
121
|
-
transaction. Failed reads and writes retry without reclassifying unchanged inputs or repeating irreversible actions.
|
|
122
|
-
With the relay, set `router.accounts` there; its default wait is 30 seconds from each customer's message time.
|
|
123
|
-
Assignment and status changes never release that wait; only `routing_seen` or the timeout does.
|
|
124
|
-
The three attribute names are fixed. The relay's link attribute must not use any of them.
|
|
125
|
-
Watermarks accept non-negative safe integers stored as numbers or numeric strings; other values count as absent.
|
|
134
|
+
1. Give `CHATWOOT_TOKEN`'s user access to every routed inbox (membership or account administrator).
|
|
135
|
+
2. Create a brand agent bot in each account. Set its webhook URL to `https://<router>/chatwoot/agent-bot` and put its
|
|
136
|
+
id in `routing.botIds`, access token in `CHATWOOT_AGENT_BOT_TOKENS`, and bot webhook secret in
|
|
137
|
+
`CHATWOOT_AGENT_BOT_SECRETS`. All three maps must name exactly the routed accounts. Account webhook secrets
|
|
138
|
+
are different credentials. Bot signatures use HMAC-SHA256 of `<timestamp>.<raw body>`, a ±5-minute window,
|
|
139
|
+
and account matching. Valid irrelevant events are acknowledged without actions.
|
|
140
|
+
3. Create topic and kind labels and any canned responses in Chatwoot. Canned responses use their short codes and
|
|
141
|
+
are read at send time; customers see the brand bot's name. Chatwoot expands its normal message variables.
|
|
142
|
+
4. After deploying both Workers, connect each bot to the inboxes it routes. No `routing_*` custom attributes or
|
|
143
|
+
definitions are needed. For the relay's Manage card, list every kind name in `router.keepLabels`.
|
|
126
144
|
|
|
127
145
|
## Configuration reference
|
|
128
146
|
|
|
@@ -132,6 +150,7 @@ Watermarks accept non-negative safe integers stored as numbers or numeric string
|
|
|
132
150
|
{
|
|
133
151
|
"chatwoot": { "baseUrl": "https://chatwoot.example.com" },
|
|
134
152
|
"routing": {
|
|
153
|
+
"botIds": { "1": 1 },
|
|
135
154
|
"accounts": {
|
|
136
155
|
"1": {
|
|
137
156
|
"support": { "assignee": 6, "covers": "Product support, billing, and account access." },
|
|
@@ -140,157 +159,58 @@ Watermarks accept non-negative safe integers stored as numbers or numeric string
|
|
|
140
159
|
},
|
|
141
160
|
"topics": { "billing": "Invoices and payments.", "technical": "Product troubleshooting." },
|
|
142
161
|
"minConfidence": 0.7,
|
|
143
|
-
"snoozeUnclear": true,
|
|
144
162
|
"kinds": { "1": { "spam": { "covers": "Unsolicited advertising.", "status": "resolved" } } }
|
|
145
|
-
}
|
|
146
|
-
"startAfterConversationId": { "1": 0 }
|
|
163
|
+
}
|
|
147
164
|
}
|
|
148
165
|
```
|
|
149
166
|
|
|
150
167
|
| Setting | Type | Default | Meaning |
|
|
151
168
|
| --- | --- | --- | --- |
|
|
152
169
|
| `chatwoot.baseUrl` | HTTP(S) URL | required | Final Chatwoot API URL; redirects are refused. |
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `subrequestBudget` | integer 20–1000 | `45` | Per-alarm outbound budget; keep under your Workers plan's limit. |
|
|
157
|
-
| `routing` | object | required | TypeSafe Jev owner, topic, and kind configuration. |
|
|
158
|
-
| `routing.model` | non-empty string | `jev-1.13.0` | TypeSafe model. |
|
|
170
|
+
| `subrequestBudget` | integer 45–1000 | `45` | Per-alarm outbound budget; reserve 45 for a bounded turn read and all actions. |
|
|
171
|
+
| `routing.endpoint` | HTTPS URL | `https://api.typesafe.ai/v1/systemone` | TypeSafe's System One API endpoint; may point to another deployment of that API, for example a proxy or gateway. Redirects are refused. |
|
|
172
|
+
| `routing.model` | non-empty string | `jev-1.13.0` | Model sent to TypeSafe's System One API; Jev by default. |
|
|
159
173
|
| `routing.minConfidence` | number 0.5–1 | `0.7` | Probability an answer needs before it is applied. |
|
|
160
|
-
| `routing.
|
|
174
|
+
| `routing.botIds` | object: account id → positive safe integer | required | Brand bot id for every routed account, and no others. |
|
|
161
175
|
| `routing.accounts` | object: account id → (owner name → owner) | required | Routed accounts and the owners Jev chooses from. Owner names are 1–40 lower-case letters, digits, or `_`; `unclear` is reserved. |
|
|
162
176
|
| `routing.accounts.<id>.<name>.assignee` | integer > 0 | required | Chatwoot user id to assign. |
|
|
163
177
|
| `routing.accounts.<id>.<name>.covers` | 1–1000 characters | required | What the owner handles: Jev's criterion for choosing them. |
|
|
164
178
|
| `routing.topics` | object: label → what it covers | unset | Topic labels (Chatwoot label names, lower case) Jev chooses from; one is added when a ticket has no label other than its kinds (an automation rule's label is kept alone). The separate relay can display them using its `forumTags` configuration. Unset: no topic. |
|
|
165
|
-
| `routing.kinds` | object: account id → (kind name → kind) | unset | Kinds of ticket Jev recognizes in routed accounts, added as labels beside the topic, and what is done once when it does ([
|
|
179
|
+
| `routing.kinds` | object: account id → (kind name → kind) | unset | Kinds of ticket Jev recognizes in routed accounts, added as labels beside the topic, and what is done once when it does ([How it works](#how-it-works)). Kind names are the account's label names (1–40 lower-case letters, digits, `_`, or `-`); `none` is reserved. Unset: none. |
|
|
166
180
|
| `routing.kinds.<id>.<name>.covers` | 1–1000 characters | required | What the kind is: Jev's criterion for recognizing it. |
|
|
167
|
-
| `routing.kinds.<id>.<name>.cannedResponse` | short code | unset | The account's Chatwoot canned response sent to the customer once, by the account's agent bot (`
|
|
181
|
+
| `routing.kinds.<id>.<name>.cannedResponse` | short code | unset | The account's Chatwoot canned response sent to the customer once, by the account's agent bot (`CHATWOOT_AGENT_BOT_TOKENS`); handoff if it is missing. |
|
|
168
182
|
| `routing.kinds.<id>.<name>.status` | `resolved` or `snoozed` | unset | Set instead of routing the ticket (`snoozed`: until the customer's next message), after the reply of a `cannedResponse`; a new customer message reopens it. |
|
|
169
183
|
|
|
170
184
|
### Secrets
|
|
171
185
|
|
|
172
186
|
| Secret | Required | Meaning |
|
|
173
187
|
| --- | --- | --- |
|
|
174
|
-
| `CHATWOOT_TOKEN` | Yes |
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
##
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
full window do not change the key. Chatwoot provides no atomic snapshot across message reads and actions.
|
|
204
|
-
|
|
205
|
-
Router message reads set `filter_internal_messages=true`: Chatwoot v4.18's controller passes it to
|
|
206
|
-
`MessageFinder`, which filters private notes and activity lines before paging. The generated OpenAPI schema
|
|
207
|
-
omits it, so the shared typed client extends the query type; relay reads are unchanged. The forward scan is
|
|
208
|
-
bounded to 300 public messages, including agent replies. If it cannot fill or finish the input window, labels,
|
|
209
|
-
assignment, and a kind reply can still apply, but no status action does; the ticket stays for a person.
|
|
210
|
-
|
|
211
|
-
Immediately before actions a fresh read must show an open ticket, with an unblocked contact, unassigned or
|
|
212
|
-
assigned to this answer's owner. Otherwise only coordination attributes are synchronized. Routing resumes
|
|
213
|
-
after reopening using memoized inputs and the effects ledger, not a saved decision state. Recorded assignment,
|
|
214
|
-
label, and status effects are never repeated for that input version: a person unassigning or clearing labels
|
|
215
|
-
via Manage has the final say. A new input version permits new effects. Actions run in order: assignment/labels,
|
|
216
|
-
reply, status, then the checkpoint and attributes. Lost assignment/label responses are recovered by observing
|
|
217
|
-
Chatwoot and recording an already-satisfied effect without another request. Routing uses `CHATWOOT_TOKEN`,
|
|
218
|
-
whose user must be an agent in the routed inboxes; Chatwoot records the assignment as made by that user.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
With `kinds`, Jev is also asked which of the account's kinds the ticket is (or `none`). Kinds are
|
|
222
|
-
labels of a second family: a ticket has one topic label, the category, and a kind Jev is confident
|
|
223
|
-
about is added beside it (create each kind as a label in its account; it needs no forum tag, and
|
|
224
|
-
the card shows it). It also acts with the decision: a kind with a `status` (spam, for example) sets the
|
|
225
|
-
ticket aside, resolved or snoozed until the customer's next message, instead of routing it; the
|
|
226
|
-
contact is not blocked, so a new message reopens the ticket as usual. A kind with a `cannedResponse` sends that
|
|
227
|
-
Chatwoot canned response (Settings → Canned Responses, by its short code) to the customer, for
|
|
228
|
-
example to acknowledge an application or point a security report to its process; a kind with both
|
|
229
|
-
replies, then sets the ticket aside (a templated security report: acknowledged, then resolved). It is read when
|
|
230
|
-
it is sent, so it is edited in Chatwoot, can use Chatwoot's variables such as `{{contact.name}}`,
|
|
231
|
-
and a missing response leaves the job retrying without recording a send attempt. It is sent as the account's
|
|
232
|
-
Chatwoot agent bot (`CHATWOOT_BOT_TOKENS`): customers see the bot's name, such as "Acme Support"; a
|
|
233
|
-
bot's message assigns nobody and is no human first reply, and Chatwoot then counts the customer as
|
|
234
|
-
answered (no longer waiting) until they write again. Create the bot in the account (Settings →
|
|
235
|
-
Bots), without connecting it to an inbox. A reply goes out at most once per ticket: it is recorded
|
|
236
|
-
before it is sent, so a failed send is not retried. Once a kind replied or set the ticket aside, the
|
|
237
|
-
customer messages it handled (those Jev was given) are relayed without calling the triage bot, with a
|
|
238
|
-
note, through the coordination attributes below. The relay waits up to its configured deadline;
|
|
239
|
-
if routing is unavailable or slower than that, the message proceeds with its usual mention. Rules that need no judgement of the text (by inbox, sender, or
|
|
240
|
-
subject) are Chatwoot's automation rules.
|
|
241
|
-
|
|
242
|
-
```jsonc
|
|
243
|
-
"routing": {
|
|
244
|
-
"accounts": {
|
|
245
|
-
"1": {
|
|
246
|
-
"cloud": { "assignee": 6, "covers": "Cloud support and billing: deployments, invoices, account access." },
|
|
247
|
-
"sales": { "assignee": 7, "covers": "Sales and partnerships: pricing, capacity, volume deals." }
|
|
248
|
-
}
|
|
249
|
-
},
|
|
250
|
-
"topics": { "technical-support": "Something does not work.", "billing": "Payments, invoices, refunds." },
|
|
251
|
-
"kinds": {
|
|
252
|
-
"1": {
|
|
253
|
-
"spam": { "covers": "Unsolicited promotion or scams.", "status": "resolved" },
|
|
254
|
-
"startup-program": { "covers": "A Startup Program application.", "cannedResponse": "startup-program" }
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
## Reliability and cutover
|
|
262
|
-
|
|
263
|
-
The Worker acknowledges after a SQLite job is queued. Alarms serialize decisions, preserve work across failures,
|
|
264
|
-
and retry with exponential backoff capped at 30 minutes. The subrequest budget reserves 15 requests for the
|
|
265
|
-
worst-case route and yields to a fresh invocation before starting work it cannot finish.
|
|
266
|
-
Chatwoot's conversation-not-found response (JSON HTTP 404) is logged and the job is dropped, including a deletion
|
|
267
|
-
during a message read or write. Other failures, including proxy errors and account-level API failures, keep their backoff.
|
|
268
|
-
A sweep every five minutes pages **all statuses**, newest activity first, using the same shared Chatwoot client
|
|
269
|
-
and pass-window/cursor logic as the relay. It queues the same route job for every listed conversation,
|
|
270
|
-
regardless of status or assignment; reconciliation includes attribute repair.
|
|
271
|
-
Closing a ticket does not remove it from this list. Activity can move it to the front, so it may be seen again;
|
|
272
|
-
the next window starts from the previous pass's **start**, with a 60-second overlap, bounded by
|
|
273
|
-
`reconcile.lookbackSeconds` and `reconcile.maxCatchUpSeconds`.
|
|
274
|
-
The persisted page cursor survives budget yields and failed-page backoff. Route jobs run between
|
|
275
|
-
pages; a failed sweep does not hold due jobs in its own account or any other account.
|
|
276
|
-
Memoized answers, effects-ledger entries, and checkpoints remain in the Router's own Durable Object without expiry.
|
|
277
|
-
|
|
278
|
-
When splitting an existing relay deployment, **deploy the new relay first**, removing its old `routing` setting and
|
|
279
|
-
routing secrets and adding `router.accounts`. Keep its existing Worker `name` and `Hub` export to preserve state.
|
|
280
|
-
Then find the newest Chatwoot conversation display id **in each routed account** at cutover and record the map in
|
|
281
|
-
`startAfterConversationId`, for example `{ "1": 1200, "2": 85 }`. Deploy the router with the old routing configuration and its secrets, then add its
|
|
282
|
-
Chatwoot webhook. Do not start the router while the old embedded routing is active. The cutover watermark
|
|
283
|
-
intentionally leaves pre-cutover conversations to humans; old relay routing decisions are not imported. New
|
|
284
|
-
customer messages on those tickets are still acknowledged with `routing_seen`. Set the relay's `router.keepLabels`
|
|
285
|
-
(for example `["spam", "security", "beg-bounty"]`) to keep legacy kind labels even without `routing_kind`.
|
|
286
|
-
|
|
287
|
-
Chatwoot conversation display ids are account-local: never use one account's id as another account's cutoff.
|
|
288
|
-
The map accepts only accounts in `routing.accounts`, with non-negative integer ids. An omitted account defaults
|
|
289
|
-
to `0`, so include every account whose pre-cutover history must be left alone. In the example, account 1 starts
|
|
290
|
-
at conversation 1201 and account 2 at 86, independently of the other account's counter.
|
|
291
|
-
|
|
292
|
-
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Review its
|
|
293
|
-
policy before enabling routing. Logs contain ids and outcomes, never message bodies or credentials.
|
|
294
|
-
See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md) to report vulnerabilities,
|
|
295
|
-
[CONTRIBUTING.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/CONTRIBUTING.md) for development,
|
|
296
|
-
and [CHANGELOG.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/CHANGELOG.md) for releases. Licensed under [MIT](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/LICENSE).
|
|
188
|
+
| `CHATWOOT_TOKEN` | Yes | User token for messages, canned responses, inbox discovery and sweep. Must see every routed inbox. |
|
|
189
|
+
| `CHATWOOT_AGENT_BOT_TOKENS` | Yes | JSON object of bot access tokens by account id, e.g. `{"1":"<token>"}`. All mutations use the bot token. |
|
|
190
|
+
| `CHATWOOT_AGENT_BOT_SECRETS` | Yes | JSON object of bot webhook secrets by account id, e.g. `{"1":"<secret>"}`. |
|
|
191
|
+
| `TYPESAFE_API_KEY` | Yes | TypeSafe API key for System One API requests. |
|
|
192
|
+
|
|
193
|
+
Both bot-secret maps require nonempty values and exactly the `routing.accounts` keys. Missing/invalid entries
|
|
194
|
+
fail startup; `/healthz` returns 503 without credentials in its response. All secrets use `bindings.secret()`.
|
|
195
|
+
|
|
196
|
+
## Upgrade and rollback
|
|
197
|
+
|
|
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.
|
|
213
|
+
|
|
214
|
+
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Logs contain
|
|
215
|
+
ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
|
|
216
|
+
[CONTRIBUTING.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/CONTRIBUTING.md) and [CHANGELOG.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/CHANGELOG.md). Licensed under [MIT](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/LICENSE).
|
|
@@ -92,18 +92,16 @@ var reconcileSchema = z.strictObject({
|
|
|
92
92
|
var accountId = z2.string().regex(/^[1-9]\d*$/, "must be a Chatwoot account id").refine((value) => Number.isSafeInteger(Number(value)), "must be a safe integer");
|
|
93
93
|
var configSchema = z2.strictObject({
|
|
94
94
|
chatwoot: z2.strictObject({ baseUrl: z2.url({ protocol: /^https?$/ }) }),
|
|
95
|
-
|
|
96
|
-
startAfterConversationId: z2.record(accountId, z2.number().int().min(0)).default({}),
|
|
97
|
-
subrequestBudget: z2.number().int().min(20).max(1e3).default(45),
|
|
95
|
+
subrequestBudget: z2.number().int().min(45).max(1e3).default(45),
|
|
98
96
|
routing: z2.strictObject({
|
|
97
|
+
/** TypeSafe's System One API (/v1/systemone), or another deployment such as a proxy or gateway. */
|
|
98
|
+
endpoint: z2.url({ protocol: /^https$/ }).default("https://api.typesafe.ai/v1/systemone"),
|
|
99
|
+
/** Model sent to TypeSafe's System One API; Jev by default. */
|
|
99
100
|
model: z2.string().min(1).default("jev-1.13.0"),
|
|
100
101
|
/** Jev's probability an answer needs before it is applied. */
|
|
101
102
|
minConfidence: z2.number().min(0.5).max(1).default(0.7),
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
* their next message (see src/routing.ts).
|
|
105
|
-
*/
|
|
106
|
-
snoozeUnclear: z2.boolean().default(false),
|
|
103
|
+
/** The account's native Chatwoot agent bot. */
|
|
104
|
+
botIds: z2.record(accountId, z2.number().int().positive()),
|
|
107
105
|
/** Per Chatwoot account id: the owners Jev chooses from, by a short name. */
|
|
108
106
|
accounts: z2.record(
|
|
109
107
|
accountId,
|
|
@@ -135,7 +133,7 @@ var configSchema = z2.strictObject({
|
|
|
135
133
|
covers: z2.string().min(1).max(1e3),
|
|
136
134
|
/**
|
|
137
135
|
* Short code of the account's Chatwoot canned response sent to the customer once, as
|
|
138
|
-
* the account's agent bot (
|
|
136
|
+
* the account's agent bot (CHATWOOT_AGENT_BOT_TOKENS). Missing responses hand off to people.
|
|
139
137
|
*/
|
|
140
138
|
cannedResponse: z2.string().trim().min(1).max(255).optional(),
|
|
141
139
|
/**
|
|
@@ -148,23 +146,26 @@ var configSchema = z2.strictObject({
|
|
|
148
146
|
)
|
|
149
147
|
).optional()
|
|
150
148
|
})
|
|
151
|
-
}).refine(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
}).refine(
|
|
150
|
+
(config) => {
|
|
151
|
+
const accounts = Object.keys(config.routing.accounts);
|
|
152
|
+
return accounts.length > 0 && accounts.length === Object.keys(config.routing.botIds).length && accounts.every((id) => config.routing.botIds[id] !== void 0);
|
|
153
|
+
},
|
|
154
|
+
{ path: ["routing", "botIds"], message: "must name exactly the routed accounts" }
|
|
155
|
+
).refine((config) => Object.keys(config.routing.kinds ?? {}).every((id) => config.routing.accounts[id]), {
|
|
155
156
|
path: ["routing", "kinds"],
|
|
156
157
|
message: "must only name routed accounts"
|
|
157
158
|
}).refine(
|
|
158
|
-
(config) => Object.values(config.routing
|
|
159
|
-
(kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing
|
|
159
|
+
(config) => Object.values(config.routing.kinds ?? {}).every(
|
|
160
|
+
(kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing.topics ?? {}, kind))
|
|
160
161
|
),
|
|
161
162
|
{ path: ["routing", "kinds"], message: "a kind cannot be named as a topic: they are labels of two families" }
|
|
162
163
|
);
|
|
163
164
|
var secretsSchema = z2.object({
|
|
164
165
|
CHATWOOT_TOKEN: z2.string().min(1),
|
|
165
|
-
|
|
166
|
+
CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
|
|
166
167
|
TYPESAFE_API_KEY: z2.string().min(1),
|
|
167
|
-
|
|
168
|
+
CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
|
|
168
169
|
});
|
|
169
170
|
|
|
170
171
|
// scripts/stored-config.ts
|
|
@@ -44,18 +44,16 @@ var reconcileSchema = z.strictObject({
|
|
|
44
44
|
var accountId = z2.string().regex(/^[1-9]\d*$/, "must be a Chatwoot account id").refine((value) => Number.isSafeInteger(Number(value)), "must be a safe integer");
|
|
45
45
|
var configSchema = z2.strictObject({
|
|
46
46
|
chatwoot: z2.strictObject({ baseUrl: z2.url({ protocol: /^https?$/ }) }),
|
|
47
|
-
|
|
48
|
-
startAfterConversationId: z2.record(accountId, z2.number().int().min(0)).default({}),
|
|
49
|
-
subrequestBudget: z2.number().int().min(20).max(1e3).default(45),
|
|
47
|
+
subrequestBudget: z2.number().int().min(45).max(1e3).default(45),
|
|
50
48
|
routing: z2.strictObject({
|
|
49
|
+
/** TypeSafe's System One API (/v1/systemone), or another deployment such as a proxy or gateway. */
|
|
50
|
+
endpoint: z2.url({ protocol: /^https$/ }).default("https://api.typesafe.ai/v1/systemone"),
|
|
51
|
+
/** Model sent to TypeSafe's System One API; Jev by default. */
|
|
51
52
|
model: z2.string().min(1).default("jev-1.13.0"),
|
|
52
53
|
/** Jev's probability an answer needs before it is applied. */
|
|
53
54
|
minConfidence: z2.number().min(0.5).max(1).default(0.7),
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
* their next message (see src/routing.ts).
|
|
57
|
-
*/
|
|
58
|
-
snoozeUnclear: z2.boolean().default(false),
|
|
55
|
+
/** The account's native Chatwoot agent bot. */
|
|
56
|
+
botIds: z2.record(accountId, z2.number().int().positive()),
|
|
59
57
|
/** Per Chatwoot account id: the owners Jev chooses from, by a short name. */
|
|
60
58
|
accounts: z2.record(
|
|
61
59
|
accountId,
|
|
@@ -87,7 +85,7 @@ var configSchema = z2.strictObject({
|
|
|
87
85
|
covers: z2.string().min(1).max(1e3),
|
|
88
86
|
/**
|
|
89
87
|
* Short code of the account's Chatwoot canned response sent to the customer once, as
|
|
90
|
-
* the account's agent bot (
|
|
88
|
+
* the account's agent bot (CHATWOOT_AGENT_BOT_TOKENS). Missing responses hand off to people.
|
|
91
89
|
*/
|
|
92
90
|
cannedResponse: z2.string().trim().min(1).max(255).optional(),
|
|
93
91
|
/**
|
|
@@ -100,23 +98,26 @@ var configSchema = z2.strictObject({
|
|
|
100
98
|
)
|
|
101
99
|
).optional()
|
|
102
100
|
})
|
|
103
|
-
}).refine(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
}).refine(
|
|
102
|
+
(config) => {
|
|
103
|
+
const accounts = Object.keys(config.routing.accounts);
|
|
104
|
+
return accounts.length > 0 && accounts.length === Object.keys(config.routing.botIds).length && accounts.every((id) => config.routing.botIds[id] !== void 0);
|
|
105
|
+
},
|
|
106
|
+
{ path: ["routing", "botIds"], message: "must name exactly the routed accounts" }
|
|
107
|
+
).refine((config) => Object.keys(config.routing.kinds ?? {}).every((id) => config.routing.accounts[id]), {
|
|
107
108
|
path: ["routing", "kinds"],
|
|
108
109
|
message: "must only name routed accounts"
|
|
109
110
|
}).refine(
|
|
110
|
-
(config) => Object.values(config.routing
|
|
111
|
-
(kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing
|
|
111
|
+
(config) => Object.values(config.routing.kinds ?? {}).every(
|
|
112
|
+
(kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing.topics ?? {}, kind))
|
|
112
113
|
),
|
|
113
114
|
{ path: ["routing", "kinds"], message: "a kind cannot be named as a topic: they are labels of two families" }
|
|
114
115
|
);
|
|
115
116
|
var secretsSchema = z2.object({
|
|
116
117
|
CHATWOOT_TOKEN: z2.string().min(1),
|
|
117
|
-
|
|
118
|
+
CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
|
|
118
119
|
TYPESAFE_API_KEY: z2.string().min(1),
|
|
119
|
-
|
|
120
|
+
CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
// scripts/stored-config.ts
|