chatwoot-router 0.7.0 → 0.9.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,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-10-18
11
+
12
+ ### Added
13
+
14
+ - An optional signed `routing.beforeHandoff` hook lets a dependent service write context to Chatwoot before the router
15
+ ends a pending bot turn. The hook is idempotent per conversation turn; failures leave the ticket pending for retry.
16
+
17
+ ## [0.8.0] - 2026-10-08
18
+
19
+ ### Changed
20
+
21
+ - Jev sees the contact's email domain on every channel (`Contact email domain: example.net`), not only an email's
22
+ sender domain: a widget visitor who gives an address at a reserved test domain, as scripted probes do, is told
23
+ from a person, so the spam kind can end the ticket. The address and name stay redacted.
24
+
10
25
  ## [0.7.0] - 2026-10-07
11
26
 
12
27
  ### Changed
@@ -148,7 +163,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
148
163
  `routing_kind`.
149
164
  - The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
150
165
 
151
- [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.6.0...HEAD
166
+ [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.9.0...HEAD
167
+ [0.9.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.8.0...chatwoot-router@0.9.0
168
+ [0.8.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.7.0...chatwoot-router@0.8.0
152
169
  [0.6.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.2...chatwoot-router@0.6.0
153
170
  [0.5.2]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.1...chatwoot-router@0.5.2
154
171
  [0.5.1]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.5.0...chatwoot-router@0.5.1
package/README.md CHANGED
@@ -12,14 +12,16 @@ 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; an
16
- email's sender domain leads them)
15
+ 2. The router reads the customer's first messages of this turn (redacted, at most three and 1,600 characters; the
16
+ contact's email domain leads them)
17
17
  and asks TypeSafe's System One API (`/v1/systemone`, model `routing.model`, Jev by default; `routing.endpoint`
18
18
  may point to another deployment) for the owner, topic, kind, and whether there is a request yet.
19
19
  3. It applies the topic and kind labels, sends the kind's canned reply if it has one (at most once per
20
- conversation), and ends the turn: the kind's resolved or snoozed status, the confident owner, or a handoff to
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
20
+ conversation), then optionally calls the configured before-handoff service. The service can write context to the
21
+ conversation before the turn ends: it returns success only after its write is complete. The router then ends the
22
+ turn: the kind's resolved or snoozed status, the confident owner, or a handoff to people (`status=open`). A greeting
23
+ with no request yet stays pending until the customer says more, for up to 30 minutes (an email that asks for nothing
24
+ will not say more); then it is routed on. A resolved
23
25
  ticket that a customer message reopens keeps its owner: its kind may still end it (a thanks is resolved),
24
26
  otherwise it goes back to the owner.
25
27
  4. Anything uncertain hands off to people: an unclear owner, a failed or unconfirmed reply, a human replying,
@@ -107,6 +109,7 @@ no unpublished shared package needs installing.
107
109
  },
108
110
  "topics": { "billing": "Invoices and payments.", "technical": "Product troubleshooting." },
109
111
  "minConfidence": 0.7,
112
+ "beforeHandoff": { "url": "https://service.example.com/router/before-handoff", "timeoutSeconds": 20 },
110
113
  "kinds": { "1": { "spam": { "covers": "Unsolicited advertising.", "status": "resolved" } } }
111
114
  }
112
115
  }
@@ -119,6 +122,9 @@ no unpublished shared package needs installing.
119
122
  | `routing.endpoint` | HTTPS URL | `https://api.typesafe.ai/v1/systemone` | TypeSafe's System One API endpoint; may point to another deployment of that API, for example a proxy or gateway. Redirects are refused. |
120
123
  | `routing.model` | non-empty string | `jev-1.13.0` | Model sent to TypeSafe's System One API; Jev by default. |
121
124
  | `routing.minConfidence` | number 0.5–1 | `0.7` | Probability an answer needs before it is applied. |
