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 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.5.2] - 2026-10-05
10
+ ## [0.7.0] - 2026-10-07
11
11
 
12
- ## [0.5.1] - 2026-10-05
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
- - Route on a customer's message as it arrives. Chatwoot's message webhook describes a contact without a `type`
17
- (`Contact#webhook_data`; users and bots have one), and the router required `type: "contact"`, so it ignored
18
- every customer message and a new ticket waited for the five-minute sweep.
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.5.2...HEAD
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. A resolved
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. Logs contain
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()
@@ -21,6 +21,7 @@ export declare const configSchema: z.ZodObject<{
21
21
  resolved: "resolved";
22
22
  snoozed: "snoozed";
23
23
  }>>;
24
+ label: z.ZodDefault<z.ZodBoolean>;
24
25
  }, z.core.$strict>>>>;
25
26
  }, z.core.$strict>;
26
27
  }, z.core.$strict>;
@@ -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 text2 = sanitize(
842
+ const text3 = sanitize(
830
843
  [message.content_attributes?.email?.subject ?? "", messageContent(message)].join("\n"),
831
844
  identities
832
845
  );
833
- return text2.replaceAll("[REDACTED]", "").trim() ? [{ id: message.id, text: text2 }] : [];
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: texts.map((entry) => entry.text).join(" ").slice(0, MAX_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
- if (!kind && decision.noRequest && input.count < MAX_MESSAGES && !current.conversation.assignee) return;
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). */
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatwoot-router",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "chatwoot",