chatwoot-router 0.2.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 +59 -1
- package/README.md +55 -89
- package/lib/packages/chatwoot-router/src/index.js +155 -107
- package/lib/packages/chatwoot-router/src/reply.d.ts +5 -0
- package/lib/packages/chatwoot-router/src/router.d.ts +2 -2
- package/lib/packages/chatwoot-router/src/routing.d.ts +0 -3
- package/lib/packages/chatwoot-router/src/turn.d.ts +1 -21
- package/lib/shared/budget.d.ts +2 -0
- package/lib/shared/chatwoot/api.d.ts +3 -3
- package/lib/shared/store.d.ts +5 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,62 @@ 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
|
+
|
|
22
|
+
## [0.3.0] - 2026-10-03
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- End a kind's turn with resolved/snoozed only after its canned reply is confirmed to exist in Chatwoot. Failed,
|
|
27
|
+
unknown or malformed creation outcomes, incomplete history, and attempts without visible replies hand off to
|
|
28
|
+
people without sending again. A reply seen as failed/deleted in the final fresh read also hands off.
|
|
29
|
+
- Recognize historical public outgoing messages from the account's exact brand bot, including the former relay's
|
|
30
|
+
replies, without moving reply ledgers. Preserve the durable attempt/observed guard across turns and upgrades.
|
|
31
|
+
- Disconnecting an inbox hands its pending brand-bot leftovers to people with native bot `status=open`. The sweep
|
|
32
|
+
reads pending/open account conversations, including old disconnected inboxes, and retries failed handoffs.
|
|
33
|
+
- Release the brand bot immediately after a kind's resolved/snoozed status using its standard unassignment API;
|
|
34
|
+
durable retries finish failed releases. Preserve status, turn boundaries and other owners. The open sweep heals
|
|
35
|
+
Chatwoot webhook-failure fallbacks without scanning resolved/snoozed history.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Partition routing by account and conversation, with a separate paged `Coordinator`. Each conversation owns its
|
|
40
|
+
queue, decisions and reply/turn guards; slow conversations no longer occupy another conversation's executor.
|
|
41
|
+
- Bound webhook admission and sweep child RPC waits. Persist scan progress independently of failed child
|
|
42
|
+
deliveries, so retries retain unavailable targets while later pages and healthy conversations continue.
|
|
43
|
+
- Reuse message pages only within one fresh phase, combine independent read-only preparation, and scope cached
|
|
44
|
+
decisions to actual redacted input and routing configuration/model. Preserve fresh side-effect checks and the
|
|
45
|
+
existing bounded history reads and 45-request budget; inbox-bot ownership is read fresh instead of cached.
|
|
46
|
+
|
|
47
|
+
### Upgrade
|
|
48
|
+
|
|
49
|
+
- This minor release changes deployment bindings. Export and declare SQLite `Router` and `Coordinator`, bound as
|
|
50
|
+
`ROUTER` and `COORDINATOR`, and keep the five-minute reconciliation trigger. Conversation Router identities are
|
|
51
|
+
now `<account>:<conversation>`; account for any existing global Router guards before enabling
|
|
52
|
+
new owners. Keep Worker identities, namespaces/storage and immutable configuration keys.
|
|
53
|
+
- Deploy chatwoot-discord-relay 0.31.0 first, completing its separately authorized
|
|
54
|
+
[partition adoption](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-discord-relay/docs/adoption.md)
|
|
55
|
+
when upgrading an existing Hub. Verify the sole routing owner before connecting bots one account at a time;
|
|
56
|
+
health checks and webhook acknowledgements do not establish successful routing or channel delivery.
|
|
57
|
+
- To stop routing, disconnect bots but retain credentials and keep this router running until pending/open account
|
|
58
|
+
passes and durable ending retries clear all brand-bot ownership. Verify resolved/snoozed endings too, preserving
|
|
59
|
+
human and other-bot owners. Only then stop ingress, cron, queued alarms and in-flight work.
|
|
60
|
+
- Preserve permanent reply attempts, observed/deleted-reply records, unknown guards and turn boundaries. Empty
|
|
61
|
+
history cannot reconstruct lost guards or justify another send. The legacy relay's reply ledger is independent;
|
|
62
|
+
restoring old code/configuration does not transfer Router-only replies or unknown attempts. Once new effects
|
|
63
|
+
exist, use forward repair in the current namespaces; direct version rollback to relay 0.27 is not a safe handover
|
|
64
|
+
and Cloudflare rollback cannot cross Durable Object class/lifecycle changes.
|
|
65
|
+
|
|
10
66
|
## [0.2.0] - 2026-10-03
|
|
11
67
|
|
|
12
68
|
### Changed
|
|
@@ -46,6 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
46
102
|
`routing_kind`.
|
|
47
103
|
- The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
|
|
48
104
|
|
|
49
|
-
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.
|
|
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
|
|
107
|
+
[0.3.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...chatwoot-router@0.3.0
|
|
50
108
|
[0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
|
|
51
109
|
[0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
|
package/README.md
CHANGED
|
@@ -6,80 +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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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).
|
|
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.
|
|
83
27
|
|
|
84
28
|
## Deploy
|
|
85
29
|
|
|
@@ -195,21 +139,43 @@ fail startup; `/healthz` returns 503 without credentials in its response. All se
|
|
|
195
139
|
|
|
196
140
|
## Upgrade and rollback
|
|
197
141
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
142
|
+
Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
|
|
143
|
+
permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot
|
|
144
|
+
rebuild an unknown attempt, an observed reply since deleted, or a missing turn boundary. Do not delete or recreate
|
|
145
|
+
the Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected
|
|
146
|
+
and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
|
|
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.
|
|
148
|
+
|
|
149
|
+
For the first move from the relay's built-in routing (production baseline 0.27.0):
|
|
150
|
+
|
|
151
|
+
1. Record the live relay deployment/version id and CONFIG_KEY before rollout; retain its Hub and old secrets.
|
|
152
|
+
Disconnect all brand bots and prevent competing old deployments. Verify bot identities, owner membership,
|
|
153
|
+
labels, canned responses and inbox visibility. Historical confirmed brand-bot replies need no ledger migration.
|
|
154
|
+
2. Deploy and verify relay 0.30 first. Remove `routing`, `router.accounts` and `router.waitSeconds`; keep every kind
|
|
155
|
+
in `router.keepLabels`. Verify the actual version and that built-in routing/in-flight old work stopped.
|
|
156
|
+
3. Bootstrap this Worker by hand with all four secrets in a protected secrets file while bots remain disconnected.
|
|
157
|
+
Verify KV/domain, upstream permissions and test tickets; health/2xx alone do not prove routing success.
|
|
158
|
+
If using an infra PR whose push deploys both Workers independently, merge only after both hand deployments pass.
|
|
159
|
+
4. Connect bots one account at a time and verify real routing, handoff, failed/unknown reply handling, historical
|
|
160
|
+
reply deduplication and Discord/triage behavior, including at least a complete sweep.
|
|
161
|
+
5. To roll back, disconnect bots first and **keep this router running until no pending ticket remains assigned to
|
|
162
|
+
a disconnected brand bot and no non-pending ticket remains assigned to that bot**. Complete pending/open
|
|
163
|
+
account passes and let failed ending releases finish through their durable retries: pending uses native bot
|
|
164
|
+
handoff, open leftovers use explicit unassignment, and kind endings release resolved/snoozed tickets in the
|
|
165
|
+
same turn. Confirm cleared bot ownership across statuses; preserve human/other-bot owners. Then stop
|
|
166
|
+
webhook entry, cron and queued/in-flight router work; stopping cron alone does not cancel DO alarms. Preserve DO
|
|
167
|
+
namespace and storage, including reply and turn guards. Restore the recorded live 0.27 version **and its
|
|
168
|
+
CONFIG_KEY**, without overlapping old/new routing. Relay 0.27 uses its original Hub's
|
|
169
|
+
`kind-reply:<account>:<conversation>` records; the Router uses separate `reply:<account>:<conversation>`
|
|
170
|
+
records. Restoring code/config does not copy Router attempts into the Hub or reset either ledger. Only replies
|
|
171
|
+
already recorded by 0.27 retain its own reply-once protection; Router-only replies/unknown attempts can be
|
|
172
|
+
attempted again by 0.27. Keep both DOs and review/isolate those tickets before restoring old routing. Rollback
|
|
173
|
+
does not guarantee reply-once across this switch or undo sent messages or other mutations.
|
|
174
|
+
|
|
175
|
+
Current router upgrades need no custom coordination attributes, compatibility effects or one-time lifecycle
|
|
176
|
+
cleanup. Older unreadable job payloads are dropped; live snapshots and the account sweep reconstruct queued work, not durable reply/turn guards.
|
|
177
|
+
If handoff credentials fail, repair them or let a real owner take the tickets; do not bulk-open with the integration
|
|
178
|
+
user token, which can assign every ticket to that user. Keep old attributes through the owner's rollback window.
|
|
213
179
|
|
|
214
180
|
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Logs contain
|
|
215
181
|
ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
|
|
@@ -90,6 +90,11 @@ var Budget = class {
|
|
|
90
90
|
get remaining() {
|
|
91
91
|
return this.limit - this.used;
|
|
92
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
|
+
}
|
|
93
98
|
fetch = (request) => {
|
|
94
99
|
if (this.used >= this.limit) return Promise.reject(new BudgetExhaustedError());
|
|
95
100
|
this.used += 1;
|
|
@@ -136,6 +141,7 @@ var emailSchema = z2.object({
|
|
|
136
141
|
});
|
|
137
142
|
var messageSchema = z2.object({
|
|
138
143
|
id: z2.number(),
|
|
144
|
+
conversation_id: z2.number().optional(),
|
|
139
145
|
content: text,
|
|
140
146
|
message_type: z2.number(),
|
|
141
147
|
content_type: text,
|
|
@@ -158,6 +164,7 @@ var messageSchema = z2.object({
|
|
|
158
164
|
attachments: z2.array(attachmentSchema).nullish()
|
|
159
165
|
});
|
|
160
166
|
var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
|
|
167
|
+
var CONVERSATIONS_PER_PAGE = 25;
|
|
161
168
|
var MESSAGE_HISTORY_PAGE_SIZE = 20;
|
|
162
169
|
var inboxBotSchema = z2.object({
|
|
163
170
|
agent_bot: z2.object({ id: z2.number().int().positive().optional(), account_id: z2.number().int().positive().nullish() }).nullable()
|
|
@@ -197,15 +204,6 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
197
204
|
);
|
|
198
205
|
return list && messageListSchema.parse(list).payload.toSorted((a, b) => a.id - b.id);
|
|
199
206
|
}
|
|
200
|
-
function setCustomAttributes(accountId2, conversationId, attributes) {
|
|
201
|
-
return ensureOk(
|
|
202
|
-
"set custom attribute",
|
|
203
|
-
client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
|
|
204
|
-
params: { path: { account_id: accountId2, conversation_id: conversationId } },
|
|
205
|
-
body: { custom_attributes: attributes, merge: true }
|
|
206
|
-
})
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
207
|
return {
|
|
210
208
|
/** The conversation, or undefined when it does not exist (it was deleted). */
|
|
211
209
|
getConversation(accountId2, conversationId) {
|
|
@@ -231,15 +229,6 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
231
229
|
const messages = await listMessages(accountId2, conversationId, { after: messageId - 1, before: messageId + 1 });
|
|
232
230
|
return messages?.find((message) => message.id === messageId);
|
|
233
231
|
},
|
|
234
|
-
async listInboxes(accountId2) {
|
|
235
|
-
const list = await data(
|
|
236
|
-
"list inboxes",
|
|
237
|
-
client.GET("/api/v1/accounts/{account_id}/inboxes", {
|
|
238
|
-
params: { path: { account_id: accountId2 } }
|
|
239
|
-
})
|
|
240
|
-
);
|
|
241
|
-
return (list.payload ?? []).flatMap((inbox) => inbox.id === void 0 ? [] : [inbox.id]);
|
|
242
|
-
},
|
|
243
232
|
async inboxBot(accountId2, inboxId) {
|
|
244
233
|
const value = await dataUnlessNotFound(
|
|
245
234
|
"get inbox agent bot",
|
|
@@ -375,13 +364,21 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
375
364
|
})
|
|
376
365
|
);
|
|
377
366
|
},
|
|
378
|
-
setCustomAttributes,
|
|
367
|
+
setCustomAttributes(accountId2, conversationId, attributes) {
|
|
368
|
+
return ensureOk(
|
|
369
|
+
"set custom attribute",
|
|
370
|
+
client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
|
|
371
|
+
params: { path: { account_id: accountId2, conversation_id: conversationId } },
|
|
372
|
+
body: { custom_attributes: attributes, merge: true }
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
},
|
|
379
376
|
/**
|
|
380
377
|
* Sends an outgoing message (or private note). With files it is sent as the spec's
|
|
381
378
|
* multipart/form-data body; the generated type renders its `attachments[]` binaries as
|
|
382
379
|
* strings, so the serializer appends the files itself.
|
|
383
380
|
*/
|
|
384
|
-
createMessage(accountId2, conversationId, message) {
|
|
381
|
+
async createMessage(accountId2, conversationId, message) {
|
|
385
382
|
const fields = {
|
|
386
383
|
content: message.content,
|
|
387
384
|
message_type: "outgoing",
|
|
@@ -397,14 +394,17 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
397
394
|
for (const file of message.files) form.append("attachments[]", file.blob, file.filename);
|
|
398
395
|
return form;
|
|
399
396
|
};
|
|
400
|
-
|
|
401
|
-
"
|
|
402
|
-
|
|
397
|
+
const { data: data2, response } = await client.POST(
|
|
398
|
+
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages",
|
|
399
|
+
{
|
|
403
400
|
params: { path: { account_id: accountId2, conversation_id: conversationId } },
|
|
404
401
|
body: fields,
|
|
405
402
|
...message.files.length === 0 ? {} : { bodySerializer: () => multipart(fields) }
|
|
406
|
-
}
|
|
403
|
+
}
|
|
407
404
|
);
|
|
405
|
+
if (!response.ok) throw new ChatwootError(response.status, "create message", notFound(response));
|
|
406
|
+
const parsed = messageSchema.safeParse(data2);
|
|
407
|
+
return parsed.success ? parsed.data : void 0;
|
|
408
408
|
}
|
|
409
409
|
};
|
|
410
410
|
}
|
|
@@ -470,10 +470,6 @@ var QueueStore = class {
|
|
|
470
470
|
);
|
|
471
471
|
CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
|
|
472
472
|
CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);`);
|
|
473
|
-
const columns = this.sql.exec("PRAGMA table_info(jobs)").toArray();
|
|
474
|
-
if (!columns.some((column) => column.name === "suspended")) {
|
|
475
|
-
this.sql.exec("ALTER TABLE jobs ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0");
|
|
476
|
-
}
|
|
477
473
|
}
|
|
478
474
|
// Cache
|
|
479
475
|
get(key) {
|
|
@@ -500,7 +496,7 @@ var QueueStore = class {
|
|
|
500
496
|
enqueue(key, priority, payload, notBefore = this.now()) {
|
|
501
497
|
this.sql.exec(
|
|
502
498
|
`INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
|
|
503
|
-
ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
|
|
499
|
+
ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
|
|
504
500
|
not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
|
|
505
501
|
key,
|
|
506
502
|
priority,
|
|
@@ -511,7 +507,7 @@ var QueueStore = class {
|
|
|
511
507
|
}
|
|
512
508
|
nextDueJob() {
|
|
513
509
|
const row = this.sql.exec(
|
|
514
|
-
`SELECT key, payload, version, attempts, created_at FROM jobs WHERE
|
|
510
|
+
`SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
|
|
515
511
|
ORDER BY priority, not_before, created_at LIMIT 1`,
|
|
516
512
|
this.now()
|
|
517
513
|
).toArray()[0];
|
|
@@ -521,7 +517,7 @@ var QueueStore = class {
|
|
|
521
517
|
}
|
|
522
518
|
/** Earliest due time. */
|
|
523
519
|
nextWakeup() {
|
|
524
|
-
const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs
|
|
520
|
+
const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
|
|
525
521
|
return row?.at ?? void 0;
|
|
526
522
|
}
|
|
527
523
|
/** Removes a finished job unless it was enqueued again while running. */
|
|
@@ -546,20 +542,18 @@ var QueueStore = class {
|
|
|
546
542
|
deferJob(job, delayMs = 0) {
|
|
547
543
|
this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
|
|
548
544
|
}
|
|
549
|
-
/**
|
|
550
|
-
|
|
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) {
|
|
551
550
|
this.sql.exec(
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
this.now() + 1e3,
|
|
551
|
+
"UPDATE jobs SET not_before = ?, attempts = 0 WHERE key = ? AND version = ?",
|
|
552
|
+
this.now() + delayMs,
|
|
555
553
|
job.key,
|
|
556
554
|
job.version
|
|
557
555
|
);
|
|
558
556
|
}
|
|
559
|
-
/** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
|
|
560
|
-
wakeHeldJobs() {
|
|
561
|
-
this.sql.exec("UPDATE jobs SET suspended = 0, not_before = ? WHERE suspended = 2", this.now());
|
|
562
|
-
}
|
|
563
557
|
prune() {
|
|
564
558
|
this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
|
|
565
559
|
}
|
|
@@ -569,6 +563,28 @@ var QueueStore = class {
|
|
|
569
563
|
import ipRegex from "ip-regex";
|
|
570
564
|
import { z as z4 } from "zod";
|
|
571
565
|
|
|
566
|
+
// src/reply.ts
|
|
567
|
+
function confirmedReply(message, botId) {
|
|
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;
|
|
569
|
+
}
|
|
570
|
+
async function replyHistory(chatwoot, accountId2, conversationId, botId) {
|
|
571
|
+
let before;
|
|
572
|
+
for (let page = 0; page < 5; page += 1) {
|
|
573
|
+
const messages = await chatwoot.listMessages(accountId2, conversationId, before === void 0 ? {} : { before });
|
|
574
|
+
for (const message of messages) {
|
|
575
|
+
if (confirmedReply(message, botId)) return "found";
|
|
576
|
+
if (message.message_type !== 1 || message.private === true) continue;
|
|
577
|
+
if (message.sender?.type == null || message.sender.type === "agent_bot" && (message.sender.id == null || message.sender.id === botId))
|
|
578
|
+
return "unknown";
|
|
579
|
+
}
|
|
580
|
+
if (messages.length < MESSAGE_HISTORY_PAGE_SIZE) return "complete-none";
|
|
581
|
+
const next = messages[0]?.id;
|
|
582
|
+
if (next === void 0 || before !== void 0 && next >= before) return "unknown";
|
|
583
|
+
before = next;
|
|
584
|
+
}
|
|
585
|
+
return "unknown";
|
|
586
|
+
}
|
|
587
|
+
|
|
572
588
|
// src/turn.ts
|
|
573
589
|
import { z as z3 } from "zod";
|
|
574
590
|
var guardSchema = z3.object({
|
|
@@ -749,17 +765,33 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
749
765
|
const bot = chatwootClient(settings.config.chatwoot.baseUrl, token, ctx.fetch);
|
|
750
766
|
const kinds = routing.kinds?.[String(accountId2)] ?? {};
|
|
751
767
|
const botId = routing.botIds[String(accountId2)];
|
|
768
|
+
const owns = (conversation) => conversation?.meta?.assignee_type === "AgentBot" && conversation.meta.assignee?.id === botId;
|
|
769
|
+
const release = async () => {
|
|
770
|
+
const latest = await chatwoot.getConversation(accountId2, conversationId);
|
|
771
|
+
if (latest?.status !== void 0 && latest.status !== "pending" && owns(latest)) {
|
|
772
|
+
await bot.unassign(accountId2, conversationId);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
752
775
|
const snapshot = async (inboxId) => {
|
|
753
776
|
const raw = await chatwoot.getConversation(accountId2, conversationId);
|
|
754
777
|
if (!raw || raw.inbox_id === void 0 || inboxId !== void 0 && raw.inbox_id !== inboxId) return;
|
|
755
778
|
if (raw.status !== "pending") {
|
|
756
779
|
observeStatus(store, accountId2, conversationId, raw.status ?? "open");
|
|
780
|
+
if (raw.status !== void 0 && owns(raw)) await release();
|
|
757
781
|
return;
|
|
758
782
|
}
|
|
759
783
|
const conversation = toRelayConversation(conversationId, raw);
|
|
760
|
-
if (conversation.
|
|
784
|
+
if (conversation.assignee) return;
|
|
761
785
|
if (raw.meta?.assignee && (raw.meta.assignee_type !== "AgentBot" || raw.meta.assignee.id !== botId)) return;
|
|
762
|
-
if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId)
|
|
786
|
+
if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId) {
|
|
787
|
+
if (owns(raw)) {
|
|
788
|
+
const latest = await chatwoot.getConversation(accountId2, conversationId);
|
|
789
|
+
if (latest?.status === "pending" && latest.inbox_id === raw.inbox_id && owns(latest))
|
|
790
|
+
await bot.setStatus(accountId2, conversationId, { status: "open" });
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (conversation.contact.blocked) return;
|
|
763
795
|
observeStatus(store, accountId2, conversationId, "pending");
|
|
764
796
|
return { raw, conversation };
|
|
765
797
|
};
|
|
@@ -782,15 +814,15 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
782
814
|
};
|
|
783
815
|
const turn = await readTurn(chatwoot, store, accountId2, conversationId);
|
|
784
816
|
const input = inputs(turn.messages);
|
|
785
|
-
const
|
|
786
|
-
(message) => message.message_type === 1 && !message.private && message.sender?.type === "user" && !message.content_attributes?.deleted
|
|
817
|
+
const requiresHandoff = (messages) => messages.some(
|
|
818
|
+
(message) => message.message_type === 1 && !message.private && (message.sender?.type === "user" && !message.content_attributes?.deleted || message.sender?.type === "agent_bot" && message.sender.id === botId && (message.status === "failed" || message.content_attributes?.deleted))
|
|
787
819
|
);
|
|
788
820
|
const fresh = async () => {
|
|
789
821
|
const current2 = await snapshot(initial.raw.inbox_id);
|
|
790
822
|
if (!current2) return;
|
|
791
823
|
const latest = await readTurn(chatwoot, store, accountId2, conversationId);
|
|
792
824
|
if (latest.boundary !== turn.boundary || inputs(latest.messages).key !== input.key) return "defer";
|
|
793
|
-
return { ...current2, handoff: latest.handoff ||
|
|
825
|
+
return { ...current2, handoff: latest.handoff || requiresHandoff(latest.messages) };
|
|
794
826
|
};
|
|
795
827
|
const handoff = async (current2) => {
|
|
796
828
|
requestHandoff(store, accountId2, conversationId);
|
|
@@ -799,9 +831,10 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
799
831
|
if (current2) await bot.setStatus(accountId2, conversationId, { status: "open" });
|
|
800
832
|
return void 0;
|
|
801
833
|
};
|
|
802
|
-
if (turn.handoff ||
|
|
834
|
+
if (turn.handoff || requiresHandoff(turn.messages) || input.count === 0) return handoff();
|
|
803
835
|
const memoKey = `decision:${accountId2}:${conversationId}:${input.key}`;
|
|
804
|
-
|
|
836
|
+
const memo = decisionSchema.safeParse(parseJson(store.get(memoKey)));
|
|
837
|
+
let decision = memo.success ? memo.data : void 0;
|
|
805
838
|
if (!decision) {
|
|
806
839
|
decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], input.text);
|
|
807
840
|
store.set(memoKey, JSON.stringify(decision));
|
|
@@ -821,14 +854,31 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
821
854
|
];
|
|
822
855
|
if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
|
|
823
856
|
const replyKey = `reply:${accountId2}:${conversationId}`;
|
|
824
|
-
if (kind?.cannedResponse
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
857
|
+
if (kind?.cannedResponse) {
|
|
858
|
+
let history;
|
|
859
|
+
try {
|
|
860
|
+
history = await replyHistory(chatwoot, accountId2, conversationId, botId);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
log.warn("reply history unavailable; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
|
|
863
|
+
return handoff();
|
|
864
|
+
}
|
|
865
|
+
if (history === "unknown" || history === "complete-none" && store.get(replyKey) !== void 0) return handoff();
|
|
866
|
+
if (history === "found" && store.get(replyKey) === void 0) store.set(replyKey, "observed");
|
|
867
|
+
if (history === "complete-none") {
|
|
868
|
+
const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
|
|
869
|
+
if (!content?.trim()) return handoff();
|
|
870
|
+
current = await fresh();
|
|
871
|
+
if (current === "defer" || !current) return current;
|
|
872
|
+
if (current.handoff) return handoff(current);
|
|
873
|
+
store.set(replyKey, "attempted");
|
|
874
|
+
try {
|
|
875
|
+
const message = await bot.createMessage(accountId2, conversationId, { content, private: false, files: [] });
|
|
876
|
+
if (!confirmedReply(message, botId) || message?.conversation_id !== conversationId) return handoff();
|
|
877
|
+
} catch (error) {
|
|
878
|
+
log.warn("reply creation unconfirmed; handing off", { accountId: accountId2, conversationId, ...errorFields(error) });
|
|
879
|
+
return handoff();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
832
882
|
}
|
|
833
883
|
if (assignee !== void 0 && !kind?.status) {
|
|
834
884
|
const agents = await chatwoot.listAgents(accountId2);
|
|
@@ -840,6 +890,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
840
890
|
if (kind?.status) {
|
|
841
891
|
await bot.setStatus(accountId2, conversationId, { status: kind.status });
|
|
842
892
|
expectActivity(store, accountId2, conversationId, { status: kind.status });
|
|
893
|
+
await release();
|
|
843
894
|
} else if (assignee !== void 0 && current.raw.meta?.assignee_type === "AgentBot") {
|
|
844
895
|
await bot.assign(accountId2, conversationId, assignee);
|
|
845
896
|
expectActivity(store, accountId2, conversationId, { status: "open" });
|
|
@@ -929,10 +980,6 @@ async function decide(ctx, owners, kinds, text2) {
|
|
|
929
980
|
noRequest: request.choice === "none" && request.confidence >= routing.minConfidence
|
|
930
981
|
};
|
|
931
982
|
}
|
|
932
|
-
function readDecision(stored) {
|
|
933
|
-
const parsed = decisionSchema.safeParse(parseJson(stored));
|
|
934
|
-
return parsed.success ? parsed.data : void 0;
|
|
935
|
-
}
|
|
936
983
|
|
|
937
984
|
// ../../shared/settings.ts
|
|
938
985
|
function settingsLoader(parse) {
|
|
@@ -1057,35 +1104,26 @@ function buildSettings(config, secrets) {
|
|
|
1057
1104
|
var loadSettings = settingsLoader(parseSettings);
|
|
1058
1105
|
|
|
1059
1106
|
// src/router.ts
|
|
1060
|
-
var
|
|
1061
|
-
var
|
|
1062
|
-
|
|
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}`);
|
|
1114
|
+
}
|
|
1063
1115
|
var RUN_WALL_MS = 5 * 60 * 1e3;
|
|
1064
1116
|
var id = z6.number().int().positive();
|
|
1065
1117
|
var jobSchema = z6.discriminatedUnion("type", [
|
|
1066
1118
|
z6.object({ type: z6.literal("route"), accountId: id, conversationId: id }),
|
|
1067
|
-
z6.object({ type: z6.literal("sweep"), accountId: id })
|
|
1119
|
+
z6.object({ type: z6.literal("sweep"), accountId: id, status: z6.enum(["pending", "open"]) })
|
|
1068
1120
|
]);
|
|
1069
|
-
var passSchema = z6.object({
|
|
1070
|
-
page: z6.number().int().positive(),
|
|
1071
|
-
remaining: z6.array(id),
|
|
1072
|
-
routed: z6.array(id)
|
|
1073
|
-
});
|
|
1074
1121
|
var Router = class extends DurableObject {
|
|
1075
1122
|
store;
|
|
1076
1123
|
constructor(context, env) {
|
|
1077
1124
|
super(context, env);
|
|
1078
1125
|
this.store = new QueueStore(context.storage.sql);
|
|
1079
|
-
|
|
1080
|
-
this.store.migrate();
|
|
1081
|
-
if (!this.store.get("migration:agent-bot")) {
|
|
1082
|
-
context.storage.sql.exec("DELETE FROM jobs");
|
|
1083
|
-
for (const prefix of ["seen:", "decision:", "assign:", "labels:", "status:", "sweep:"]) {
|
|
1084
|
-
context.storage.sql.exec("DELETE FROM cache WHERE key >= ? AND key < ?", prefix, `${prefix.slice(0, -1)};`);
|
|
1085
|
-
}
|
|
1086
|
-
this.store.set("migration:agent-bot", "1");
|
|
1087
|
-
}
|
|
1088
|
-
});
|
|
1126
|
+
this.store.migrate();
|
|
1089
1127
|
}
|
|
1090
1128
|
async enqueueConversation(accountId2, conversationId, transition) {
|
|
1091
1129
|
const settings = await loadSettings(this.env);
|
|
@@ -1096,7 +1134,8 @@ var Router = class extends DurableObject {
|
|
|
1096
1134
|
}
|
|
1097
1135
|
async requestSweep() {
|
|
1098
1136
|
for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
|
|
1099
|
-
|
|
1137
|
+
for (const status of ["pending", "open"])
|
|
1138
|
+
this.enqueue({ type: "sweep", accountId: Number(accountId2), status });
|
|
1100
1139
|
}
|
|
1101
1140
|
await this.schedule();
|
|
1102
1141
|
}
|
|
@@ -1129,7 +1168,13 @@ var Router = class extends DurableObject {
|
|
|
1129
1168
|
}
|
|
1130
1169
|
clearFailures(this.store, payload.accountId, payload.conversationId);
|
|
1131
1170
|
} else if (routesAccount(settings, payload.accountId)) {
|
|
1132
|
-
await this.sweep(
|
|
1171
|
+
await this.sweep(
|
|
1172
|
+
chatwoot,
|
|
1173
|
+
budget,
|
|
1174
|
+
payload.accountId,
|
|
1175
|
+
payload.status,
|
|
1176
|
+
settings.config.routing.botIds[String(payload.accountId)]
|
|
1177
|
+
);
|
|
1133
1178
|
}
|
|
1134
1179
|
this.store.completeJob(job);
|
|
1135
1180
|
} catch (error) {
|
|
@@ -1157,34 +1202,37 @@ var Router = class extends DurableObject {
|
|
|
1157
1202
|
}
|
|
1158
1203
|
await this.schedule(yielded ? Date.now() : void 0);
|
|
1159
1204
|
}
|
|
1160
|
-
async sweep(
|
|
1161
|
-
const key = `sweep:${accountId2}
|
|
1162
|
-
const saved =
|
|
1163
|
-
const
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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;
|
|
1182
1230
|
}
|
|
1183
|
-
this.store.set(key,
|
|
1184
|
-
this.enqueue({ type: "sweep", accountId: accountId2 });
|
|
1231
|
+
this.store.set(key, String(page + 1));
|
|
1232
|
+
this.enqueue({ type: "sweep", accountId: accountId2, status });
|
|
1185
1233
|
}
|
|
1186
1234
|
enqueue(payload) {
|
|
1187
|
-
const key = payload.type === "sweep" ? `sweep:${payload.accountId}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
|
|
1235
|
+
const key = payload.type === "sweep" ? `sweep:${payload.accountId}:${payload.status}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
|
|
1188
1236
|
this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
|
|
1189
1237
|
}
|
|
1190
1238
|
async schedule(at) {
|
|
@@ -1271,7 +1319,7 @@ app.post("/chatwoot/agent-bot", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (
|
|
|
1271
1319
|
if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
|
|
1272
1320
|
return context.text("account does not match the webhook secret", 403);
|
|
1273
1321
|
}
|
|
1274
|
-
await context.env.
|
|
1322
|
+
await conversationRouter(context.env, target.accountId, target.conversationId).enqueueConversation(
|
|
1275
1323
|
target.accountId,
|
|
1276
1324
|
target.conversationId,
|
|
1277
1325
|
target.transition
|
|
@@ -1286,7 +1334,7 @@ app.onError((error, context) => {
|
|
|
1286
1334
|
var src_default = {
|
|
1287
1335
|
fetch: app.fetch,
|
|
1288
1336
|
async scheduled(_controller, env, context) {
|
|
1289
|
-
context.waitUntil(env
|
|
1337
|
+
context.waitUntil(sweeper(env).requestSweep());
|
|
1290
1338
|
}
|
|
1291
1339
|
};
|
|
1292
1340
|
export {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ChatwootClient, type ChatwootMessage } from "../../../shared/chatwoot/api.ts";
|
|
2
|
+
/** A Chatwoot creation receipt, not a channel delivery receipt. */
|
|
3
|
+
export declare function confirmedReply(message: ChatwootMessage | undefined, botId: number | undefined): boolean;
|
|
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): Promise<"found" | "complete-none" | "unknown">;
|
|
@@ -1,8 +1,8 @@
|
|
|
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
|
|
5
|
-
export declare
|
|
4
|
+
export declare function sweeper(env: Env): DurableObjectStub<Router>;
|
|
5
|
+
export declare function conversationRouter(env: Env, accountId: number, conversationId: number): DurableObjectStub<Router>;
|
|
6
6
|
export declare class Router extends DurableObject<Env> {
|
|
7
7
|
private readonly store;
|
|
8
8
|
constructor(context: DurableObjectState, env: Env);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { type ChatwootClient, type Fetch } from "../../../shared/chatwoot/api.ts";
|
|
2
2
|
import type { Settings } from "./config.ts";
|
|
3
|
-
/** Jev's answer when no owner fits; also the reserved route name. */
|
|
4
|
-
export declare const UNCLEAR = "unclear";
|
|
5
3
|
export interface RoutingStore {
|
|
6
4
|
get(key: string): string | undefined;
|
|
7
5
|
set(key: string, value: string): void;
|
|
@@ -15,4 +13,3 @@ export interface RoutingContext {
|
|
|
15
13
|
}
|
|
16
14
|
export declare function routesAccount(settings: Settings, accountId: number): boolean;
|
|
17
15
|
export declare function routeConversation(ctx: RoutingContext, accountId: number, conversationId: number): Promise<"defer" | undefined>;
|
|
18
|
-
export declare function sanitize(text: string, identities: Array<string | null | undefined>): string;
|
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { type ChatwootClient } from "../../../shared/chatwoot/api.ts";
|
|
3
2
|
import type { RoutingStore } from "./routing.ts";
|
|
4
3
|
import type { Transition } from "./webhook.ts";
|
|
5
|
-
declare const guardSchema: z.ZodObject<{
|
|
6
|
-
boundary: z.ZodOptional<z.ZodNumber>;
|
|
7
|
-
claimed: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
-
pending: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
-
transitionAt: z.ZodOptional<z.ZodNumber>;
|
|
10
|
-
expected: z.ZodOptional<z.ZodObject<{
|
|
11
|
-
status: z.ZodString;
|
|
12
|
-
at: z.ZodOptional<z.ZodNumber>;
|
|
13
|
-
after: z.ZodOptional<z.ZodNumber>;
|
|
14
|
-
}, z.core.$strip>>;
|
|
15
|
-
handoff: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
-
failures: z.ZodDefault<z.ZodNumber>;
|
|
17
|
-
}, z.core.$strip>;
|
|
18
|
-
type Guard = z.infer<typeof guardSchema>;
|
|
19
|
-
export declare function readGuard(store: RoutingStore, accountId: number, conversationId: number): Guard;
|
|
20
|
-
export declare function saveGuard(store: RoutingStore, accountId: number, conversationId: number, guard: Guard): void;
|
|
21
4
|
export declare function expectActivity(store: RoutingStore, accountId: number, conversationId: number, transition: Transition): void;
|
|
22
5
|
/** Preserve an observed pending departure across retries, without using metadata timestamps. */
|
|
23
6
|
export declare function observeStatus(store: RoutingStore, accountId: number, conversationId: number, status: string): void;
|
|
@@ -25,12 +8,10 @@ export declare function requestHandoff(store: RoutingStore, accountId: number, c
|
|
|
25
8
|
/** Counts processing failures for this turn, independently of an old queued job's backoff. */
|
|
26
9
|
export declare function recordFailure(store: RoutingStore, accountId: number, conversationId: number): number;
|
|
27
10
|
export declare function clearFailures(store: RoutingStore, accountId: number, conversationId: number): void;
|
|
28
|
-
export declare class ActivityPendingError extends Error {
|
|
29
|
-
constructor();
|
|
30
|
-
}
|
|
31
11
|
export declare function readTurn(chatwoot: ChatwootClient, store: RoutingStore, accountId: number, conversationId: number): Promise<{
|
|
32
12
|
messages: {
|
|
33
13
|
id: number;
|
|
14
|
+
conversation_id?: number | undefined;
|
|
34
15
|
content?: string | null | undefined;
|
|
35
16
|
message_type: number;
|
|
36
17
|
content_type?: string | null | undefined;
|
|
@@ -83,4 +64,3 @@ export declare function readTurn(chatwoot: ChatwootClient, store: RoutingStore,
|
|
|
83
64
|
boundary: number;
|
|
84
65
|
handoff: boolean;
|
|
85
66
|
}>;
|
|
86
|
-
export {};
|
package/lib/shared/budget.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export declare class Budget {
|
|
|
9
9
|
private used;
|
|
10
10
|
constructor(limit: number, fetchImpl?: Fetch, timeoutMs?: number);
|
|
11
11
|
get remaining(): number;
|
|
12
|
+
/** Counts a request that is not a fetch, such as a call to another Durable Object. */
|
|
13
|
+
consume(): void;
|
|
12
14
|
readonly fetch: Fetch;
|
|
13
15
|
}
|
|
@@ -19,6 +19,7 @@ export type ChatwootConversation = components["schemas"]["conversation_show"] &
|
|
|
19
19
|
};
|
|
20
20
|
declare const messageSchema: z.ZodObject<{
|
|
21
21
|
id: z.ZodNumber;
|
|
22
|
+
conversation_id: z.ZodOptional<z.ZodNumber>;
|
|
22
23
|
content: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
23
24
|
message_type: z.ZodNumber;
|
|
24
25
|
content_type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -107,7 +108,6 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
|
|
|
107
108
|
* (MessageFinder#messages_between at v4.18.0) or exclusively (as the spec describes it).
|
|
108
109
|
*/
|
|
109
110
|
getMessage(accountId: number, conversationId: number, messageId: number): Promise<ChatwootMessage | undefined>;
|
|
110
|
-
listInboxes(accountId: number): Promise<number[]>;
|
|
111
111
|
inboxBot(accountId: number, inboxId: number): Promise<{
|
|
112
112
|
id: number;
|
|
113
113
|
} | undefined>;
|
|
@@ -186,13 +186,13 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
|
|
|
186
186
|
conversationLabels(accountId: number, conversationId: number): Promise<string[]>;
|
|
187
187
|
/** Replaces the conversation's labels (the spec's "Add Labels" overwrites the list). */
|
|
188
188
|
setLabels(accountId: number, conversationId: number, labels: string[]): Promise<void>;
|
|
189
|
-
setCustomAttributes
|
|
189
|
+
setCustomAttributes(accountId: number, conversationId: number, attributes: Record<string, unknown>): Promise<void>;
|
|
190
190
|
/**
|
|
191
191
|
* Sends an outgoing message (or private note). With files it is sent as the spec's
|
|
192
192
|
* multipart/form-data body; the generated type renders its `attachments[]` binaries as
|
|
193
193
|
* strings, so the serializer appends the files itself.
|
|
194
194
|
*/
|
|
195
|
-
createMessage(accountId: number, conversationId: number, message: NewMessage): Promise<
|
|
195
|
+
createMessage(accountId: number, conversationId: number, message: NewMessage): Promise<ChatwootMessage | undefined>;
|
|
196
196
|
};
|
|
197
197
|
/** A bot (or another explicit non-user assignee) must never be matched to a numeric user id. */
|
|
198
198
|
export declare function personAssignee(conversation: ChatwootConversation): {
|
package/lib/shared/store.d.ts
CHANGED
|
@@ -32,9 +32,10 @@ export declare class QueueStore {
|
|
|
32
32
|
* budget, or Discord rate limits it.
|
|
33
33
|
*/
|
|
34
34
|
deferJob(job: Job, delayMs?: number): void;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
39
40
|
prune(): void;
|
|
40
41
|
}
|