chatwoot-router 0.1.0 → 0.3.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,5 @@
1
+ import { type ChatwootClient, type ChatwootMessage } from "../../../shared/chatwoot/api.ts";
2
+ /** A Chatwoot creation receipt, not a channel delivery receipt. */
3
+ export declare function confirmedReply(message: ChatwootMessage | undefined, botId: number | undefined): boolean;
4
+ /** Read past turn boundaries, including replies made by the former relay's brand bot. */
5
+ export declare function replyHistory(chatwoot: ChatwootClient, accountId: number, conversationId: number, botId: number | undefined, freshPages?: readonly ChatwootMessage[][]): Promise<"found" | "complete-none" | "unknown">;
@@ -1,14 +1,11 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import type { Env } from "./env.ts";
3
- export declare const ROUTER_NAME = "global";
4
- export declare const ROUTE_BUDGET = 15;
3
+ import type { Transition } from "./webhook.ts";
4
+ export declare function conversationName(accountId: number, conversationId: number): string;
5
5
  export declare class Router extends DurableObject<Env> {
6
6
  private readonly store;
7
7
  constructor(context: DurableObjectState, env: Env);
8
- enqueueConversation(accountId: number, conversationId: number): Promise<void>;
9
- requestSweep(): Promise<void>;
8
+ enqueueConversation(accountId: number, conversationId: number, transition?: Transition): Promise<void>;
10
9
  alarm(): Promise<void>;
11
- private sweep;
12
10
  private enqueue;
13
- private schedule;
14
11
  }
@@ -1,10 +1,7 @@
1
1
  import { type ChatwootClient, type Fetch } from "../../../shared/chatwoot/api.ts";
2
2
  import type { Settings } from "./config.ts";
3
- /** Jev's answer when no owner fits; also the reserved route name. */
4
- export declare const UNCLEAR = "unclear";
5
3
  export interface RoutingStore {
6
4
  get(key: string): string | undefined;
7
- list(start: string, end: string): string[];
8
5
  set(key: string, value: string): void;
9
6
  }