125
+ | `routing.beforeHandoff` | object | unset | Optional service call completed before the router ends a pending bot turn. |
126
+ | `routing.beforeHandoff.url` | HTTPS URL | required when configured | Endpoint that writes any context needed before handoff. |
127
+ | `routing.beforeHandoff.timeoutSeconds` | positive integer | `20` | Maximum time for the endpoint request, including its response body. |
122
128
  | `routing.botIds` | object: account id → positive safe integer | required | Brand bot id for every routed account, and no others. |
123
129
  | `routing.accounts` | object: account id → (owner name → owner) | required | Routed accounts and the owners Jev chooses from. Owner names are 1–40 lower-case letters, digits, or `_`; `unclear` is reserved. |
124
130
  | `routing.accounts.<id>.<name>.assignee` | integer > 0 | required | Chatwoot user id to assign. |
@@ -138,10 +144,59 @@ no unpublished shared package needs installing.
138
144
  | `CHATWOOT_AGENT_BOT_TOKENS` | Yes | JSON object of bot access tokens by account id, e.g. `{"1":"<token>"}`. All mutations use the bot token. |
139
145
  | `CHATWOOT_AGENT_BOT_SECRETS` | Yes | JSON object of bot webhook secrets by account id, e.g. `{"1":"<secret>"}`. |
140
146
  | `TYPESAFE_API_KEY` | Yes | TypeSafe API key for System One API requests. |
147
+ | `BEFORE_HANDOFF_SECRET` | When `routing.beforeHandoff.url` is set | At least 32 characters; signs requests to that endpoint. |
141
148
 
142
149
  Both bot-secret maps require nonempty values and exactly the `routing.accounts` keys. Missing/invalid entries
143
150
  fail startup; `/healthz` returns 503 without credentials in its response. All secrets use `bindings.secret()`.
144
151
 
152
+ ### Before-handoff hook
153
+
154
+ When `routing.beforeHandoff` is configured, the router calls its HTTPS endpoint once before each successful pending-turn
155
+ release. This covers a handoff to people (`status=open`), a kind's `resolved` or `snoozed` status, an owner assignment,
156
+ and the open mutation used when the inbox bot is disconnected. A person or another bot taking over a ticket, and the
157
+ unassignment of a bot from an already non-pending conversation, do not end a pending bot turn and do not call the hook.
158
+
159
+ The request is a `POST` with this JSON body. `turnId` is the status-activity boundary returned by the router's turn read;
160
+ it is `0` for the first turn, remains stable when that turn is retried, and changes when a conversation is reopened.
161
+ `channel` and `hmacVerified` are `null` when Chatwoot has no value; `hmacVerified` otherwise carries Chatwoot's
162
+ boolean contact-inbox verification result.
163
+
164
+ ```json
165
+ {
166
+ "accountId": 42,
167
+ "conversationId": 9001,
168
+ "turnId": 17,
169
+ "contact": { "email": "customer@example.com", "blocked": false },
170
+ "channel": "Channel::WebWidget",
171
+ "hmacVerified": true
172
+ }
173
+ ```
174
+
175
+ The exact UTF-8 body bytes are signed with HMAC-SHA256 using `BEFORE_HANDOFF_SECRET` and the Unix timestamp:
176
+ `HMAC-SHA256(secret, "<x-timestamp>.<raw body>")`. Headers are:
177
+
178
+ ```text
179
+ x-timestamp: 1760000000
180
+ x-signature: sha256=<64 lowercase hexadecimal characters>
181
+ content-type: application/json
182
+ ```
183
+
184
+ For example, a receiver can verify and handle a request with the same raw body before returning success:
185
+
186
+ ```sh
187
+ body='{"accountId":42,"conversationId":9001,"turnId":17,"contact":{"email":"customer@example.com","blocked":false},"channel":"Channel::WebWidget","hmacVerified":true}'
188
+ timestamp="$(date +%s)"
189
+ signature="$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$BEFORE_HANDOFF_SECRET" -hex | sed 's/^.* //')"
190
+ curl --fail-with-body -X POST "https://service.example.com/router/before-handoff" \
191
+ -H "content-type: application/json" -H "x-timestamp: $timestamp" -H "x-signature: sha256=$signature" \
192
+ --data "$body"
193
+ ```
194
+
195
+ The receiver must be idempotent for `(accountId, conversationId, turnId)` because retries can repeat a request after a
196
+ timeout or network failure. It should return a 2xx response only after all context it adds for that turn is written (or
197
+ when nothing is due). A non-2xx response, timeout, or network error is logged without credentials or request body and
198
+ is raised by the router; the pending ticket then remains pending for the existing Durable Object retry and sweep.
199
+
145
200
  ## Upgrade and rollback
