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