10
7
  export interface RoutingContext {
@@ -16,4 +13,3 @@ export interface RoutingContext {
16
13
  }
17
14
  export declare function routesAccount(settings: Settings, accountId: number): boolean;
18
15
  export declare function routeConversation(ctx: RoutingContext, accountId: number, conversationId: number): Promise<"defer" | undefined>;
19
- export declare function sanitize(text: string, identities: Array<string | null | undefined>): string;
@@ -0,0 +1,118 @@
1
+ import { type ChatwootClient } from "../../../shared/chatwoot/api.ts";
2
+ import type { RoutingStore } from "./routing.ts";
3
+ import type { Transition } from "./webhook.ts";
4
+ export declare function expectActivity(store: RoutingStore, accountId: number, conversationId: number, transition: Transition): void;
5
+ /** Preserve an observed pending departure across retries, without using metadata timestamps. */
6
+ export declare function observeStatus(store: RoutingStore, accountId: number, conversationId: number, status: string): void;
7
+ export declare function requestHandoff(store: RoutingStore, accountId: number, conversationId: number): void;
8
+ /** Counts processing failures for this turn, independently of an old queued job's backoff. */
9
+ export declare function recordFailure(store: RoutingStore, accountId: number, conversationId: number): number;
10
+ export declare function clearFailures(store: RoutingStore, accountId: number, conversationId: number): void;
11
+ export declare function readTurn(chatwoot: ChatwootClient, store: RoutingStore, accountId: number, conversationId: number): Promise<{
12
+ pages: {
13
+ id: number;
14
+ conversation_id?: number | undefined;
15
+ content?: string | null | undefined;
16
+ message_type: number;
17
+ content_type?: string | null | undefined;
18
+ status?: string | null | undefined;
19
+ created_at?: number | null | undefined;
20
+ private?: boolean | null | undefined;
21
+ content_attributes?: {
22
+ email?: {
23
+ subject?: string | null | undefined;
24
+ auto_reply?: boolean | null | undefined;
25
+ text_content?: {
26
+ quoted?: string | null | undefined;
27
+ } | null | undefined;
28
+ html_content?: {
29
+ quoted?: string | null | undefined;
30
+ } | null | undefined;
31
+ } | null | undefined;
32
+ deleted?: boolean | null | undefined;
33
+ external_error?: string | null | undefined;
34
+ activity?: {
35
+ type?: string | null | undefined;
36
+ status?: string | null | undefined;
37
+ } | null | undefined;
38
+ submitted_values?: unknown;
39
+ submitted_email?: unknown;
40
+ items?: unknown;
41
+ } | null | undefined;
42
+ sender?: {
43
+ id?: number | null | undefined;
44
+ name?: string | null | undefined;
45
+ email?: string | null | undefined;
46
+ type?: string | null | undefined;
47
+ thumbnail?: string | null | undefined;
48
+ avatar_url?: string | null | undefined;
49
+ } | null | undefined;
50
+ attachments?: {
51
+ file_type?: string | null | undefined;
52
+ data_url?: string | null | undefined;
53
+ fallback_title?: string | null | undefined;
54
+ coordinates_lat?: number | null | undefined;
55
+ coordinates_long?: number | null | undefined;
56
+ meta?: {
57
+ firstName?: string | null | undefined;
58
+ lastName?: string | null | undefined;
59
+ first_name?: string | null | undefined;
60
+ last_name?: string | null | undefined;
61
+ } | null | undefined;
62
+ }[] | null | undefined;
63
+ }[][];
64
+ messages: {
65
+ id: number;
66
+ conversation_id?: number | undefined;
67
+ content?: string | null | undefined;
68
+ message_type: number;
69
+ content_type?: string | null | undefined;
70
+ status?: string | null | undefined;
71
+ created_at?: number | null | undefined;
72
+ private?: boolean | null | undefined;
73
+ content_attributes?: {
74
+ email?: {
75
+ subject?: string | null | undefined;
76
+ auto_reply?: boolean | null | undefined;
77
+ text_content?: {
78
+ quoted?: string | null | undefined;
79
+ } | null | undefined;
80
+ html_content?: {
81
+ quoted?: string | null | undefined;
82
+ } | null | undefined;
83
+ } | null | undefined;
84
+ deleted?: boolean | null | undefined;
85
+ external_error?: string | null | undefined;
86
+ activity?: {
87
+ type?: string | null | undefined;
88
+ status?: string | null | undefined;
89
+ } | null | undefined;
90
+ submitted_values?: unknown;
91
+ submitted_email?: unknown;
92
+ items?: unknown;
93
+ } | null | undefined;
94
+ sender?: {
95
+ id?: number | null | undefined;
96
+ name?: string | null | undefined;
97
+ email?: string | null | undefined;
98
+ type?: string | null | undefined;
99
+ thumbnail?: string | null | undefined;
100
+ avatar_url?: string | null | undefined;
101
+ } | null | undefined;
102
+ attachments?: {
103
+ file_type?: string | null | undefined;
104
+ data_url?: string | null | undefined;
105
+ fallback_title?: string | null | undefined;
106
+ coordinates_lat?: number | null | undefined;
107
+ coordinates_long?: number | null | undefined;
108
+ meta?: {
109
+ firstName?: string | null | undefined;
110
+ lastName?: string | null | undefined;
111
+ first_name?: string | null | undefined;
112
+ last_name?: string | null | undefined;
113
+ } | null | undefined;
114
+ }[] | null | undefined;
115
+ }[];
116
+ boundary: number;
117
+ handoff: boolean;
118
+ }>;
@@ -1,4 +1,9 @@
1
+ export interface Transition {
2
+ status: string;
3
+ at?: number;
4
+ }
1
5
  export declare function eventTarget(payload: unknown): {
2
6
  accountId: number;
3
7
  conversationId: number;
8
+ transition?: Transition;
4
9
  } | undefined;
@@ -0,0 +1,2 @@
1
+ /** Set the one Durable Object alarm for the earliest durable wake-up. */
2
+ export declare function scheduleAlarm(state: DurableObjectState, next: number | undefined): Promise<void>;
@@ -2,12 +2,25 @@ import type { Fetch } from "./chatwoot/api.ts";
2
2
  export declare class BudgetExhaustedError extends Error {
3
3
  constructor();
4
4
  }
5
+ export { JobDeadlineError } from "./deadline.ts";
6
+ export declare const METADATA_TIMEOUT_MS = 1500;
7
+ export declare const TRANSFER_TIMEOUT_MS = 8000;
8
+ export declare const JOB_SLICE_MS = 10000;
5
9
  export declare class Budget {
6
10
  readonly limit: number;
7
11
  private readonly fetchImpl;
8
- private readonly timeoutMs;
9
12
  private used;
10
- constructor(limit: number, fetchImpl?: Fetch, timeoutMs?: number);
13
+ private slice;
14
+ private sliceEnds;
15
+ constructor(limit: number, fetchImpl?: Fetch);
11
16
  get remaining(): number;
17
+ startSlice(ms?: number): void;
18
+ requireTime(ms: number): void;
19
+ checkpoint(): void;
20
+ consume(): void;
21
+ require(requests: number): void;
22
+ controlSignal(ms: number): AbortSignal;
23
+ fetchWith(timeoutMs: number): Fetch;
12
24
  readonly fetch: Fetch;
25
+ private request;
13
26
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import type { RateLimitStore } from "../rate-limit.ts";
2
3
  import type { RelayConversation, RelayMessage } from "../types.ts";
3
4
  import type { components, operations } from "./schema.ts";
4
5
  export type Fetch = (input: Request) => Promise<Response>;
@@ -6,15 +7,21 @@ export type Fetch = (input: Request) => Promise<Response>;
6
7
  export declare class ChatwootError extends Error {
7
8
  readonly status: number;
8
9
  readonly conversationMissing: boolean;
9
- constructor(status: number, operation: string, conversationMissing?: boolean);
10
+ readonly retryAfterMs: number | undefined;
11
+ constructor(status: number, operation: string, conversationMissing?: boolean, retryAfterMs?: number);
10
12
  }
11
13
  /**
12
14
  * A conversation as returned by GET conversations/{id} and in the conversation list. The spec's
13
15
  * `status` enum omits `snoozed`, which the API does return, so the relay treats it as a string.
14
16
  */
15
- export type ChatwootConversation = components["schemas"]["conversation_show"];
17
+ export type ChatwootConversation = components["schemas"]["conversation_show"] & {
18
+ meta?: {
19
+ assignee_type?: string | null;
20
+ };
21
+ };
16
22
  declare const messageSchema: z.ZodObject<{
17
23
  id: z.ZodNumber;
24
+ conversation_id: z.ZodOptional<z.ZodNumber>;
18
25
  content: z.ZodOptional<z.ZodNullable<z.ZodString>>;
19
26
  message_type: z.ZodNumber;
20
27
  content_type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -34,6 +41,10 @@ declare const messageSchema: z.ZodObject<{
34
41
  }, z.core.$strip>>>;
35
42
  deleted: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
36
43
  external_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
44
+ activity: z.ZodOptional<z.ZodNullable<z.ZodObject<{
45
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
46
+ status: z.ZodOptional<z.ZodNullable<z.ZodString>>;
47
+ }, z.core.$strip>>>;
37
48
  submitted_values: z.ZodOptional<z.ZodUnknown>;
38
49
  submitted_email: z.ZodOptional<z.ZodUnknown>;
39
50
  items: z.ZodOptional<z.ZodUnknown>;
@@ -65,6 +76,8 @@ export type ChatwootMessage = z.infer<typeof messageSchema>;
65
76
  export declare const CONVERSATIONS_PER_PAGE = 25;
66
77
  /** Chatwoot returns up to 100 messages per `after` page. */
67
78
  export declare const MESSAGE_PAGE_SIZE = 100;
79
+ /** MessageFinder returns 20 for the latest page and for `before`. */
80
+ export declare const MESSAGE_HISTORY_PAGE_SIZE = 20;
68
81
  /** The body of POST conversations/{id}/toggle_status. */
69
82
  export type StatusChange = operations["toggle-status-of-a-conversation"]["requestBody"]["content"]["application/json"];
70
83
  /** A priority for POST conversations/{id}/toggle_priority; null clears it. */
@@ -86,7 +99,7 @@ export type ChatwootClient = ReturnType<typeof chatwootClient>;
86
99
  type MessageQuery = NonNullable<operations["list-all-messages"]["parameters"]["query"]> & {
87
100
  filter_internal_messages?: true;
88
101
  };
89
- export declare function chatwootClient(baseUrl: string, token: string, fetch: Fetch): {
102
+ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fetch, limits?: RateLimitStore): {
90
103
  /** The conversation, or undefined when it does not exist (it was deleted). */
91
104
  getConversation(accountId: number, conversationId: number): Promise<ChatwootConversation | undefined>;
92
105
  /** Messages with an id above `after`, oldest first; without `after`, the latest page. */
@@ -97,13 +110,16 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
97
110
  * (MessageFinder#messages_between at v4.18.0) or exclusively (as the spec describes it).
98
111
  */
99
112
  getMessage(accountId: number, conversationId: number, messageId: number): Promise<ChatwootMessage | undefined>;
113
+ inboxBot(accountId: number, inboxId: number): Promise<{
114
+ id: number;
115
+ } | undefined>;
100
116
  inboxName(accountId: number, inboxId: number): Promise<string | undefined>;
101
117
  /**
102
118
  * One page (CONVERSATIONS_PER_PAGE) of conversations of any assignee and the given status
103
119
  * (default: any), most recent activity first (Conversations::SortService's default,
104
120
  * `last_activity_at_desc`, at v4.18.0).
105
121
  */
106
- listConversations(accountId: number, page: number, status?: "all" | "open" | "snoozed"): Promise<ChatwootConversation[]>;
122
+ listConversations(accountId: number, page: number, status?: "all" | "open" | "snoozed" | "pending"): Promise<ChatwootConversation[]>;
107
123
  /** The content of the account's canned response with this short code, if it exists. */
108
124
  cannedResponse(accountId: number, shortCode: string): Promise<string | undefined>;
109
125
  getProfile(): Promise<{
@@ -160,7 +176,7 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
160
176
  setStatus(accountId: number, conversationId: number, change: StatusChange): Promise<void>;
161
177
  setPriority(accountId: number, conversationId: number, priority: ConversationPriority | null): Promise<void>;
162
178
  setContactBlocked(accountId: number, contactId: number, blocked: boolean): Promise<void>;
163
- assign(accountId: number, conversationId: number, assigneeId: number): Promise<void>;
179
+ assign(accountId: number, conversationId: number, assigneeId: number, assigneeType?: "User" | "AgentBot"): Promise<void>;
164
180
  /**
165
181
  * Removes the conversation's assignee the way Chatwoot's dashboard does (ConversationAction.vue
166
182
  * posts `assignee_id: null`, which AssignmentsController#create applies, at v4.18.0). The
@@ -172,14 +188,51 @@ export declare function chatwootClient(baseUrl: string, token: string, fetch: Fe
172
188
  conversationLabels(accountId: number, conversationId: number): Promise<string[]>;
173
189
  /** Replaces the conversation's labels (the spec's "Add Labels" overwrites the list). */
174
190
  setLabels(accountId: number, conversationId: number, labels: string[]): Promise<void>;
175
- setCustomAttributes: (accountId: number, conversationId: number, attributes: Record<string, unknown>) => Promise<void>;
191
+ setCustomAttributes(accountId: number, conversationId: number, attributes: Record<string, unknown>): Promise<void>;
176
192
  /**
177
193
  * Sends an outgoing message (or private note). With files it is sent as the spec's
178
194
  * multipart/form-data body; the generated type renders its `attachments[]` binaries as
179
195
  * strings, so the serializer appends the files itself.
180
196
  */
181
- createMessage(accountId: number, conversationId: number, message: NewMessage): Promise<void>;
197
+ createMessage(accountId: number, conversationId: number, message: NewMessage): Promise<ChatwootMessage | undefined>;
182
198
  };
199
+ /** A bot (or another explicit non-user assignee) must never be matched to a numeric user id. */
200
+ export declare function personAssignee(conversation: ChatwootConversation): {
201
+ id?: number;
202
+ access_token?: string;
203
+ account_id?: number;
204
+ available_name?: string;
205
+ avatar_url?: string;
206
+ confirmed?: boolean;
207
+ display_name?: string | null;
208
+ message_signature?: string | null;
209
+ email?: string;
210
+ hmac_identifier?: string;
211
+ inviter_id?: number | null;
212
+ name?: string;
213
+ provider?: string;
214
+ pubsub_token?: string;
215
+ role?: "agent" | "administrator";
216
+ ui_settings?: Record<string, unknown>;
217
+ uid?: string;
218
+ type?: string | null;
219
+ custom_attributes?: Record<string, unknown>;
220
+ accounts?: {
221
+ id?: number;
222
+ name?: string;
223
+ status?: string;
224
+ active_at?: string | null;
225
+ role?: "administrator" | "agent";
226
+ permissions?: string[];
227
+ availability?: string;
228
+ availability_status?: string;
229
+ auto_offline?: boolean;
230
+ custom_role_id?: number | null;
231
+ custom_role?: Record<string, unknown> | null;
232
+ }[];
233
+ } | undefined;
234
+ /** An actual public answer; Chatwoot templates include greetings and out-of-office notices. */
235
+ export declare function isAnsweringReply(message: ChatwootMessage): boolean;
183
236
  export declare function toRelayConversation(conversationId: number, conversation: ChatwootConversation): RelayConversation;
184
237
  export declare function toRelayMessage(message: ChatwootMessage, context: {
185
238
  account: {
@@ -0,0 +1,4 @@
1
+ /** Finish or cancel every independent read before the caller can yield to another job. */
2
+ export declare function parallel<T extends readonly unknown[]>(...reads: {
3
+ [K in keyof T]: Promise<T[K]>;
4
+ }): Promise<T>;
@@ -0,0 +1,6 @@
1
+ /** Bounds non-fetch dependencies too, including an RPC whose enqueue outcome can be unknown. */
2
+ export declare function within<T>(pending: Promise<T>, signal: AbortSignal): Promise<T>;
3
+ export declare class JobDeadlineError extends Error {
4
+ readonly requestStarted: boolean;
5
+ constructor(requestStarted?: boolean);
6
+ }
@@ -0,0 +1,5 @@
1
+ /** Cache-aside state shared by upstream clients' durable cooldown stores. */
2
+ export interface RateLimitStore {
3
+ get(key: string): string | undefined;
4
+ set(key: string, value: string, ttlMs?: number): void;
5
+ }
@@ -1,27 +1,30 @@
1
1
  export declare function retryDelay(attempts: number): number;
2
- export interface Job {
2
+ export type Job = {
3
3
  key: string;
4
4
  payload: string;
5
5
  version: number;
6
6
  attempts: number;
7
7
  /** When the job was first queued (ms since the epoch). */
8
8
  createdAt: number;
9
- }
9
+ };
10
10
  export declare class QueueStore {
11
11
  protected readonly sql: SqlStorage;
12
12
  protected readonly now: () => number;
13
- constructor(sql: SqlStorage, now?: () => number);
13
+ private readonly atomic;
14
+ constructor(sql: SqlStorage, now?: () => number, atomic?: <T>(write: () => T) => T);
15
+ transaction<T>(write: () => T): T;
14
16
  migrate(): void;
15
17
  get(key: string): string | undefined;
16
18
  set(key: string, value: string, ttlMs?: number): void;
17
19
  delete(key: string): void;
18
- list(start: string, end: string): string[];
19
20
  /**
20
21
  * Adds a job, or marks an existing one as needing another run (its version changes). A job
21
22
  * that is backing off after failures keeps its schedule.
22
23
  */
23
24
  enqueue(key: string, priority: number, payload: string, notBefore?: number): void;
24
25
  nextDueJob(): Job | undefined;
26
+ /** A bounded due batch; independent scan and delivery work can select their own keys. */
27
+ nextDueJobs(limit: number, prefix?: string): Job[];
25
28
  /** Earliest due time. */
26
29
  nextWakeup(): number | undefined;
27
30
  /** Removes a finished job unless it was enqueued again while running. */
@@ -33,5 +36,9 @@ export declare class QueueStore {
33
36
  * budget, or Discord rate limits it.
34
37
  */
35
38
  deferJob(job: Job, delayMs?: number): void;
39
+ /** One short re-read for a racing status webhook, then wait for an event or a sweep. */
40
+ holdJob(job: Job): void;
41
+ /** Suspended conversation jobs need reconciliation even outside the ordinary activity window. */
42
+ wakeHeldJobs(): void;
36
43
  prune(): void;
37
44
  }
@@ -49,7 +49,9 @@ export interface RelayConversation {
49
49
  blocked?: boolean;
50
50
  avatarUrl?: string | null;
51
51
  };
52
+ /** Person only; agent bots are represented as unassigned. */
52
53
  assignee?: RelayAssignee | null;
54
+ assigneeType?: string | null;
53
55
  customAttributes: Record<string, unknown>;
54
56
  }
55
57
  export interface RelayMessage {
@@ -64,6 +66,8 @@ export interface RelayMessage {
64
66
  emailSubject?: string | null;
65
67
  /** An automatic email reply (out of office, for example): relayed without notifications. */
66
68
  autoReply?: boolean;
69
+ /** A qualifying public reply follows this message in Chatwoot. */
70
+ answered?: boolean;
67
71
  attachments: RelayAttachment[];
68
72
  /** A bot's options to pick, cards, or articles, with a link each when they have one. */
69
73
  items?: RelayItem[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatwoot-router",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "chatwoot",
@@ -1,14 +0,0 @@
1
- import { z } from "zod";
2
- import type { RoutingContext, RoutingStore } from "./routing.ts";
3
- declare const effectSchema: z.ZodObject<{
4
- inputId: z.ZodNumber;
5
- kind: z.ZodNullable<z.ZodString>;
6
- handled: z.ZodNumber;
7
- }, z.core.$strip>;
8
- export declare function actOnce(ctx: RoutingContext, key: string, effect: z.infer<typeof effectSchema>, record: "before" | "after", act: () => Promise<unknown>): Promise<void>;
9
- export declare function readEffects(store: RoutingStore, accountId: number, conversationId: number): {
10
- inputId: number;
11
- kind: string | null;
12
- handled: number;
13
- }[];
14
- export {};
@@ -1,6 +0,0 @@
1
- export declare const ROUTING_ATTRIBUTES: {
2
- readonly seen: "routing_seen";
3
- readonly handled: "routing_handled";
4
- readonly kind: "routing_kind";
5
- };
6
- export declare function messageWatermark(value: unknown): number;
@@ -1,11 +0,0 @@
1
- import { z } from "zod";
2
- import type { reconcileSchema } from "./config.ts";
3
- import type { QueueStore } from "./store.ts";
4
- declare const passSchema: z.ZodObject<{
5
- cutoff: z.ZodNumber;
6
- page: z.ZodNumber;
7
- startedAt: z.ZodNumber;
8
- }, z.core.$strip>;
9
- export declare function readSweepPass(store: QueueStore, accountId: number, reconcile: z.infer<typeof reconcileSchema>): z.infer<typeof passSchema>;
10
- export declare function saveSweepPass(store: QueueStore, accountId: number, pass: z.infer<typeof passSchema>, nextPage?: number): void;
11
- export {};