146
201
 
147
202
  Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
@@ -151,7 +206,7 @@ the Router DO during rollback. If its state is lost or restored to an older reco
151
206
  and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
152
207
  prove another send is safe. Existing pending conversations are reconciled; non-pending conversations stay with people. The user token must see all relevant inboxes.
153
208
 
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
209
+ Redaction is best effort, not anonymization: other personal information can still reach TypeSafe, and the
210
+ contact's email domain is sent on purpose (never the address or name). Logs contain
156
211
  ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
157
212
  [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).
@@ -120,6 +120,11 @@ var configSchema = z2.strictObject({
120
120
  z2.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
121
121
  z2.string().min(1).max(1e3)
122
122
  ).optional(),
123
+ /** A signed service call that must complete before a pending bot turn is handed off or assigned. */
124
+ beforeHandoff: z2.strictObject({
125
+ url: z2.url({ protocol: /^https$/ }),
126
+ timeoutSeconds: z2.number().int().min(1).default(20)
127
+ }).optional(),
123
128
  /**
124
129
  * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
125
130
  * once, when it does (see src/routing.ts). Unset: none.
@@ -170,7 +175,8 @@ var secretsSchema = z2.object({
170
175
  CHATWOOT_TOKEN: z2.string().min(1),
171
176
  CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
172
177
  TYPESAFE_API_KEY: z2.string().min(1),
173
- CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
178
+ CHATWOOT_AGENT_BOT_TOKENS: jsonRecord,
179
+ BEFORE_HANDOFF_SECRET: z2.string().min(32).optional()
174
180
  });
175
181
 
176
182
  // scripts/stored-config.ts
@@ -72,6 +72,11 @@ var configSchema = z2.strictObject({
72
72
  z2.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
73
73
  z2.string().min(1).max(1e3)
74
74
  ).optional(),
75
+ /** A signed service call that must complete before a pending bot turn is handed off or assigned. */
76
+ beforeHandoff: z2.strictObject({
77
+ url: z2.url({ protocol: /^https$/ }),
78
+ timeoutSeconds: z2.number().int().min(1).default(20)
79
+ }).optional(),
75
80
  /**
76
81
  * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
77
82
  * once, when it does (see src/routing.ts). Unset: none.
@@ -122,7 +127,8 @@ var secretsSchema = z2.object({
122
127
  CHATWOOT_TOKEN: z2.string().min(1),
123
128
  CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
124
129
  TYPESAFE_API_KEY: z2.string().min(1),
125
- CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
130
+ CHATWOOT_AGENT_BOT_TOKENS: jsonRecord,
131
+ BEFORE_HANDOFF_SECRET: z2.string().min(32).optional()
126
132
  });
127
133
 
128
134
  // scripts/stored-config.ts
@@ -0,0 +1,19 @@
1
+ import type { Settings } from "./config.ts";
2
+ export interface BeforeHandoffConversation {
3
+ contact: {
4
+ email?: string | null;
5
+ blocked?: boolean;
6
+ };
7
+ channel?: string | null;
8
+ hmacVerified?: boolean | null;
9
+ }
10
+ export interface BeforeHandoffContext {
11
+ settings: Settings;
12
+ /** Counts toward the invocation's subrequest budget. */
13
+ fetch: (request: Request) => Promise<Response>;
14
+ }
15
+ /**
16
+ * Let a dependent service finish its per-turn write before the router releases the pending bot turn.
17
+ * An unset URL deliberately does nothing, preserving the router's existing behavior.
18
+ */
19
+ export declare function beforeHandoff(ctx: BeforeHandoffContext, accountId: number, conversationId: number, turnId: number, conversation: BeforeHandoffConversation): Promise<void>;
@@ -14,6 +14,10 @@ export declare const configSchema: z.ZodObject<{
14
14
  covers: z.ZodString;
15
15
  }, z.core.$strict>>>;
