chatwoot-router 0.5.1 → 0.6.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 +17 -1
- package/README.md +1 -31
- package/lib/packages/chatwoot-router/scripts/store-config.js +6 -1
- package/lib/packages/chatwoot-router/scripts/stored-config.js +6 -1
- package/lib/packages/chatwoot-router/src/config.d.ts +1 -0
- package/lib/packages/chatwoot-router/src/index.js +41 -5
- package/lib/shared/budget.d.ts +4 -0
- package/lib/shared/chatwoot/api.d.ts +2 -2
- package/lib/shared/types.d.ts +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `routing.kinds.<id>.<name>.label` (default `true`): `false` handles the kind without giving the ticket its label,
|
|
15
|
+
for a kind that is not a kind of ticket, such as a thank-you that is only resolved.
|
|
16
|
+
|
|
17
|
+
## [0.5.2] - 2026-10-05
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Clear a request's timeout once its response has been read. The pending `AbortSignal.timeout` timer kept a
|
|
22
|
+
Durable Object invocation open until it fired, so most alarms were billed and reported at about 60 seconds.
|
|
23
|
+
|
|
10
24
|
## [0.5.1] - 2026-10-05
|
|
11
25
|
|
|
12
26
|
### Fixed
|
|
@@ -120,7 +134,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
120
134
|
`routing_kind`.
|
|
121
135
|
- The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
|
|
122
136
|
|
|
123
|
-
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.
|
|
137
|
+
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.6.0...HEAD
|
|
138
|
+
[0.6.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.2...chatwoot-router@0.6.0
|
|
139
|
+
[0.5.2]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.1...chatwoot-router@0.5.2
|
|
124
140
|
[0.5.1]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.0...chatwoot-router@0.5.1
|
|
125
141
|
[0.5.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.4.0...chatwoot-router@0.5.0
|
|
126
142
|
[0.4.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...chatwoot-router@0.4.0
|
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ no unpublished shared package needs installing.
|
|
|
126
126
|
| `routing.kinds.<id>.<name>.covers` | 1–1000 characters | required | What the kind is: Jev's criterion for recognizing it. |
|
|
127
127
|
| `routing.kinds.<id>.<name>.cannedResponse` | short code | unset | The account's Chatwoot canned response sent to the customer once, by the account's agent bot (`CHATWOOT_AGENT_BOT_TOKENS`); handoff if it is missing. |
|
|
128
128
|
| `routing.kinds.<id>.<name>.status` | `resolved` or `snoozed` | unset | Set instead of routing the ticket (`snoozed`: until the customer's next message), after the reply of a `cannedResponse`; a new customer message reopens it. |
|
|
129
|
+
| `routing.kinds.<id>.<name>.label` | boolean | `true` | Whether the ticket gets the kind's label; `false` for a kind that is not a kind of ticket, such as a thank-you that is only resolved. |
|
|
129
130
|
|
|
130
131
|
### Secrets
|
|
131
132
|
|
|
@@ -148,37 +149,6 @@ the Router DO during rollback. If its state is lost or restored to an older reco
|
|
|
148
149
|
and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
|
|
149
150
|
prove another send is safe. Existing pending conversations are reconciled; non-pending conversations stay with people. The user token must see all relevant inboxes.
|
|
150
151
|
|
|
151
|
-
For the first move from the relay's built-in routing (production baseline 0.27.0):
|
|
152
|
-
|
|
153
|
-
1. Record the live relay deployment/version id and CONFIG_KEY before rollout; retain its Hub and old secrets.
|
|
154
|
-
Disconnect all brand bots and prevent competing old deployments. Verify bot identities, owner membership,
|
|
155
|
-
labels, canned responses and inbox visibility. Historical confirmed brand-bot replies need no ledger migration.
|
|
156
|
-
2. Deploy and verify relay 0.30 first. Remove `routing`, `router.accounts` and `router.waitSeconds`; keep every kind
|
|
157
|
-
in `router.keepLabels`. Verify the actual version and that built-in routing/in-flight old work stopped.
|
|
158
|
-
3. Bootstrap this Worker by hand with all four secrets in a protected secrets file while bots remain disconnected.
|
|
159
|
-
Verify KV/domain, upstream permissions and test tickets; health/2xx alone do not prove routing success.
|
|
160
|
-
If using an infra PR whose push deploys both Workers independently, merge only after both hand deployments pass.
|
|
161
|
-
4. Connect bots one account at a time and verify real routing, handoff, failed/unknown reply handling, historical
|
|
162
|
-
reply deduplication and Discord/triage behavior, including at least a complete sweep.
|
|
163
|
-
5. To roll back, disconnect bots first and **keep this router running until no pending ticket remains assigned to
|
|
164
|
-
a disconnected brand bot and no non-pending ticket remains assigned to that bot**. Complete pending/open
|
|
165
|
-
account passes and let failed ending releases finish through their durable retries: pending uses native bot
|
|
166
|
-
handoff, open leftovers use explicit unassignment, and kind endings release resolved/snoozed tickets in the
|
|
167
|
-
same turn. Confirm cleared bot ownership across statuses; preserve human/other-bot owners. Then stop
|
|
168
|
-
webhook entry, cron and queued/in-flight router work; stopping cron alone does not cancel DO alarms. Preserve DO
|
|
169
|
-
namespace and storage, including reply and turn guards. Restore the recorded live 0.27 version **and its
|
|
170
|
-
CONFIG_KEY**, without overlapping old/new routing. Relay 0.27 uses its original Hub's
|
|
171
|
-
`kind-reply:<account>:<conversation>` records; the Router uses separate `reply:<account>:<conversation>`
|
|
172
|
-
records. Restoring code/config does not copy Router attempts into the Hub or reset either ledger. Only replies
|
|
173
|
-
already recorded by 0.27 retain its own reply-once protection; Router-only replies/unknown attempts can be
|
|
174
|
-
attempted again by 0.27. Keep both DOs and review/isolate those tickets before restoring old routing. Rollback
|
|
175
|
-
does not guarantee reply-once across this switch or undo sent messages or other mutations.
|
|
176
|
-
|
|
177
|
-
Current router upgrades need no custom coordination attributes, compatibility effects or one-time lifecycle
|
|
178
|
-
cleanup. Older unreadable job payloads are dropped; live snapshots and the account sweep reconstruct queued work, not durable reply/turn guards.
|
|
179
|
-
If handoff credentials fail, repair them or let a real owner take the tickets; do not bulk-open with the integration
|
|
180
|
-
user token, which can assign every ticket to that user. Keep old attributes through the owner's rollback window.
|
|
181
|
-
|
|
182
152
|
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Logs contain
|
|
183
153
|
ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
|
|
184
154
|
[CONTRIBUTING.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/CONTRIBUTING.md) and [CHANGELOG.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/CHANGELOG.md). Licensed under [MIT](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/LICENSE).
|
|
@@ -141,7 +141,12 @@ var configSchema = z2.strictObject({
|
|
|
141
141
|
* or `snoozed` until the customer's next message. A new message from the customer
|
|
142
142
|
* reopens either.
|
|
143
143
|
*/
|
|
144
|
-
status: z2.enum(["resolved", "snoozed"]).optional()
|
|
144
|
+
status: z2.enum(["resolved", "snoozed"]).optional(),
|
|
145
|
+
/**
|
|
146
|
+
* Whether the ticket gets the kind's label (the default). False for a kind that is not a kind of
|
|
147
|
+
* ticket, such as a thank-you that is only resolved.
|
|
148
|
+
*/
|
|
149
|
+
label: z2.boolean().default(true)
|
|
145
150
|
})
|
|
146
151
|
)
|
|
147
152
|
).optional()
|
|
@@ -93,7 +93,12 @@ var configSchema = z2.strictObject({
|
|
|
93
93
|
* or `snoozed` until the customer's next message. A new message from the customer
|
|
94
94
|
* reopens either.
|
|
95
95
|
*/
|
|
96
|
-
status: z2.enum(["resolved", "snoozed"]).optional()
|
|
96
|
+
status: z2.enum(["resolved", "snoozed"]).optional(),
|
|
97
|
+
/**
|
|
98
|
+
* Whether the ticket gets the kind's label (the default). False for a kind that is not a kind of
|
|
99
|
+
* ticket, such as a thank-you that is only resolved.
|
|
100
|
+
*/
|
|
101
|
+
label: z2.boolean().default(true)
|
|
97
102
|
})
|
|
98
103
|
)
|
|
99
104
|
).optional()
|
|
@@ -95,10 +95,30 @@ var Budget = class {
|
|
|
95
95
|
if (this.used >= this.limit) throw new BudgetExhaustedError();
|
|
96
96
|
this.used += 1;
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The timeout is cleared once the response body has been read (or there is none): a pending
|
|
100
|
+
* timer, such as `AbortSignal.timeout`'s, keeps the invocation open until it fires.
|
|
101
|
+
*/
|
|
102
|
+
fetch = async (request) => {
|
|
103
|
+
if (this.used >= this.limit) throw new BudgetExhaustedError();
|
|
100
104
|
this.used += 1;
|
|
101
|
-
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timer = setTimeout(
|
|
107
|
+
() => controller.abort(new DOMException("The request timed out", "TimeoutError")),
|
|
108
|
+
this.timeoutMs
|
|
109
|
+
);
|
|
110
|
+
try {
|
|
111
|
+
const response = await this.fetchImpl(new Request(request, { signal: controller.signal }));
|
|
112
|
+
if (!response.body) {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
return response;
|
|
115
|
+
}
|
|
116
|
+
const body = response.body.pipeThrough(new TransformStream({ flush: () => clearTimeout(timer) }));
|
|
117
|
+
return new Response(body, response);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
102
122
|
};
|
|
103
123
|
};
|
|
104
124
|
|
|
@@ -343,6 +363,16 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
343
363
|
);
|
|
344
364
|
},
|
|
345
365
|
/** The account's label names (Chatwoot saves them in lower case). */
|
|
366
|
+
/** The contact's latest 25 conversations, newest first, that this user may see. */
|
|
367
|
+
async contactConversations(accountId2, contactId) {
|
|
368
|
+
const list = await data(
|
|
369
|
+
"list contact conversations",
|
|
370
|
+
client.GET("/api/v1/accounts/{account_id}/contacts/{id}/conversations", {
|
|
371
|
+
params: { path: { account_id: accountId2, id: contactId } }
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
return list.payload ?? [];
|
|
375
|
+
},
|
|
346
376
|
async listLabels(accountId2) {
|
|
347
377
|
const list = await data(
|
|
348
378
|
"list labels",
|
|
@@ -430,6 +460,7 @@ function toRelayConversation(conversationId, conversation) {
|
|
|
430
460
|
priority: conversation.priority ?? null,
|
|
431
461
|
labels: conversation.labels ?? [],
|
|
432
462
|
contact: {
|
|
463
|
+
id: meta?.sender?.id ?? null,
|
|
433
464
|
name: meta?.sender?.name ?? null,
|
|
434
465
|
email: meta?.sender?.email ?? null,
|
|
435
466
|
phone: meta?.sender?.phone_number ?? null,
|
|
@@ -855,7 +886,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
855
886
|
const labels = [
|
|
856
887
|
.../* @__PURE__ */ new Set([
|
|
857
888
|
...current.conversation.labels,
|
|
858
|
-
...[topic, kind ? decision.kind : null].filter((label) => label !== null)
|
|
889
|
+
...[topic, kind?.label ? decision.kind : null].filter((label) => label !== null)
|
|
859
890
|
])
|
|
860
891
|
];
|
|
861
892
|
if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
|
|
@@ -1063,7 +1094,12 @@ var configSchema = z5.strictObject({
|
|
|
1063
1094
|
* or `snoozed` until the customer's next message. A new message from the customer
|
|
1064
1095
|
* reopens either.
|
|
1065
1096
|
*/
|
|
1066
|
-
status: z5.enum(["resolved", "snoozed"]).optional()
|
|
1097
|
+
status: z5.enum(["resolved", "snoozed"]).optional(),
|
|
1098
|
+
/**
|
|
1099
|
+
* Whether the ticket gets the kind's label (the default). False for a kind that is not a kind of
|
|
1100
|
+
* ticket, such as a thank-you that is only resolved.
|
|
1101
|
+
*/
|
|
1102
|
+
label: z5.boolean().default(true)
|
|
1067
1103
|
})
|
|
1068
1104
|
)
|
|
1069
1105
|
).optional()
|
package/lib/shared/budget.d.ts
CHANGED
|
@@ -11,5 +11,9 @@ export declare class Budget {
|
|
|
11
11
|
get remaining(): number;
|
|
12
12
|
/** Counts a request that is not a fetch, such as a call to another Durable Object. */
|
|
13
13
|
consume(): void;
|
|
14
|
+
/**
|
|
15
|
+
* The timeout is cleared once the response body has been read (or there is none): a pending
|
|
16
|
+
* timer, such as `AbortSignal.timeout`'s, keeps the invocation open until it fires.
|
|
17
|
+
*/
|
|
14
18
|
readonly fetch: Fetch;
|
|
15
19
|
}
|
|
@@ -185,6 +185,8 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
|
|
|
185
185
|
*/
|
|
186
186
|
unassign(accountId: number, conversationId: number): Promise<void>;
|
|
187
187
|
/** The account's label names (Chatwoot saves them in lower case). */
|
|
188
|
+
/** The contact's latest 25 conversations, newest first, that this user may see. */
|
|
189
|
+
contactConversations(accountId: number, contactId: number): Promise<ChatwootConversation[]>;
|
|
188
190
|
listLabels(accountId: number): Promise<string[]>;
|
|
189
191
|
conversationLabels(accountId: number, conversationId: number): Promise<string[]>;
|
|
190
192
|
/** Replaces the conversation's labels (the spec's "Add Labels" overwrites the list). */
|
|
@@ -232,8 +234,6 @@ export declare function personAssignee(conversation: ChatwootConversation): {
|
|
|
232
234
|
custom_role?: Record<string, unknown> | null;
|
|
233
235
|
}[];
|
|
234
236
|
} | undefined;
|
|
235
|
-
/** An actual public answer; Chatwoot templates include greetings and out-of-office notices. */
|
|
236
|
-
export declare function isAnsweringReply(message: ChatwootMessage): boolean;
|
|
237
237
|
export declare function toRelayConversation(conversationId: number, conversation: ChatwootConversation): RelayConversation;
|
|
238
238
|
export declare function toRelayMessage(message: ChatwootMessage, context: {
|
|
239
239
|
account: {
|
package/lib/shared/types.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface RelayConversation {
|
|
|
43
43
|
priority?: string | null;
|
|
44
44
|
labels: string[];
|
|
45
45
|
contact: {
|
|
46
|
+
/** The contact's Chatwoot id. */
|
|
47
|
+
id?: number | null;
|
|
46
48
|
name?: string | null;
|
|
47
49
|
email?: string | null;
|
|
48
50
|
phone?: string | null;
|
|
@@ -80,6 +82,8 @@ export interface RelayMessage {
|
|
|
80
82
|
/** The Discord avatar of the linked agent who sent the message, if any. */
|
|
81
83
|
discordAvatarUrl?: string;
|
|
82
84
|
sender?: {
|
|
85
|
+
/** The Chatwoot id of the contact, user, or agent bot (see `type`). */
|
|
86
|
+
id?: number | null | undefined;
|
|
83
87
|
name?: string | null | undefined;
|
|
84
88
|
email?: string | null | undefined;
|
|
85
89
|
type?: string | null | undefined;
|