chatwoot-router 0.1.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.
@@ -0,0 +1,1201 @@
1
+ // src/index.ts
2
+ import { Hono } from "hono";
3
+ import { bodyLimit } from "hono/body-limit";
4
+
5
+ // ../../shared/chatwoot/signature.ts
6
+ var TIMESTAMP_TOLERANCE_SECONDS = 300;
7
+ var encoder = new TextEncoder();
8
+ function isFreshTimestamp(timestamp, nowSeconds) {
9
+ if (!timestamp || !/^\d{1,12}$/.test(timestamp)) return false;
10
+ return Math.abs(nowSeconds - Number(timestamp)) <= TIMESTAMP_TOLERANCE_SECONDS;
11
+ }
12
+ async function verifyChatwootSignature(secret, timestamp, body, signatureHeader) {
13
+ const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader ?? "");
14
+ const hex = match?.[1];
15
+ if (!hex) return false;
16
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
17
+ "verify"
18
+ ]);
19
+ const prefix = encoder.encode(`${timestamp}.`);
20
+ const signed = new Uint8Array(prefix.length + body.length);
21
+ signed.set(prefix);
22
+ signed.set(body, prefix.length);
23
+ return crypto.subtle.verify("HMAC", key, Uint8Array.fromHex(hex), signed);
24
+ }
25
+
26
+ // ../../shared/config.ts
27
+ import { z } from "zod";
28
+ var jsonRecord = z.string().transform((value, ctx) => {
29
+ try {
30
+ return JSON.parse(value);
31
+ } catch {
32
+ ctx.addIssue({ code: "custom", message: "must be a JSON object" });
33
+ return z.NEVER;
34
+ }
35
+ }).pipe(z.record(z.string(), z.string().min(1)));
36
+ var ConfigError = class extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "ConfigError";
40
+ }
41
+ };
42
+ function describe(error) {
43
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
44
+ }
45
+ var reconcileSchema = z.strictObject({
46
+ /** The sweep looks at conversations with activity within at least this window. */
47
+ lookbackSeconds: z.number().int().min(60).default(3600),
48
+ /** After downtime, the sweep catches up at most this far back. */
49
+ maxCatchUpSeconds: z.number().int().min(60).default(7 * 24 * 3600)
50
+ }).prefault({});
51
+
52
+ // ../../shared/log.ts
53
+ function write(level, message, fields = {}) {
54
+ const line = JSON.stringify({ level, message, ...fields });
55
+ if (level === "error") console.error(line);
56
+ else if (level === "warn") console.warn(line);
57
+ else console.log(line);
58
+ }
59
+ var log = {
60
+ info: (message, fields) => write("info", message, fields),
61
+ warn: (message, fields) => write("warn", message, fields),
62
+ error: (message, fields) => write("error", message, fields)
63
+ };
64
+ function errorFields(error) {
65
+ return error instanceof Error ? { error: error.name, detail: error.message } : { error: String(error) };
66
+ }
67
+
68
+ // src/router.ts
69
+ import { DurableObject } from "cloudflare:workers";
70
+ import { z as z7 } from "zod";
71
+
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
+ fetch = (request) => {
94
+ if (this.used >= this.limit) return Promise.reject(new BudgetExhaustedError());
95
+ this.used += 1;
96
+ return this.fetchImpl(new Request(request, { signal: AbortSignal.timeout(this.timeoutMs) }));
97
+ };
98
+ };
99
+
100
+ // ../../shared/chatwoot/api.ts
101
+ import createClient from "openapi-fetch";
102
+ import { z as z2 } from "zod";
103
+ var ChatwootError = class extends Error {
104
+ status;
105
+ conversationMissing;
106
+ constructor(status, operation, conversationMissing = false) {
107
+ super(`Chatwoot ${operation} failed with HTTP ${status}`);
108
+ this.name = "ChatwootError";
109
+ this.status = status;
110
+ this.conversationMissing = conversationMissing;
111
+ }
112
+ };
113
+ var text = z2.string().nullish();
114
+ var attachmentSchema = z2.object({
115
+ file_type: text,
116
+ data_url: text,
117
+ fallback_title: text,
118
+ coordinates_lat: z2.number().nullish(),
119
+ coordinates_long: z2.number().nullish(),
120
+ meta: z2.object({ firstName: text, lastName: text, first_name: text, last_name: text }).nullish().catch(null)
121
+ });
122
+ var itemText = text.catch(null);
123
+ var itemSchema = z2.object({
124
+ title: itemText,
125
+ value: itemText,
126
+ description: itemText,
127
+ media_url: itemText,
128
+ link: itemText,
129
+ actions: z2.array(z2.object({ text: itemText, uri: itemText })).nullish().catch(null)
130
+ });
131
+ var emailSchema = z2.object({
132
+ subject: text,
133
+ auto_reply: z2.boolean().nullish(),
134
+ text_content: z2.object({ quoted: text }).nullish().catch(null),
135
+ html_content: z2.object({ quoted: text }).nullish().catch(null)
136
+ });
137
+ var messageSchema = z2.object({
138
+ id: z2.number(),
139
+ content: text,
140
+ message_type: z2.number(),
141
+ content_type: text,
142
+ /** sent, delivered, read, or failed. */
143
+ status: text,
144
+ /** Unix seconds. */
145
+ created_at: z2.number().nullish(),
146
+ private: z2.boolean().nullish(),
147
+ content_attributes: z2.object({
148
+ email: emailSchema.nullish(),
149
+ deleted: z2.boolean().nullish(),
150
+ external_error: text,
151
+ submitted_values: z2.unknown().optional(),
152
+ submitted_email: z2.unknown().optional(),
153
+ items: z2.unknown().optional()
154
+ }).nullish().catch(null),
155
+ // A user's or contact's picture is `thumbnail`; an agent bot's is `avatar_url` ("" without one).
156
+ sender: z2.object({ id: z2.number().nullish(), name: text, email: text, type: text, thumbnail: text, avatar_url: text }).nullish(),
157
+ attachments: z2.array(attachmentSchema).nullish()
158
+ });
159
+ var messageListSchema = z2.object({ payload: z2.array(messageSchema) });
160
+ var MESSAGE_PAGE_SIZE = 100;
161
+ function chatwootClient(baseUrl, token, fetch2) {
162
+ const client = createClient({
163
+ baseUrl: baseUrl.replace(/\/+$/, ""),
164
+ headers: { api_access_token: token },
165
+ // A redirect is an error, never followed: it could carry the token to another host, and each
166
+ // hop would be a subrequest the budget does not count. Configure Chatwoot's final URL.
167
+ fetch: (request) => fetch2(new Request(request, { redirect: "manual" }))
168
+ });
169
+ async function data(operation, pending) {
170
+ const { data: data2, response } = await pending;
171
+ if (!response.ok || data2 === void 0) throw new ChatwootError(response.status, operation);
172
+ return data2;
173
+ }
174
+ async function dataUnlessNotFound(operation, pending) {
175
+ const { data: data2, response } = await pending;
176
+ if (notFound(response)) {
177
+ log.info("conversation deleted", { operation });
178
+ return void 0;
179
+ }
180
+ if (!response.ok || data2 === void 0) throw new ChatwootError(response.status, operation);
181
+ return data2;
182
+ }
183
+ async function ensureOk(operation, pending, conversation = true) {
184
+ const { response } = await pending;
185
+ if (!response.ok) throw new ChatwootError(response.status, operation, conversation && notFound(response));
186
+ }
187
+ async function listMessages(accountId2, conversationId, query) {
188
+ const list = await dataUnlessNotFound(
189
+ "list messages",
190
+ client.GET("/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages", {
191
+ params: { path: { account_id: accountId2, conversation_id: conversationId }, query }
192
+ })
193
+ );
194
+ return list && messageListSchema.parse(list).payload.toSorted((a, b) => a.id - b.id);
195
+ }
196
+ function setCustomAttributes(accountId2, conversationId, attributes) {
197
+ return ensureOk(
198
+ "set custom attribute",
199
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/custom_attributes", {
200
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
201
+ body: { custom_attributes: attributes, merge: true }
202
+ })
203
+ );
204
+ }
205
+ return {
206
+ /** The conversation, or undefined when it does not exist (it was deleted). */
207
+ getConversation(accountId2, conversationId) {
208
+ return dataUnlessNotFound(
209
+ "get conversation",
210
+ client.GET("/api/v1/accounts/{account_id}/conversations/{conversation_id}", {
211
+ params: { path: { account_id: accountId2, conversation_id: conversationId } }
212
+ })
213
+ );
214
+ },
215
+ /** Messages with an id above `after`, oldest first; without `after`, the latest page. */
216
+ async listMessages(accountId2, conversationId, query = {}) {
217
+ const messages = await listMessages(accountId2, conversationId, query);
218
+ if (!messages) throw new ChatwootError(404, "list messages", true);
219
+ return messages;
220
+ },
221
+ /**
222
+ * One message, or undefined if it or its conversation does not exist. Asks for the ids between
223
+ * `messageId - 1` and `messageId + 1`, which contains it whether `after` is read inclusively
224
+ * (MessageFinder#messages_between at v4.18.0) or exclusively (as the spec describes it).
225
+ */
226
+ async getMessage(accountId2, conversationId, messageId) {
227
+ const messages = await listMessages(accountId2, conversationId, { after: messageId - 1, before: messageId + 1 });
228
+ return messages?.find((message) => message.id === messageId);
229
+ },
230
+ async inboxName(accountId2, inboxId) {
231
+ const inbox = await data(
232
+ "get inbox",
233
+ client.GET("/api/v1/accounts/{account_id}/inboxes/{id}", {
234
+ params: { path: { account_id: accountId2, id: inboxId } }
235
+ })
236
+ );
237
+ return inbox.name;
238
+ },
239
+ /**
240
+ * One page (CONVERSATIONS_PER_PAGE) of conversations of any assignee and the given status
241
+ * (default: any), most recent activity first (Conversations::SortService's default,
242
+ * `last_activity_at_desc`, at v4.18.0).
243
+ */
244
+ async listConversations(accountId2, page, status = "all") {
245
+ const list = await data(
246
+ "list conversations",
247
+ client.GET("/api/v1/accounts/{account_id}/conversations", {
248
+ params: { path: { account_id: accountId2 }, query: { status, assignee_type: "all", page } }
249
+ })
250
+ );
251
+ return list.data?.payload ?? [];
252
+ },
253
+ /** The content of the account's canned response with this short code, if it exists. */
254
+ async cannedResponse(accountId2, shortCode) {
255
+ const responses = await data(
256
+ "list canned responses",
257
+ client.GET("/api/v1/accounts/{account_id}/canned_responses", { params: { path: { account_id: accountId2 } } })
258
+ );
259
+ return responses.find((response) => response.short_code === shortCode)?.content;
260
+ },
261
+ getProfile() {
262
+ return data("get profile", client.GET("/api/v1/profile"));
263
+ },
264
+ listAgents(accountId2) {
265
+ return data(
266
+ "list agents",
267
+ client.GET("/api/v1/accounts/{account_id}/agents", { params: { path: { account_id: accountId2 } } })
268
+ );
269
+ },
270
+ setStatus(accountId2, conversationId, change) {
271
+ return ensureOk(
272
+ "toggle status",
273
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status", {
274
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
275
+ body: change
276
+ })
277
+ );
278
+ },
279
+ setPriority(accountId2, conversationId, priority) {
280
+ return ensureOk(
281
+ "toggle priority",
282
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_priority", {
283
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
284
+ body: { priority }
285
+ })
286
+ );
287
+ },
288
+ setContactBlocked(accountId2, contactId, blocked) {
289
+ return ensureOk(
290
+ blocked ? "block contact" : "unblock contact",
291
+ client.PUT("/api/v1/accounts/{account_id}/contacts/{id}", {
292
+ params: { path: { account_id: accountId2, id: contactId } },
293
+ body: { blocked }
294
+ }),
295
+ false
296
+ );
297
+ },
298
+ assign(accountId2, conversationId, assigneeId) {
299
+ return ensureOk(
300
+ "assign conversation",
301
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments", {
302
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
303
+ body: { assignee_id: assigneeId }
304
+ })
305
+ );
306
+ },
307
+ /**
308
+ * Removes the conversation's assignee the way Chatwoot's dashboard does (ConversationAction.vue
309
+ * posts `assignee_id: null`, which AssignmentsController#create applies, at v4.18.0). The
310
+ * spec types `assignee_id` as a number only, so this body is serialized here.
311
+ */
312
+ unassign(accountId2, conversationId) {
313
+ return ensureOk(
314
+ "unassign conversation",
315
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/assignments", {
316
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
317
+ body: {},
318
+ bodySerializer: () => JSON.stringify({ assignee_id: null })
319
+ })
320
+ );
321
+ },
322
+ /** The account's label names (Chatwoot saves them in lower case). */
323
+ async listLabels(accountId2) {
324
+ const list = await data(
325
+ "list labels",
326
+ client.GET("/api/v1/accounts/{account_id}/labels", { params: { path: { account_id: accountId2 } } })
327
+ );
328
+ return (list.payload ?? []).flatMap((label) => label.title ? [label.title] : []);
329
+ },
330
+ async conversationLabels(accountId2, conversationId) {
331
+ const list = await data(
332
+ "list conversation labels",
333
+ client.GET("/api/v1/accounts/{account_id}/conversations/{conversation_id}/labels", {
334
+ params: { path: { account_id: accountId2, conversation_id: conversationId } }
335
+ })
336
+ );
337
+ return list.payload ?? [];
338
+ },
339
+ /** Replaces the conversation's labels (the spec's "Add Labels" overwrites the list). */
340
+ setLabels(accountId2, conversationId, labels) {
341
+ return ensureOk(
342
+ "set labels",
343
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/labels", {
344
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
345
+ body: { labels }
346
+ })
347
+ );
348
+ },
349
+ setCustomAttributes,
350
+ /**
351
+ * Sends an outgoing message (or private note). With files it is sent as the spec's
352
+ * multipart/form-data body; the generated type renders its `attachments[]` binaries as
353
+ * strings, so the serializer appends the files itself.
354
+ */
355
+ createMessage(accountId2, conversationId, message) {
356
+ const fields = {
357
+ content: message.content,
358
+ message_type: "outgoing",
359
+ private: message.private,
360
+ ...message.sendAsAgent ? { content_attributes: { send_as_agent: true } } : {}
361
+ };
362
+ const multipart = (body) => {
363
+ const form = new FormData();
364
+ if (body.content) form.append("content", body.content);
365
+ if (body.message_type) form.append("message_type", body.message_type);
366
+ form.append("private", String(body.private ?? false));
367
+ if (body.content_attributes) form.append("content_attributes", JSON.stringify(body.content_attributes));
368
+ for (const file of message.files) form.append("attachments[]", file.blob, file.filename);
369
+ return form;
370
+ };
371
+ return ensureOk(
372
+ "create message",
373
+ client.POST("/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages", {
374
+ params: { path: { account_id: accountId2, conversation_id: conversationId } },
375
+ body: fields,
376
+ ...message.files.length === 0 ? {} : { bodySerializer: () => multipart(fields) }
377
+ })
378
+ );
379
+ }
380
+ };
381
+ }
382
+ function notFound(response) {
383
+ return response.status === 404 && (response.headers.get("content-type")?.includes("application/json") ?? false);
384
+ }
385
+ function toRelayConversation(conversationId, conversation) {
386
+ const meta = conversation.meta;
387
+ const assignee = meta?.assignee;
388
+ return {
389
+ id: conversationId,
390
+ status: conversation.status,
391
+ channel: meta?.channel ?? null,
392
+ priority: conversation.priority ?? null,
393
+ labels: conversation.labels ?? [],
394
+ contact: {
395
+ name: meta?.sender?.name ?? null,
396
+ email: meta?.sender?.email ?? null,
397
+ phone: meta?.sender?.phone_number ?? null,
398
+ blocked: meta?.sender?.blocked ?? false,
399
+ avatarUrl: meta?.sender?.thumbnail ?? null
400
+ },
401
+ assignee: assignee ? { id: assignee.id, name: assignee.name } : null,
402
+ customAttributes: conversation.custom_attributes ?? {}
403
+ };
404
+ }
405
+ function messageContent(message) {
406
+ const email = message.content_attributes?.email;
407
+ return email?.text_content?.quoted ?? email?.html_content?.quoted ?? message.content ?? "";
408
+ }
409
+
410
+ // ../../shared/json.ts
411
+ function parseJson(text2) {
412
+ if (text2 === void 0) return void 0;
413
+ try {
414
+ return JSON.parse(text2);
415
+ } catch {
416
+ return void 0;
417
+ }
418
+ }
419
+
420
+ // ../../shared/store.ts
421
+ function retryDelay(attempts) {
422
+ return Math.min(5e3 * 2 ** attempts, 30 * 60 * 1e3);
423
+ }
424
+ var QueueStore = class {
425
+ constructor(sql, now = Date.now) {
426
+ this.sql = sql;
427
+ this.now = now;
428
+ }
429
+ sql;
430
+ now;
431
+ migrate() {
432
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS jobs (
433
+ key TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL,
434
+ version INTEGER NOT NULL DEFAULT 1, attempts INTEGER NOT NULL DEFAULT 0,
435
+ not_before INTEGER NOT NULL, created_at INTEGER NOT NULL
436
+ );
437
+ CREATE INDEX IF NOT EXISTS jobs_due ON jobs (not_before);
438
+ CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);`);
439
+ }
440
+ // Cache
441
+ get(key) {
442
+ const row = this.sql.exec("SELECT value, expires_at FROM cache WHERE key = ?", key).toArray()[0];
443
+ if (!row || row.expires_at !== null && row.expires_at <= this.now()) return void 0;
444
+ return row.value;
445
+ }
446
+ set(key, value, ttlMs) {
447
+ this.sql.exec(
448
+ "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
449
+ key,
450
+ value,
451
+ ttlMs === void 0 ? null : this.now() + ttlMs
452
+ );
453
+ }
454
+ delete(key) {
455
+ this.sql.exec("DELETE FROM cache WHERE key = ?", key);
456
+ }
457
+ list(start, end) {
458
+ return this.sql.exec(
459
+ "SELECT value FROM cache WHERE key >= ? AND key < ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key",
460
+ start,
461
+ end,
462
+ this.now()
463
+ ).toArray().map((row) => row.value);
464
+ }
465
+ // Jobs
466
+ /**
467
+ * Adds a job, or marks an existing one as needing another run (its version changes). A job
468
+ * that is backing off after failures keeps its schedule.
469
+ */
470
+ enqueue(key, priority, payload, notBefore = this.now()) {
471
+ this.sql.exec(
472
+ `INSERT INTO jobs (key, priority, payload, not_before, created_at) VALUES (?, ?, ?, ?, ?)
473
+ ON CONFLICT (key) DO UPDATE SET version = version + 1, payload = excluded.payload,
474
+ not_before = CASE WHEN attempts > 0 THEN not_before ELSE MIN(not_before, excluded.not_before) END`,
475
+ key,
476
+ priority,
477
+ payload,
478
+ notBefore,
479
+ this.now()
480
+ );
481
+ }
482
+ nextDueJob() {
483
+ const row = this.sql.exec(
484
+ `SELECT key, payload, version, attempts, created_at FROM jobs WHERE not_before <= ?
485
+ ORDER BY priority, not_before, created_at LIMIT 1`,
486
+ this.now()
487
+ ).toArray()[0];
488
+ if (!row) return void 0;
489
+ const { created_at: createdAt, ...job } = row;
490
+ return { ...job, createdAt };
491
+ }
492
+ /** Earliest due time. */
493
+ nextWakeup() {
494
+ const row = this.sql.exec("SELECT MIN(not_before) AS at FROM jobs").toArray()[0];
495
+ return row?.at ?? void 0;
496
+ }
497
+ /** Removes a finished job unless it was enqueued again while running. */
498
+ completeJob(job) {
499
+ const removed = this.sql.exec("DELETE FROM jobs WHERE key = ? AND version = ?", job.key, job.version).rowsWritten;
500
+ if (removed === 0) this.sql.exec("UPDATE jobs SET attempts = 0, not_before = ? WHERE key = ?", this.now(), job.key);
501
+ }
502
+ deleteJob(key) {
503
+ this.sql.exec("DELETE FROM jobs WHERE key = ?", key);
504
+ }
505
+ retryJob(job, delayMs) {
506
+ this.sql.exec(
507
+ "UPDATE jobs SET attempts = attempts + 1, not_before = ? WHERE key = ?",
508
+ this.now() + delayMs,
509
+ job.key
510
+ );
511
+ }
512
+ /**
513
+ * Makes a job due after `delayMs` without counting an attempt: when an invocation runs out of
514
+ * budget, or Discord rate limits it.
515
+ */
516
+ deferJob(job, delayMs = 0) {
517
+ this.sql.exec("UPDATE jobs SET not_before = ? WHERE key = ?", this.now() + delayMs, job.key);
518
+ }
519
+ prune() {
520
+ this.sql.exec("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at <= ?", this.now());
521
+ }
522
+ };
523
+
524
+ // ../../shared/sweep.ts
525
+ import { z as z3 } from "zod";
526
+ var passSchema = z3.object({ cutoff: z3.number(), page: z3.number().int().positive(), startedAt: z3.number() });
527
+ var PASS_TTL_MS = 24 * 60 * 60 * 1e3;
528
+ function readSweepPass(store, accountId2, reconcile) {
529
+ const parsed = passSchema.safeParse(parseJson(store.get(`sweep:${accountId2}:pass`)));
530
+ if (parsed.success) return parsed.data;
531
+ const now = Date.now();
532
+ const last = Number(store.get(`sweep:${accountId2}:last`) ?? 0);
533
+ const { lookbackSeconds, maxCatchUpSeconds } = reconcile;
534
+ const sinceLast = last > 0 ? (now - last) / 1e3 + 60 : lookbackSeconds;
535
+ const window = Math.min(Math.max(sinceLast, lookbackSeconds), maxCatchUpSeconds);
536
+ return { cutoff: now / 1e3 - window, page: 1, startedAt: now };
537
+ }
538
+ function saveSweepPass(store, accountId2, pass, nextPage) {
539
+ const key = `sweep:${accountId2}:pass`;
540
+ if (nextPage === void 0) {
541
+ store.set(`sweep:${accountId2}:last`, String(pass.startedAt));
542
+ store.delete(key);
543
+ } else {
544
+ store.set(key, JSON.stringify({ ...pass, page: nextPage }), PASS_TTL_MS);
545
+ }
546
+ }
547
+
548
+ // src/routing.ts
549
+ import ipRegex from "ip-regex";
550
+ import { z as z5 } from "zod";
551
+
552
+ // ../../shared/attributes.ts
553
+ var ROUTING_ATTRIBUTES = {
554
+ seen: "routing_seen",
555
+ handled: "routing_handled",
556
+ kind: "routing_kind"
557
+ };
558
+ function messageWatermark(value) {
559
+ const number = typeof value === "string" ? Number(value) : value;
560
+ return typeof number === "number" && Number.isSafeInteger(number) && number >= 0 ? number : 0;
561
+ }
562
+
563
+ // src/effects.ts
564
+ import { z as z4 } from "zod";
565
+ var effectSchema = z4.object({
566
+ inputId: z4.number().int().min(0),
567
+ kind: z4.string().nullable(),
568
+ handled: z4.number().int().min(0)
569
+ });
570
+ async function actOnce(ctx, key, effect, record, act) {
571
+ if (ctx.store.get(key) !== void 0) return;
572
+ if (record === "before") ctx.store.set(key, JSON.stringify({ ...effect, handled: 0 }));
573
+ await act();
574
+ ctx.store.set(key, JSON.stringify(effect));
575
+ }
576
+ function readEffects(store, accountId2, conversationId) {
577
+ return [
578
+ store.get(`reply:${accountId2}:${conversationId}`),
579
+ ...["assign", "labels", "status"].flatMap(
580
+ (effect) => store.list(`${effect}:${accountId2}:${conversationId}:`, `${effect}:${accountId2}:${conversationId};`)
581
+ )
582
+ ].flatMap((value) => {
583
+ const parsed = effectSchema.safeParse(parseJson(value));
584
+ return parsed.success ? [parsed.data] : [];
585
+ }).sort((left, right) => left.inputId - right.inputId);
586
+ }
587
+
588
+ // src/routing.ts
589
+ var UNCLEAR = "unclear";
590
+ var UNCLEAR_CRITERION = "The message has no concrete request, mixes several of the other areas, concerns another product, or cannot be assigned to exactly one of them.";
591
+ var REQUEST_CRITERIA = {
592
+ request: "The customer asks for support, information, or an action: a question about a product or service, a problem, or something to do, however briefly.",
593
+ none: 'No request yet: a greeting or a check that someone is there (such as "hi" or "hello, anyone there?"), a test, a name or contact details alone, or a few words that ask for nothing.'
594
+ };
595
+ var NO_KIND = "none";
596
+ var NO_KIND_CRITERION = "None of the other kinds.";
597
+ var JEV_URL = "https://api.typesafe.ai/v1/systemone";
598
+ var MAX_MESSAGES = 3;
599
+ var MAX_TEXT = 1600;
600
+ var REDACTIONS = [
601
+ /(?<![\w.+-])[\w.+-]+@[\w.-]+\.[a-z]{2,}(?!\w)/gi,
602
+ // email
603
+ /\b(?:https?|ftp):\/\/[^\s<>()]+|\bwww\.[^\s<>()]+/gi,
604
+ // URL
605
+ /\b(?:0x)?[0-9a-f]{20,}\b/gi,
606
+ // hex ids, EVM addresses, hashes
607
+ /(?<![1-9A-HJ-NP-Za-km-z])[1-9A-HJ-NP-Za-km-z]{32,64}(?![1-9A-HJ-NP-Za-km-z])/g,
608
+ // base58 addresses
609
+ /(?<![A-Za-z0-9_+/=-])[A-Za-z0-9_+/=-]{40,}(?![A-Za-z0-9_+/=-])/g,
610
+ // keys and tokens
611
+ /(?<!\w)\+?\d[\d ()-]{8,}\d(?!\w)/g,
612
+ // phone numbers
613
+ // IPv6 (with a zone id), whole: not inside a word or a path such as std::io, and not followed by a
614
+ // colon (an address with a port is bracketed). Before IPv4, which an IPv6 address may end with.
615
+ new RegExp(`(?<!\\w)(?:${ipRegex.v6().source})(?![\\w:])`, "g"),
616
+ // IPv4, also after `key:` and before `:port`, but not inside a word or a longer dotted number (a
617
+ // four-part version number reads as an address and is redacted too).
618
+ new RegExp(`(?<!\\w|\\d\\.)(?:${ipRegex.v4().source})(?!\\w|\\.\\d)`, "g"),
619
+ /(?<!\w)@[A-Za-z0-9_.-]{2,}/g
620
+ // handles
621
+ ];
622
+ var decisionSchema = z5.object({
623
+ owner: z5.string().nullable(),
624
+ ownerConfidence: z5.number(),
625
+ topic: z5.string().nullable(),
626
+ topicConfidence: z5.number(),
627
+ kind: z5.string().nullable().default(null),
628
+ kindConfidence: z5.number().default(0),
629
+ /** Jev is confident the customer asked for nothing yet (asked with `snoozeUnclear` only). */
630
+ noRequest: z5.boolean().default(false)
631
+ });
632
+ var jevResponseSchema = z5.object({
633
+ answers: z5.record(
634
+ z5.string(),
635
+ z5.object({
636
+ choice: z5.string(),
637
+ probabilities: z5.record(z5.string(), z5.number()).optional(),
638
+ confidence: z5.number().optional()
639
+ })
640
+ )
641
+ });
642
+ var JevError = class extends Error {
643
+ constructor(detail) {
644
+ super(`TypeSafe Jev: ${detail}`);
645
+ this.name = "JevError";
646
+ }
647
+ };
648
+ function routesAccount(settings, accountId2) {
649
+ return settings.config.routing.accounts[String(accountId2)] !== void 0;
650
+ }
651
+ async function routeConversation(ctx, accountId2, conversationId) {
652
+ const { settings, store, chatwoot } = ctx;
653
+ const routing = settings.config.routing;
654
+ const owners = routing.accounts[String(accountId2)];
655
+ if (!owners) return;
656
+ const kinds = routing.kinds?.[String(accountId2)] ?? {};
657
+ const raw = await chatwoot.getConversation(accountId2, conversationId);
658
+ if (!raw) return;
659
+ const conversation = toRelayConversation(conversationId, raw);
660
+ const latest = await chatwoot.listMessages(accountId2, conversationId, { filter_internal_messages: true });
661
+ const identities = [conversation.contact?.name, conversation.contact?.email];
662
+ const inputs = await customerInputs(ctx, accountId2, conversationId, identities);
663
+ let seen = Math.max(inputs.seen, ...latest.filter(isCustomer).map((message) => message.id));
664
+ const key = inputs.ids.join(",");
665
+ const memoKey = `decision:${accountId2}:${conversationId}:${key}`;
666
+ const cached = inputs.ids.map(
667
+ (_, index) => readDecision(store.get(`decision:${accountId2}:${conversationId}:${inputs.ids.slice(0, index + 1).join(",")}`))
668
+ );
669
+ let decision = cached.at(-1);
670
+ const previous = cached.findLast((entry) => entry !== void 0);
671
+ const kindFor = (answer) => answer?.kind && answer.kindConfidence >= routing.minConfidence ? kinds[answer.kind] : void 0;
672
+ const assigneeFor = (answer) => answer?.owner && answer.ownerConfidence >= routing.minConfidence ? owners[answer.owner]?.assignee : void 0;
673
+ const eligible = (observed2, answer) => conversationId > (settings.config.startAfterConversationId[String(accountId2)] ?? 0) && !observed2.contact?.blocked && observed2.status === "open" && (!observed2.assignee || observed2.assignee.id === assigneeFor(answer));
674
+ let observed = raw;
675
+ if (inputs.ids.length > 0 && eligible(conversation, previous)) {
676
+ if (!decision) {
677
+ decision = await decide(ctx, owners, routing.kinds?.[String(accountId2)], inputs.text);
678
+ store.set(memoKey, JSON.stringify(decision));
679
+ }
680
+ const freshInputs = await customerInputs(ctx, accountId2, conversationId, identities);
681
+ if (freshInputs.ids.join(",") !== key) return "defer";
682
+ seen = Math.max(seen, freshInputs.seen);
683
+ const fresh = await chatwoot.getConversation(accountId2, conversationId);
684
+ if (!fresh) return;
685
+ observed = fresh;
686
+ const current = toRelayConversation(conversationId, fresh);
687
+ if (eligible(current, decision)) {
688
+ const kind = kindFor(decision);
689
+ const kindName = kind ? decision.kind : null;
690
+ const assignee = kind?.status ? void 0 : assigneeFor(decision);
691
+ const inputId = inputs.ids.at(-1) ?? 0;
692
+ if (assignee !== void 0) {
693
+ await actOnce(
694
+ ctx,
695
+ `assign:${accountId2}:${conversationId}:${key}`,
696
+ { inputId, kind: null, handled: 0 },
697
+ "after",
698
+ async () => {
699
+ if (!current.assignee) await chatwoot.assign(accountId2, conversationId, assignee);
700
+ }
701
+ );
702
+ }
703
+ const topic = !kind?.status && decision.topic !== null && Object.hasOwn(routing.topics ?? {}, decision.topic) && decision.topicConfidence >= routing.minConfidence && current.labels.every((label) => Object.hasOwn(kinds, label)) ? decision.topic : null;
704
+ const labels = [.../* @__PURE__ */ new Set([...current.labels, ...[topic, kindName].filter((label) => label !== null)])];
705
+ await actOnce(
706
+ ctx,
707
+ `labels:${accountId2}:${conversationId}:${key}`,
708
+ { inputId, kind: kindName, handled: 0 },
709
+ "after",
710
+ async () => {
711
+ if (labels.length !== current.labels.length) await chatwoot.setLabels(accountId2, conversationId, labels);
712
+ }
713
+ );
714
+ const replyKey = `reply:${accountId2}:${conversationId}`;
715
+ const botToken = settings.botToken(accountId2);
716
+ if (kind?.cannedResponse && botToken && store.get(replyKey) === void 0) {
717
+ const content = await chatwoot.cannedResponse(accountId2, kind.cannedResponse);
718
+ if (content === void 0) throw new Error("Chatwoot canned response is missing");
719
+ const bot = chatwootClient(settings.config.chatwoot.baseUrl, botToken, ctx.fetch);
720
+ await actOnce(
721
+ ctx,
722
+ replyKey,
723
+ { inputId, kind: kindName, handled: inputId },
724
+ "before",
725
+ () => bot.createMessage(accountId2, conversationId, { content, private: false, files: [] })
726
+ );
727
+ }
728
+ const snooze = !assignee && inputs.ids.length < MAX_MESSAGES && routing.snoozeUnclear && decision.noRequest;
729
+ const status = freshInputs.complete ? kind?.status ?? (snooze ? "snoozed" : void 0) : void 0;
730
+ if (status) {
731
+ await actOnce(
732
+ ctx,
733
+ `status:${accountId2}:${conversationId}:${key}`,
734
+ { inputId, kind: kindName, handled: kind?.status ? inputId : 0 },
735
+ "before",
736
+ () => chatwoot.setStatus(accountId2, conversationId, { status })
737
+ );
738
+ }
739
+ }
740
+ }
741
+ await syncAttributes(ctx, accountId2, conversationId, observed, seen);
742
+ }
743
+ async function syncAttributes(ctx, accountId2, conversationId, observed, seen) {
744
+ const current = observed.custom_attributes ?? {};
745
+ const names = ROUTING_ATTRIBUTES;
746
+ const key = `seen:${accountId2}:${conversationId}`;
747
+ const checkpoint = Math.max(messageWatermark(ctx.store.get(key)), seen, messageWatermark(current[names.seen]));
748
+ ctx.store.set(key, String(checkpoint));
749
+ const effects = readEffects(ctx.store, accountId2, conversationId);
750
+ const handled = Math.max(0, ...effects.map((effect) => effect.handled));
751
+ const kind = effects.findLast((effect) => effect.kind !== null)?.kind ?? null;
752
+ const attributes = {
753
+ [names.seen]: checkpoint,
754
+ ...handled > 0 ? { [names.handled]: Math.max(handled, messageWatermark(current[names.handled])) } : {},
755
+ ...kind === null ? {} : { [names.kind]: kind }
756
+ };
757
+ if (Object.entries(attributes).some(
758
+ ([name, value]) => (name === names.kind ? current[name] : messageWatermark(current[name])) !== value
759
+ ))
760
+ await ctx.chatwoot.setCustomAttributes(accountId2, conversationId, attributes);
761
+ }
762
+ function isCustomer(message) {
763
+ return message.message_type === 0 && !message.private;
764
+ }
765
+ async function customerInputs(ctx, accountId2, conversationId, identities) {
766
+ const ids = [];
767
+ const parts = [];
768
+ let after = 0;
769
+ let seen = 0;
770
+ let complete = false;
771
+ for (let page = 0; page < 3 && ids.length < MAX_MESSAGES; page += 1) {
772
+ const messages = await ctx.chatwoot.listMessages(accountId2, conversationId, {
773
+ after,
774
+ filter_internal_messages: true
775
+ });
776
+ for (const message of messages.filter(isCustomer)) {
777
+ seen = Math.max(seen, message.id);
778
+ const text2 = sanitize(
779
+ [message.content_attributes?.email?.subject ?? "", messageContent(message)].join("\n"),
780
+ identities
781
+ );
782
+ if (ids.length < MAX_MESSAGES && text2.replaceAll("[REDACTED]", "").trim()) {
783
+ ids.push(message.id);
784
+ parts.push(text2);
785
+ }
786
+ }
787
+ complete = ids.length === MAX_MESSAGES || messages.length < MESSAGE_PAGE_SIZE;
788
+ if (complete) break;
789
+ after = messages.at(-1)?.id ?? after;
790
+ }
791
+ return { ids, text: parts.join(" ").slice(0, MAX_TEXT), complete, seen };
792
+ }
793
+ function sanitize(text2, identities) {
794
+ let value = text2.normalize("NFKC");
795
+ for (const pattern of REDACTIONS) value = value.replace(pattern, "[REDACTED]");
796
+ const names = identities.flatMap((identity) => identity ? [identity, ...identity.split(/\s+/)] : []);
797
+ for (const name of [...new Set(names)].filter((item) => item.length >= 2).sort((a, b) => b.length - a.length)) {
798
+ const escaped = RegExp.escape(name);
799
+ value = value.replace(new RegExp(`(?<!\\w)${escaped}(?!\\w)`, "giu"), "[REDACTED]");
800
+ }
801
+ return value.replace(/\s+/g, " ").trim().slice(0, MAX_TEXT);
802
+ }
803
+ async function decide(ctx, owners, kinds, text2) {
804
+ const routing = ctx.settings.config.routing;
805
+ const apiKey = ctx.settings.secrets.TYPESAFE_API_KEY;
806
+ if (!routing || !apiKey) throw new JevError("routing is not configured");
807
+ const ownerCriteria = Object.fromEntries(
808
+ Object.entries(owners).map(([route, owner2]) => [route, owner2.covers])
809
+ );
810
+ ownerCriteria[UNCLEAR] = UNCLEAR_CRITERION;
811
+ const questions = {
812
+ owner: {
813
+ type: "choice",
814
+ instructions: "Select who should handle this support ticket, using only the ticket.",
815
+ criteria: ownerCriteria
816
+ }
817
+ };
818
+ if (routing.topics) {
819
+ questions.topic = {
820
+ type: "choice",
821
+ instructions: "Select the topic of this support ticket, using only the ticket.",
822
+ criteria: routing.topics
823
+ };
824
+ }
825
+ if (routing.snoozeUnclear) {
826
+ questions.request = {
827
+ type: "choice",
828
+ instructions: "Select whether the customer asks for anything in this support ticket, using only the ticket.",
829
+ criteria: REQUEST_CRITERIA
830
+ };
831
+ }
832
+ if (kinds) {
833
+ questions.kind = {
834
+ type: "choice",
835
+ instructions: "Select the kind of this support ticket, using only the ticket.",
836
+ criteria: {
837
+ ...Object.fromEntries(Object.entries(kinds).map(([name, kind2]) => [name, kind2.covers])),
838
+ [NO_KIND]: NO_KIND_CRITERION
839
+ }
840
+ };
841
+ }
842
+ const response = await ctx.fetch(
843
+ new Request(JEV_URL, {
844
+ method: "POST",
845
+ redirect: "manual",
846
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
847
+ body: JSON.stringify({ model: routing.model, state: { ticket: text2 }, questions })
848
+ })
849
+ );
850
+ if (!response.ok) throw new JevError(`HTTP ${response.status}`);
851
+ const parsed = jevResponseSchema.safeParse(await response.json().catch(() => void 0));
852
+ if (!parsed.success) throw new JevError("invalid response");
853
+ const answer = (question) => {
854
+ const criteria = questions[question]?.criteria;
855
+ if (!criteria) return { choice: null, confidence: 0 };
856
+ const given = parsed.data.answers[question];
857
+ if (!given || !Object.hasOwn(criteria, given.choice)) throw new JevError(`no valid ${question}`);
858
+ return { choice: given.choice, confidence: given.probabilities?.[given.choice] ?? given.confidence ?? 0 };
859
+ };
860
+ const owner = answer("owner");
861
+ const topic = answer("topic");
862
+ const kind = answer("kind");
863
+ const request = answer("request");
864
+ return {
865
+ owner: owner.choice === UNCLEAR ? null : owner.choice,
866
+ ownerConfidence: owner.confidence,
867
+ topic: topic.choice,
868
+ topicConfidence: topic.confidence,
869
+ kind: kind.choice === NO_KIND ? null : kind.choice,
870
+ kindConfidence: kind.confidence,
871
+ noRequest: request.choice === "none" && request.confidence >= routing.minConfidence
872
+ };
873
+ }
874
+ function readDecision(stored) {
875
+ const parsed = decisionSchema.safeParse(parseJson(stored));
876
+ return parsed.success ? parsed.data : void 0;
877
+ }
878
+
879
+ // ../../shared/settings.ts
880
+ function settingsLoader(parse) {
881
+ const cache = /* @__PURE__ */ new WeakMap();
882
+ return (env) => {
883
+ let settings = cache.get(env);
884
+ if (!settings) {
885
+ settings = readConfig(env).then((config) => parse(config, env));
886
+ cache.set(env, settings);
887
+ settings.catch(() => cache.delete(env));
888
+ }
889
+ return settings;
890
+ };
891
+ }
892
+ async function readConfig(env) {
893
+ if (env.CONFIG_KEY === void 0) return typeof env.CONFIG === "string" ? parseJson(env.CONFIG) : env.CONFIG;
894
+ if (env.CONFIG !== void 0) throw new ConfigError("Invalid CONFIG: set either CONFIG or CONFIG_KEY");
895
+ if (!env.CONFIG_STORE) throw new ConfigError("Invalid CONFIG_KEY: requires the CONFIG_STORE KV namespace");
896
+ const config = await env.CONFIG_STORE.get(env.CONFIG_KEY, "json");
897
+ if (config === null) throw new ConfigError("Invalid CONFIG_KEY: not in CONFIG_STORE");
898
+ return config;
899
+ }
900
+
901
+ // src/config.ts
902
+ import { z as z6 } from "zod";
903
+ var accountId = z6.string().regex(/^[1-9]\d*$/, "must be a Chatwoot account id").refine((value) => Number.isSafeInteger(Number(value)), "must be a safe integer");
904
+ var configSchema = z6.strictObject({
905
+ chatwoot: z6.strictObject({ baseUrl: z6.url({ protocol: /^https?$/ }) }),
906
+ reconcile: reconcileSchema,
907
+ startAfterConversationId: z6.record(accountId, z6.number().int().min(0)).default({}),
908
+ subrequestBudget: z6.number().int().min(20).max(1e3).default(45),
909
+ routing: z6.strictObject({
910
+ model: z6.string().min(1).default("jev-1.13.0"),
911
+ /** Jev's probability an answer needs before it is applied. */
912
+ minConfidence: z6.number().min(0.5).max(1).default(0.7),
913
+ /**
914
+ * Snooze a ticket Jev cannot assign yet, and in which the customer asked for nothing yet, until
915
+ * their next message (see src/routing.ts).
916
+ */
917
+ snoozeUnclear: z6.boolean().default(false),
918
+ /** Per Chatwoot account id: the owners Jev chooses from, by a short name. */
919
+ accounts: z6.record(
920
+ accountId,
921
+ z6.record(
922
+ z6.string().regex(/^[a-z0-9_]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "unclear", "unclear is reserved"),
923
+ z6.strictObject({
924
+ /** Chatwoot user id to assign. */
925
+ assignee: z6.number().int().positive(),
926
+ /** What the owner handles, as Jev's criterion for choosing them. */
927
+ covers: z6.string().min(1).max(1e3)
928
+ })
929
+ ).refine((owners) => Object.keys(owners).length > 0, "needs at least one owner")
930
+ ),
931
+ /** Topic labels (Chatwoot label names, lower case) and what each covers. Unset: no topic. */
932
+ topics: z6.record(
933
+ z6.string().regex(/^[a-z0-9_-]{1,255}$/, "must be a Chatwoot label name (lower case)"),
934
+ z6.string().min(1).max(1e3)
935
+ ).optional(),
936
+ /**
937
+ * Per Chatwoot account id: kinds of ticket Jev recognizes, by a short name, and what is done,
938
+ * once, when it does (see src/routing.ts). Unset: none.
939
+ */
940
+ kinds: z6.record(
941
+ accountId,
942
+ z6.record(
943
+ z6.string().regex(/^[a-z0-9_-]{1,40}$/, "must be a short lower-case name").refine((name) => name !== "none", "none is reserved"),
944
+ z6.strictObject({
945
+ /** What the kind is, as Jev's criterion for recognizing it. */
946
+ covers: z6.string().min(1).max(1e3),
947
+ /**
948
+ * Short code of the account's Chatwoot canned response sent to the customer once, as
949
+ * the account's agent bot (CHATWOOT_BOT_TOKENS). None is sent while it does not exist.
950
+ */
951
+ cannedResponse: z6.string().trim().min(1).max(255).optional(),
952
+ /**
953
+ * Set instead of routing the ticket (after the reply, with cannedResponse): `resolved`,
954
+ * or `snoozed` until the customer's next message. A new message from the customer
955
+ * reopens either.
956
+ */
957
+ status: z6.enum(["resolved", "snoozed"]).optional()
958
+ })
959
+ )
960
+ ).optional()
961
+ })
962
+ }).refine((config) => Object.keys(config.startAfterConversationId).every((id3) => config.routing.accounts[id3]), {
963
+ path: ["startAfterConversationId"],
964
+ message: "must only name routed accounts"
965
+ }).refine((config) => Object.keys(config.routing?.kinds ?? {}).every((id3) => config.routing?.accounts[id3]), {
966
+ path: ["routing", "kinds"],
967
+ message: "must only name routed accounts"
968
+ }).refine(
969
+ (config) => Object.values(config.routing?.kinds ?? {}).every(
970
+ (kinds) => Object.keys(kinds).every((kind) => !Object.hasOwn(config.routing?.topics ?? {}, kind))
971
+ ),
972
+ { path: ["routing", "kinds"], message: "a kind cannot be named as a topic: they are labels of two families" }
973
+ );
974
+ var secretsSchema = z6.object({
975
+ CHATWOOT_TOKEN: z6.string().min(1),
976
+ CHATWOOT_WEBHOOK_SECRETS: jsonRecord,
977
+ TYPESAFE_API_KEY: z6.string().min(1),
978
+ CHATWOOT_BOT_TOKENS: jsonRecord.default({})
979
+ });
980
+ function parseSettings(rawConfig, rawSecrets) {
981
+ const config = configSchema.safeParse(rawConfig);
982
+ if (!config.success) throw new ConfigError(`Invalid CONFIG: ${describe(config.error)}`);
983
+ const secrets = secretsSchema.safeParse(rawSecrets);
984
+ if (!secrets.success) throw new ConfigError(`Invalid secrets: ${describe(secrets.error)}`);
985
+ return buildSettings(config.data, secrets.data);
986
+ }
987
+ function buildSettings(config, secrets) {
988
+ for (const accountId2 of Object.keys(config.routing.accounts)) {
989
+ if (!secrets.CHATWOOT_WEBHOOK_SECRETS[accountId2]) {
990
+ throw new ConfigError(`Invalid secrets: CHATWOOT_WEBHOOK_SECRETS: account ${accountId2} needs a webhook secret`);
991
+ }
992
+ }
993
+ for (const [accountId2, kinds] of Object.entries(config.routing.kinds ?? {})) {
994
+ if (Object.values(kinds).some((kind) => kind.cannedResponse) && !secrets.CHATWOOT_BOT_TOKENS[accountId2]) {
995
+ throw new ConfigError(`Invalid secrets: CHATWOOT_BOT_TOKENS: account ${accountId2} has kinds that reply`);
996
+ }
997
+ }
998
+ return { config, secrets, botToken: (accountId2) => secrets.CHATWOOT_BOT_TOKENS[String(accountId2)] };
999
+ }
1000
+
1001
+ // src/settings.ts
1002
+ var loadSettings = settingsLoader(parseSettings);
1003
+
1004
+ // src/router.ts
1005
+ var ROUTER_NAME = "global";
1006
+ var ROUTE_BUDGET = 15;
1007
+ var BUDGET = { route: ROUTE_BUDGET, sweep: 1 };
1008
+ var RUN_WALL_MS = 5 * 60 * 1e3;
1009
+ var id = z7.number().int().positive();
1010
+ var jobSchema = z7.discriminatedUnion("type", [
1011
+ z7.object({ type: z7.literal("route"), accountId: id, conversationId: id }),
1012
+ z7.object({ type: z7.literal("sweep"), accountId: id })
1013
+ ]);
1014
+ var Router = class extends DurableObject {
1015
+ store;
1016
+ constructor(context, env) {
1017
+ super(context, env);
1018
+ this.store = new QueueStore(context.storage.sql);
1019
+ context.blockConcurrencyWhile(async () => this.store.migrate());
1020
+ }
1021
+ async enqueueConversation(accountId2, conversationId) {
1022
+ const settings = await loadSettings(this.env);
1023
+ if (!routesAccount(settings, accountId2)) return;
1024
+ this.enqueue({ type: "route", accountId: accountId2, conversationId });
1025
+ await this.schedule();
1026
+ }
1027
+ async requestSweep() {
1028
+ for (const accountId2 of Object.keys((await loadSettings(this.env)).config.routing.accounts)) {
1029
+ this.enqueue({ type: "sweep", accountId: Number(accountId2) });
1030
+ }
1031
+ await this.schedule();
1032
+ }
1033
+ async alarm() {
1034
+ const settings = await loadSettings(this.env);
1035
+ const budget = new Budget(settings.config.subrequestBudget);
1036
+ const chatwoot = chatwootClient(settings.config.chatwoot.baseUrl, settings.secrets.CHATWOOT_TOKEN, budget.fetch);
1037
+ const started = Date.now();
1038
+ let yielded = false;
1039
+ this.store.prune();
1040
+ for (let job = this.store.nextDueJob(); job; job = this.store.nextDueJob()) {
1041
+ const parsed = jobSchema.safeParse(parseJson(job.payload));
1042
+ if (!parsed.success) {
1043
+ log.warn("unreadable job dropped", { job: job.key });
1044
+ this.store.deleteJob(job.key);
1045
+ continue;
1046
+ }
1047
+ const payload = parsed.data;
1048
+ if (budget.remaining < BUDGET[payload.type] || Date.now() - started > RUN_WALL_MS) {
1049
+ yielded = true;
1050
+ break;
1051
+ }
1052
+ try {
1053
+ const ctx = { settings, chatwoot, store: this.store, fetch: budget.fetch };
1054
+ if (payload.type === "route") {
1055
+ if (await routeConversation(ctx, payload.accountId, payload.conversationId) === "defer") {
1056
+ this.store.deferJob(job);
1057
+ yielded = true;
1058
+ break;
1059
+ }
1060
+ } else if (routesAccount(settings, payload.accountId)) {
1061
+ await this.sweep(settings, chatwoot, payload.accountId);
1062
+ }
1063
+ this.store.completeJob(job);
1064
+ } catch (error) {
1065
+ if (error instanceof ChatwootError && error.conversationMissing) {
1066
+ log.info("conversation deleted; job dropped", { job: job.key });
1067
+ this.store.deleteJob(job.key);
1068
+ continue;
1069
+ }
1070
+ if (error instanceof BudgetExhaustedError) {
1071
+ this.store.deferJob(job);
1072
+ yielded = true;
1073
+ break;
1074
+ }
1075
+ const delay = retryDelay(job.attempts);
1076
+ const logAt = job.attempts + 1 >= 3 ? log.error : log.warn;
1077
+ logAt("job failed; will retry", {
1078
+ job: job.key,
1079
+ attempts: job.attempts + 1,
1080
+ delayMs: delay,
1081
+ ...errorFields(error)
1082
+ });
1083
+ this.store.retryJob(job, delay);
1084
+ }
1085
+ }
1086
+ await this.schedule(yielded ? Date.now() : void 0);
1087
+ }
1088
+ async sweep(settings, chatwoot, accountId2) {
1089
+ const pass = readSweepPass(this.store, accountId2, settings.config.reconcile);
1090
+ const conversations = await chatwoot.listConversations(accountId2, pass.page);
1091
+ let finished = conversations.length === 0;
1092
+ for (const conversation of conversations) {
1093
+ if ((conversation.last_activity_at ?? 0) < pass.cutoff) {
1094
+ finished = true;
1095
+ break;
1096
+ }
1097
+ const conversationId = conversation.id;
1098
+ if (conversationId === void 0) continue;
1099
+ this.enqueue({ type: "route", accountId: accountId2, conversationId });
1100
+ }
1101
+ if (finished) {
1102
+ saveSweepPass(this.store, accountId2, pass);
1103
+ } else {
1104
+ saveSweepPass(this.store, accountId2, pass, pass.page + 1);
1105
+ this.enqueue({ type: "sweep", accountId: accountId2 });
1106
+ }
1107
+ }
1108
+ enqueue(payload) {
1109
+ const key = payload.type === "sweep" ? `sweep:${payload.accountId}` : `${payload.type}:${payload.accountId}:${payload.conversationId}`;
1110
+ this.store.enqueue(key, payload.type === "sweep" ? 1 : 0, JSON.stringify(payload));
1111
+ }
1112
+ async schedule(at) {
1113
+ const next = at ?? this.store.nextWakeup();
1114
+ if (next === void 0) return;
1115
+ const current = await this.ctx.storage.getAlarm();
1116
+ if (current === null || current > next) await this.ctx.storage.setAlarm(next);
1117
+ }
1118
+ };
1119
+
1120
+ // src/webhook.ts
1121
+ import { z as z8 } from "zod";
1122
+ var id2 = z8.number().int().positive();
1123
+ var eventSchema = z8.object({
1124
+ event: z8.string(),
1125
+ account: z8.object({ id: id2 }),
1126
+ id: id2.optional(),
1127
+ conversation: z8.object({ id: id2 }).optional(),
1128
+ message_type: z8.union([z8.string(), z8.number()]).optional(),
1129
+ private: z8.boolean().optional(),
1130
+ sender: z8.object({ type: z8.string().optional() }).nullish()
1131
+ });
1132
+ var CONVERSATION_EVENTS = /* @__PURE__ */ new Set(["conversation_created", "conversation_updated", "conversation_status_changed"]);
1133
+ function eventTarget(payload) {
1134
+ const parsed = eventSchema.safeParse(payload);
1135
+ if (!parsed.success) return void 0;
1136
+ const event = parsed.data;
1137
+ if (CONVERSATION_EVENTS.has(event.event) && event.id !== void 0) {
1138
+ return { accountId: event.account.id, conversationId: event.id };
1139
+ }
1140
+ if (event.event === "message_created" && (event.message_type === "incoming" || event.message_type === 0) && !event.private && event.sender?.type === "contact" && event.id !== void 0 && event.conversation) {
1141
+ return { accountId: event.account.id, conversationId: event.conversation.id };
1142
+ }
1143
+ return void 0;
1144
+ }
1145
+
1146
+ // src/index.ts
1147
+ var app = new Hono();
1148
+ app.get("/healthz", async (context) => {
1149
+ try {
1150
+ await loadSettings(context.env);
1151
+ return context.json({ ok: true });
1152
+ } catch (error) {
1153
+ log.error(error instanceof ConfigError ? "configuration invalid" : "configuration unavailable", errorFields(error));
1154
+ return context.json({ ok: false }, 503);
1155
+ }
1156
+ });
1157
+ app.post("/chatwoot/webhook", bodyLimit({ maxSize: 2 * 1024 * 1024 }), async (context) => {
1158
+ const settings = await loadSettings(context.env);
1159
+ const timestamp = context.req.header("x-chatwoot-timestamp");
1160
+ if (!timestamp || !isFreshTimestamp(timestamp, Math.floor(Date.now() / 1e3))) {
1161
+ return context.text("invalid or stale timestamp", 401);
1162
+ }
1163
+ const body = new Uint8Array(await context.req.arrayBuffer());
1164
+ const signature = context.req.header("x-chatwoot-signature");
1165
+ let signedBy;
1166
+ for (const [accountId2, secret] of Object.entries(settings.secrets.CHATWOOT_WEBHOOK_SECRETS)) {
1167
+ if (await verifyChatwootSignature(secret, timestamp, body, signature)) {
1168
+ signedBy = Number(accountId2);
1169
+ break;
1170
+ }
1171
+ }
1172
+ if (signedBy === void 0) return context.text("invalid signature", 401);
1173
+ let payload;
1174
+ try {
1175
+ payload = JSON.parse(new TextDecoder().decode(body));
1176
+ } catch {
1177
+ return context.text("bad request", 400);
1178
+ }
1179
+ const target = eventTarget(payload);
1180
+ if (!target) return context.json({ ok: true, ignored: true });
1181
+ if (target.accountId !== signedBy || !routesAccount(settings, target.accountId)) {
1182
+ return context.text("account does not match the webhook secret", 403);
1183
+ }
1184
+ await context.env.ROUTER.getByName(ROUTER_NAME).enqueueConversation(target.accountId, target.conversationId);
1185
+ return context.json({ ok: true });
1186
+ });
1187
+ app.notFound((context) => context.text("not found", 404));
1188
+ app.onError((error, context) => {
1189
+ log.error("request failed", { path: context.req.path, ...errorFields(error) });
1190
+ return context.text("internal error", 500);
1191
+ });
1192
+ var src_default = {
1193
+ fetch: app.fetch,
1194
+ async scheduled(_controller, env, context) {
1195
+ context.waitUntil(env.ROUTER.getByName(ROUTER_NAME).requestSweep());
1196
+ }
1197
+ };
1198
+ export {
1199
+ Router,
1200
+ src_default as default
1201
+ };