16
16
  topics: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
17
+ beforeHandoff: z.ZodOptional<z.ZodObject<{
18
+ url: z.ZodURL;
19
+ timeoutSeconds: z.ZodDefault<z.ZodNumber>;
20
+ }, z.core.$strict>>;
17
21
  kinds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
18
22
  covers: z.ZodString;
19
23
  cannedResponse: z.ZodOptional<z.ZodString>;
@@ -30,6 +34,7 @@ export declare const secretsSchema: z.ZodObject<{
30
34
  CHATWOOT_AGENT_BOT_SECRETS: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<unknown, string>>, z.ZodRecord<z.ZodString, z.ZodString>>;
31
35
  TYPESAFE_API_KEY: z.ZodString;
32
36
  CHATWOOT_AGENT_BOT_TOKENS: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<unknown, string>>, z.ZodRecord<z.ZodString, z.ZodString>>;
37
+ BEFORE_HANDOFF_SECRET: z.ZodOptional<z.ZodString>;
33
38
  }, z.core.$strip>;
34
39
  type Config = z.infer<typeof configSchema>;
35
40
  type Secrets = z.infer<typeof secretsSchema>;
@@ -6,4 +6,5 @@ export interface Env extends ConfigEnv {
6
6
  CHATWOOT_AGENT_BOT_SECRETS: string;
7
7
  TYPESAFE_API_KEY: string;
8
8
  CHATWOOT_AGENT_BOT_TOKENS: string;
9
+ BEFORE_HANDOFF_SECRET?: string;
9
10
  }
@@ -108,7 +108,8 @@ var Budget = class {
108
108
  this.timeoutMs
109
109
  );
