chatwoot-router 0.5.2 → 0.7.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 +28 -6
- package/README.md +7 -3
- 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 +28 -6
- package/lib/shared/chatwoot/api.d.ts +2 -0
- package/lib/shared/types.d.ts +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,21 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.7.0] - 2026-10-07
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Jev sees an email's sender domain (`Email sender domain: mg1.substack.com`) before the customer's text, never the
|
|
15
|
+
address or name: a newsletter or notification sent through a mailing service is told from a person, so the spam
|
|
16
|
+
kind can end it. Other identifiers are redacted as before.
|
|
13
17
|
|
|
14
18
|
### Fixed
|
|
15
19
|
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
- A ticket with no request yet waits for the customer to say more for 30 minutes at most, then the sweep routes it on
|
|
21
|
+
like any other ticket. An email that asked for nothing (a newsletter Jev did not call spam) stayed pending, with no
|
|
22
|
+
post and no owner, until someone noticed it.
|
|
23
|
+
|
|
24
|
+
## [0.6.0] - 2026-10-05
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `routing.kinds.<id>.<name>.label` (default `true`): `false` handles the kind without giving the ticket its label,
|
|
29
|
+
for a kind that is not a kind of ticket, such as a thank-you that is only resolved.
|
|
30
|
+
|
|
31
|
+
## [0.5.2] - 2026-10-05
|
|
19
32
|
|
|
20
33
|
### Fixed
|
|
21
34
|
|
|
22
35
|
- Clear a request's timeout once its response has been read. The pending `AbortSignal.timeout` timer kept a
|
|
23
36
|
Durable Object invocation open until it fired, so most alarms were billed and reported at about 60 seconds.
|
|
24
37
|
|
|
38
|
+
## [0.5.1] - 2026-10-05
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Route on a customer's message as it arrives. Chatwoot's message webhook describes a contact without a `type`
|
|
43
|
+
(`Contact#webhook_data`; users and bots have one), and the router required `type: "contact"`, so it ignored
|
|
44
|
+
every customer message and a new ticket waited for the five-minute sweep.
|
|
45
|
+
|
|
25
46
|
## [0.5.0] - 2026-10-05
|
|
26
47
|
|
|
27
48
|
### Fixed
|
|
@@ -127,7 +148,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
127
148
|
`routing_kind`.
|
|
128
149
|
- The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
|
|
129
150
|
|
|
130
|
-
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.
|
|
151
|
+
[Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.6.0...HEAD
|
|
152
|
+
[0.6.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.2...chatwoot-router@0.6.0
|
|
131
153
|
[0.5.2]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.1...chatwoot-router@0.5.2
|
|
132
154
|
[0.5.1]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.0...chatwoot-router@0.5.1
|
|
133
155
|
[0.5.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.4.0...chatwoot-router@0.5.0
|
package/README.md
CHANGED
|
@@ -12,12 +12,14 @@ is each routed account's native agent bot, connected to the inboxes it should ro
|
|
|
12
12
|
1. A new ticket in a connected inbox is pending, so Chatwoot sends its events to the router's
|
|
13
13
|
`/chatwoot/agent-bot` webhook. Each conversation is routed by a Durable Object of its own, so a slow request
|
|
14
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
|
|
15
|
+
2. The router reads the customer's first messages of this turn (redacted, at most three and 1,600 characters; an
|
|
16
|
+
email's sender domain leads them)
|
|
16
17
|
and asks TypeSafe's System One API (`/v1/systemone`, model `routing.model`, Jev by default; `routing.endpoint`
|
|
17
18
|
may point to another deployment) for the owner, topic, kind, and whether there is a request yet.
|
|
18
19
|
3. It applies the topic and kind labels, sends the kind's canned reply if it has one (at most once per
|
|
19
20
|
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
|
+
people (`status=open`). A greeting with no request yet stays pending until the customer says more, for up to 30
|
|
22
|
+
minutes (an email that asks for nothing will not say more); then it is routed on. A resolved
|
|
21
23
|
ticket that a customer message reopens keeps its owner: its kind may still end it (a thanks is resolved),
|
|
22
24
|
otherwise it goes back to the owner.
|
|
23
25
|
4. Anything uncertain hands off to people: an unclear owner, a failed or unconfirmed reply, a human replying,
|
|
@@ -126,6 +128,7 @@ no unpublished shared package needs installing.
|
|
|
126
128
|
| `routing.kinds.<id>.<name>.covers` | 1–1000 characters | required | What the kind is: Jev's criterion for recognizing it. |
|
|
127
129
|
| `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
130
|
| `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. |
|
|
131
|
+
| `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
132
|
|
|
130
133
|
### Secrets
|
|
131
134
|
|
|
@@ -148,6 +151,7 @@ the Router DO during rollback. If its state is lost or restored to an older reco
|
|
|
148
151
|
and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
|
|
149
152
|
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
153
|
|
|
151
|
-
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe
|
|
154
|
+
Redaction is best effort, not anonymization: other personal information can still reach TypeSafe, and an email's
|
|
155
|
+
sender domain is sent on purpose (never the address or name). Logs contain
|
|
152
156
|
ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
|
|
153
157
|
[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()
|
|
@@ -363,6 +363,16 @@ function chatwootClient(baseUrl, token, fetch2) {
|
|
|
363
363
|
);
|
|
364
364
|
},
|
|
365
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
|
+
},
|
|
366
376
|
async listLabels(accountId2) {
|
|
367
377
|
const list = await data(
|
|
368
378
|
"list labels",
|
|
@@ -450,6 +460,7 @@ function toRelayConversation(conversationId, conversation) {
|
|
|
450
460
|
priority: conversation.priority ?? null,
|
|
451
461
|
labels: conversation.labels ?? [],
|
|
452
462
|
contact: {
|
|
463
|
+
id: meta?.sender?.id ?? null,
|
|
453
464
|
name: meta?.sender?.name ?? null,
|
|
454
465
|
email: meta?.sender?.email ?? null,
|
|
455
466
|
phone: meta?.sender?.phone_number ?? null,
|
|
@@ -732,6 +743,7 @@ var NO_KIND = "none";
|
|
|
732
743
|
var NO_KIND_CRITERION = "None of the other kinds.";
|
|
733
744
|
var MAX_MESSAGES = 3;
|
|
734
745
|
var MAX_TEXT = 1600;
|
|
746
|
+
var REQUEST_WAIT_SECONDS = 30 * 60;
|
|
735
747
|
var REDACTIONS = [
|
|
736
748
|
/(?<![\w.+-])[\w.+-]+@[\w.-]+\.[a-z]{2,}(?!\w)/gi,
|
|
737
749
|
// email
|
|
@@ -824,17 +836,20 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
824
836
|
const initial = await snapshot();
|
|
825
837
|
if (!initial) return;
|
|
826
838
|
const identities = [initial.conversation.contact.name, initial.conversation.contact.email];
|
|
839
|
+
const senderDomain = initial.conversation.channel === "Channel::Email" ? initial.conversation.contact.email?.split("@")[1] : void 0;
|
|
827
840
|
const inputs = (messages) => {
|
|
828
841
|
const texts = messages.filter(isCustomer).flatMap((message) => {
|
|
829
|
-
const
|
|
842
|
+
const text3 = sanitize(
|
|
830
843
|
[message.content_attributes?.email?.subject ?? "", messageContent(message)].join("\n"),
|
|
831
844
|
identities
|
|
832
845
|
);
|
|
833
|
-
return
|
|
846
|
+
return text3.replaceAll("[REDACTED]", "").trim() ? [{ id: message.id, text: text3 }] : [];
|
|
834
847
|
}).slice(0, MAX_MESSAGES);
|
|
848
|
+
const text2 = texts.map((entry) => entry.text).join(" ");
|
|
835
849
|
return {
|
|
836
850
|
key: texts.map((entry) => entry.id).join(","),
|
|
837
|
-
text:
|
|
851
|
+
text: (senderDomain ? `Email sender domain: ${senderDomain}
|
|
852
|
+
${text2}` : text2).slice(0, MAX_TEXT),
|
|
838
853
|
count: texts.length
|
|
839
854
|
};
|
|
840
855
|
};
|
|
@@ -870,12 +885,14 @@ async function routeConversation(ctx, accountId2, conversationId) {
|
|
|
870
885
|
let current = await fresh();
|
|
871
886
|
if (current === "defer" || !current) return current;
|
|
872
887
|
if (current.handoff) return handoff(current);
|
|
873
|
-
|
|
888
|
+
const lastSaid = Math.max(0, ...turn.messages.filter(isCustomer).map((message) => message.created_at ?? Infinity));
|
|
889
|
+
const waiting = Date.now() / 1e3 - lastSaid < REQUEST_WAIT_SECONDS;
|
|
890
|
+
if (!kind && decision.noRequest && input.count < MAX_MESSAGES && !current.conversation.assignee && waiting) return;
|
|
874
891
|
const topic = !kind?.status && decision.topic !== null && Object.hasOwn(routing.topics ?? {}, decision.topic) && decision.topicConfidence >= routing.minConfidence && current.conversation.labels.every((label) => Object.hasOwn(kinds, label)) ? decision.topic : null;
|
|
875
892
|
const labels = [
|
|
876
893
|
.../* @__PURE__ */ new Set([
|
|
877
894
|
...current.conversation.labels,
|
|
878
|
-
...[topic, kind ? decision.kind : null].filter((label) => label !== null)
|
|
895
|
+
...[topic, kind?.label ? decision.kind : null].filter((label) => label !== null)
|
|
879
896
|
])
|
|
880
897
|
];
|
|
881
898
|
if (labels.length !== current.conversation.labels.length) await bot.setLabels(accountId2, conversationId, labels);
|
|
@@ -1083,7 +1100,12 @@ var configSchema = z5.strictObject({
|
|
|
1083
1100
|
* or `snoozed` until the customer's next message. A new message from the customer
|
|
1084
1101
|
* reopens either.
|
|
1085
1102
|
*/
|
|
1086
|
-
status: z5.enum(["resolved", "snoozed"]).optional()
|
|
1103
|
+
status: z5.enum(["resolved", "snoozed"]).optional(),
|
|
1104
|
+
/**
|
|
1105
|
+
* Whether the ticket gets the kind's label (the default). False for a kind that is not a kind of
|
|
1106
|
+
* ticket, such as a thank-you that is only resolved.
|
|
1107
|
+
*/
|
|
1108
|
+
label: z5.boolean().default(true)
|
|
1087
1109
|
})
|
|
1088
1110
|
)
|
|
1089
1111
|
).optional()
|
|
@@ -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). */
|
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;
|