110
110
  try {
111
- const response = await this.fetchImpl(new Request(request, { signal: controller.signal }));
111
+ const signal = AbortSignal.any([request.signal, controller.signal]);
112
+ const response = await this.fetchImpl(new Request(request, { signal }));
112
113
  if (!response.body) {
113
114
  clearTimeout(timer);
114
115
  return response;
@@ -162,7 +163,7 @@ var emailSchema = z2.object({
162
163
  subject: text,
163
164
  auto_reply: z2.boolean().nullish(),
164
165
  text_content: z2.object({ quoted: text }).nullish().catch(null),
165
- html_content: z2.object({ quoted: text }).nullish().catch(null)
166
+ html_content: z2.object({ quoted: text, full: text }).nullish().catch(null)
166
167
  });
167
168
  var messageSchema = z2.object({
168
169
  id: z2.number(),
@@ -457,6 +458,7 @@ function toRelayConversation(conversationId, conversation) {
457
458
  id: conversationId,
458
459
  status: conversation.status,
459
460
  channel: meta?.channel ?? null,
461
+ hmacVerified: meta?.hmac_verified ?? null,
460
462
  priority: conversation.priority ?? null,
461
463
  labels: conversation.labels ?? [],
462
464
  contact: {
@@ -474,9 +476,10 @@ function toRelayConversation(conversationId, conversation) {
474
476
  customAttributes: conversation.custom_attributes ?? {}
475
477
  };
476
478
  }
477
- function messageContent(message) {
479
+ function messageContent(message, dropCidPlaceholders = false) {
478
480
  const email = message.content_attributes?.email;
479
- return email?.text_content?.quoted ?? email?.html_content?.quoted ?? message.content ?? "";
481
+ const content = email?.text_content?.quoted ?? email?.html_content?.quoted ?? message.content ?? "";
482
+ return dropCidPlaceholders ? content.replace(/\[cid:[^\]\r\n]+\]/gi, "") : content;
480
483
  }
481
484
 
482
485
  // ../../shared/json.ts
@@ -601,6 +604,56 @@ var QueueStore = class {
601
604
  import ipRegex from "ip-regex";
602
605
  import { z as z4 } from "zod";
603
606
 
607
+ // src/before-handoff.ts
608
+ var encoder2 = new TextEncoder();
609
+ async function beforeHandoff(ctx, accountId2, conversationId, turnId, conversation) {
610
+ const config = ctx.settings.config.routing.beforeHandoff;
611
+ if (!config) return;
612
+ const secret = ctx.settings.secrets.BEFORE_HANDOFF_SECRET;
613
+ if (!secret) throw new Error("before-handoff secret is not configured");
614
+ const body = JSON.stringify({
615
+ accountId: accountId2,
616
+ conversationId,
617
+ turnId,
618
+ contact: { email: conversation.contact.email ?? null, blocked: conversation.contact.blocked ?? false },
619
+ channel: conversation.channel ?? null,
620
+ hmacVerified: conversation.hmacVerified ?? null
621
+ });
622
+ const timestamp = String(Math.floor(Date.now() / 1e3));
623
+ const key = await crypto.subtle.importKey("raw", encoder2.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
624
+ "sign"
625
+ ]);
626
+ const signed = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder2.encode(`${timestamp}.${body}`)));
627
+ const signature = Array.from(signed, (byte) => byte.toString(16).padStart(2, "0")).join("");
628
+ const controller = new AbortController();
629
+ const timer = setTimeout(
630
+ () => controller.abort(new DOMException("The request timed out", "TimeoutError")),
631
+ config.timeoutSeconds * 1e3
632
+ );
633
+ try {
634
+ const response = await ctx.fetch(
635
+ new Request(config.url, {
636
+ method: "POST",
637
+ redirect: "manual",
638
+ signal: controller.signal,
639
+ headers: {
640
+ "content-type": "application/json",
641
+ "x-timestamp": timestamp,
642
+ "x-signature": `sha256=${signature}`
643
+ },
644
+ body
645
+ })
646
+ );
647
+ await response.arrayBuffer();
648
+ if (!response.ok) throw new Error(`before-handoff HTTP ${response.status}`);
649
+ } catch (error) {
650
+ log.warn("before-handoff failed", { accountId: accountId2, conversationId, turnId, ...errorFields(error) });
651
+ throw error;
652
+ } finally {
653
+ clearTimeout(timer);
654
+ }
655
+ }
656
+
604
657
  // src/reply.ts
605
658
  function confirmedReply(message, botId) {
606
659
  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;
@@ -824,8 +877,12 @@ async function routeConversation(ctx, accountId2, conversationId) {
824
877
  if ((await chatwoot.inboxBot(accountId2, raw.inbox_id))?.id !== botId) {
825
878
  if (owns(raw)) {
826
879
  const latest = await chatwoot.getConversation(accountId2, conversationId);
827
- if (latest?.status === "pending" && latest.inbox_id === raw.inbox_id && owns(latest))
880
+ if (latest?.status === "pending" && latest.inbox_id === raw.inbox_id && owns(latest)) {
881
+ const conversation2 = toRelayConversation(conversationId, latest);
882
+ const turnId = routing.beforeHandoff ? (await readTurn(chatwoot, store, accountId2, conversationId)).boundary : 0;
883
+ await beforeHandoff(ctx, accountId2, conversationId, turnId, conversation2);
828
884
  await bot.setStatus(accountId2, conversationId, { status: "open" });
885
+ }
829
886
  }
830
887
  return;
831
888
  }
@@ -836,7 +893,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
836
893
  const initial = await snapshot();
837
894
  if (!initial) return;
838
895
  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;
896
+ const contactDomain = initial.conversation.contact.email?.split("@")[1];
840
897
  const inputs = (messages) => {
841
898
  const texts = messages.filter(isCustomer).flatMap((message) => {
842
899
  const text3 = sanitize(
@@ -848,7 +905,7 @@ async function routeConversation(ctx, accountId2, conversationId) {
848
905
  const text2 = texts.map((entry) => entry.text).join(" ");
849
906
  return {
850
907
  key: texts.map((entry) => entry.id).join(","),
851
- text: (senderDomain ? `Email sender domain: ${senderDomain}
908
+ text: (contactDomain ? `Contact email domain: ${contactDomain}
852
909
  ${text2}` : text2).slice(0, MAX_TEXT),
853
910
  count: texts.length
854
911
  };
@@ -869,7 +926,10 @@ ${text2}` : text2).slice(0, MAX_TEXT),
869
926
  requestHandoff(store, accountId2, conversationId);
870
927
  current2 ??= await fresh();
871
928
  if (current2 === "defer") return "defer";
872
- if (current2) await bot.setStatus(accountId2, conversationId, { status: "open" });
929
+ if (current2) {
930
+ await beforeHandoff(ctx, accountId2, conversationId, turn.boundary, current2.conversation);
931
+ await bot.setStatus(accountId2, conversationId, { status: "open" });
932
+ }
873
933
  return void 0;
874
934
  };
875
935
  if (turn.handoff || requiresHandoff(turn.messages) || input.count === 0) return handoff();
@@ -931,10 +991,12 @@ ${text2}` : text2).slice(0, MAX_TEXT),
931
991
  if (current === "defer" || !current) return current;
932
992
  if (current.handoff) return handoff(current);
933
993
  if (kind?.status) {
994
+ await beforeHandoff(ctx, accountId2, conversationId, turn.boundary, current.conversation);
934
995
  await bot.setStatus(accountId2, conversationId, { status: kind.status });
935
996
  expectActivity(store, accountId2, conversationId, { status: kind.status });
936
997
  await release();
937
998
  } else if (assignee !== void 0 && current.raw.meta?.assignee_type === "AgentBot") {
999
+ await beforeHandoff(ctx, accountId2, conversationId, turn.boundary, current.conversation);
938
1000
  await bot.assign(accountId2, conversationId, assignee);
939
1001
  expectActivity(store, accountId2, conversationId, { status: "open" });
940
1002
  } else {
@@ -1079,6 +1141,11 @@ var configSchema = z5.strictObject({
1079
1141
  z5.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
1080
1142
  z5.string().min(1).max(1e3)
1081
1143
  ).optional(),
1144
+ /** A signed service call that must complete before a pending bot turn is handed off or assigned. */
1145
+ beforeHandoff: z5.strictObject({
1146
+ url: z5.url({ protocol: /^https$/ }),
1147
+ timeoutSeconds: z5.number().int().min(1).default(20)
1148
+ }).optional(),
1082
1149
  /**
1083
1150
  * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
1084
1151
  * once, when it does (see src/routing.ts). Unset: none.
@@ -1129,7 +1196,8 @@ var secretsSchema = z5.object({
1129
1196
  CHATWOOT_TOKEN: z5.string().min(1),
1130
1197
  CHATWOOT_AGENT_BOT_SECRETS: jsonRecord,
1131
1198
  TYPESAFE_API_KEY: z5.string().min(1),
1132
- CHATWOOT_AGENT_BOT_TOKENS: jsonRecord
1199
+ CHATWOOT_AGENT_BOT_TOKENS: jsonRecord,
1200
+ BEFORE_HANDOFF_SECRET: z5.string().min(32).optional()
1133
1201
  });
1134
1202
  function parseSettings(rawConfig, rawSecrets) {
1135
1203
  const config = configSchema.safeParse(rawConfig);
@@ -1145,6 +1213,9 @@ function buildSettings(config, secrets) {
1145
1213
  throw new ConfigError(`Invalid secrets: ${name}: needs exactly the routed accounts`);
1146
1214
  }
1147
1215
  }
1216
+ if (config.routing.beforeHandoff && !secrets.BEFORE_HANDOFF_SECRET) {
1217
+ throw new ConfigError("Invalid secrets: BEFORE_HANDOFF_SECRET: required when routing.beforeHandoff.url is set");
1218
+ }
1148
1219
  return { config, secrets };
1149
1220
  }
1150
1221
 
@@ -27,6 +27,7 @@ export declare function readTurn(chatwoot: ChatwootClient, store: RoutingStore,
27
27
  } | null | undefined;
28
28
  html_content?: {
29
29
  quoted?: string | null | undefined;
30
+ full?: string | null | undefined;
30
31
  } | null | undefined;
31
32
  } | null | undefined;
32
33
  deleted?: boolean | null | undefined;
@@ -38,6 +38,7 @@ declare const messageSchema: z.ZodObject<{
38
38
  }, z.core.$strip>>>>;
39
39
  html_content: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
40
40
  quoted: z.ZodOptional<z.ZodNullable<z.ZodString>>;
41
+ full: z.ZodOptional<z.ZodNullable<z.ZodString>>;
41
42
  }, z.core.$strip>>>>;
42
43
  }, z.core.$strip>>>;
43
44
  deleted: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
@@ -240,6 +241,7 @@ export declare function toRelayMessage(message: ChatwootMessage, context: {
240
241
  id: number;
241
242
  name: string;
242
243
  };
244
+ chatwootBaseUrl?: string;
243
245
  inboxName?: string | null;
244
246
  conversation: RelayConversation;
245
247
  mentionedAgents?: ReadonlyMap<number, string>;
@@ -249,5 +251,5 @@ export declare function toRelayMessage(message: ChatwootMessage, context: {
249
251
  * What Chatwoot itself shows and forwards (Message#ensure_processed_message_content, used by its
250
252
  * Slack integration at v4.18.0): for an email, the reply without its quoted history.
251
253
  */
252
- export declare function messageContent(message: ChatwootMessage): string;
254
+ export declare function messageContent(message: ChatwootMessage, dropCidPlaceholders?: boolean): string;
253
255
  export {};
@@ -39,6 +39,8 @@ export interface RelayConversation {
39
39
  /** open, pending, snoozed, or resolved. */
40
40
  status?: string | undefined;
41
41
  channel?: string | null;
42
+ /** Whether Chatwoot verified the contact's HMAC for this inbox. */
43
+ hmacVerified?: boolean | null;
42
44
  /** urgent, high, medium, low, or null. */
43
45
  priority?: string | null;
44
46
  labels: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatwoot-router",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "chatwoot",
@@ -58,10 +58,10 @@
58
58
  "prepack": "npm run build:package"
59
59
  },
60
60
  "devDependencies": {
61
- "@cloudflare/vite-plugin": "2.0.0-beta.sha-805ec1ff3"
61
+ "@cloudflare/vite-plugin": "2.0.0-beta.sha-91c870c02"
62
62
  },
63
63
  "dependencies": {
64
- "hono": "^4.13.10",
64
+ "hono": "^4.13.13",
65
65
  "ip-regex": "^5.0.0",
66
66
  "jsonc-parser": "^3.3.1",
67
67
  "openapi-fetch": "^0.17.0",