@openclaw/crabline 0.1.8 → 0.1.10

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.
Files changed (190) hide show
  1. package/README.md +52 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +4 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +38 -0
  34. package/dist/src/openclaw/artifact-generation.js +314 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +26 -0
  54. package/dist/src/openclaw/private-file.js +411 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +23 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +949 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +195 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +539 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.d.ts +2 -0
  141. package/dist/src/servers/matrix.js +466 -52
  142. package/dist/src/servers/matrix.js.map +1 -1
  143. package/dist/src/servers/mattermost.d.ts +7 -0
  144. package/dist/src/servers/mattermost.js +307 -59
  145. package/dist/src/servers/mattermost.js.map +1 -1
  146. package/dist/src/servers/pending-events.d.ts +2 -0
  147. package/dist/src/servers/pending-events.js +11 -0
  148. package/dist/src/servers/pending-events.js.map +1 -0
  149. package/dist/src/servers/recorder.d.ts +12 -0
  150. package/dist/src/servers/recorder.js +51 -0
  151. package/dist/src/servers/recorder.js.map +1 -0
  152. package/dist/src/servers/signal.d.ts +4 -0
  153. package/dist/src/servers/signal.js +445 -57
  154. package/dist/src/servers/signal.js.map +1 -1
  155. package/dist/src/servers/slack.d.ts +7 -0
  156. package/dist/src/servers/slack.js +516 -50
  157. package/dist/src/servers/slack.js.map +1 -1
  158. package/dist/src/servers/telegram.d.ts +113 -0
  159. package/dist/src/servers/telegram.js +814 -155
  160. package/dist/src/servers/telegram.js.map +1 -1
  161. package/dist/src/servers/webhook-target.d.ts +40 -0
  162. package/dist/src/servers/webhook-target.js +349 -0
  163. package/dist/src/servers/webhook-target.js.map +1 -0
  164. package/dist/src/servers/websocket.d.ts +2 -0
  165. package/dist/src/servers/websocket.js +29 -0
  166. package/dist/src/servers/websocket.js.map +1 -0
  167. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  168. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  169. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  170. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  171. package/dist/src/servers/whatsapp-jid.js +32 -0
  172. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  174. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  175. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  177. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  178. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  179. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  180. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  181. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  182. package/dist/src/servers/whatsapp.d.ts +14 -7
  183. package/dist/src/servers/whatsapp.js +272 -110
  184. package/dist/src/servers/whatsapp.js.map +1 -1
  185. package/dist/src/servers/zalo.d.ts +16 -0
  186. package/dist/src/servers/zalo.js +524 -91
  187. package/dist/src/servers/zalo.js.map +1 -1
  188. package/docs/channel-setup.md +197 -19
  189. package/fixtures/examples/crabline.example.yaml +10 -0
  190. package/package.json +20 -15
@@ -1,31 +1,130 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
1
2
  import path from "node:path";
2
- import { CrablineError } from "../../core/errors.js";
3
+ import { CrablineError, ensureErrorMessage } from "../../core/errors.js";
4
+ import { matchesInbound } from "../../core/matcher.js";
3
5
  import { LocalMockProviderAdapter } from "../local-mock.js";
4
- import { authorFromBotFlag, createNativeTargetCodec, genericMockPayloadWithNativeThread, isRecord, optionalRecord, optionalString, requireNativeInboundId, } from "./native-local-mock.js";
5
- const WHATSAPP_WA_ID_RULE = {
6
- example: "15551234567",
7
- name: "WhatsApp wa_id",
8
- pattern: /^\d{7,15}$/u,
6
+ import { appendRecordedInboundBatch, cloneRecordedInboundCursor, createRecordedInboundCursor, waitForRecordedInbound, watchRecordedInbound, } from "../recorder.js";
7
+ import { getBuiltinTargetCodec, WHATSAPP_WA_ID_RULE } from "../target-normalizers.js";
8
+ import { startWebhookServer } from "../webhook-server.js";
9
+ import { authorFromBotFlag, genericMockPayloadWithNativeThread, isRecord, normalizeAuthor, optionalRecord, optionalString, requireNativeInboundId, } from "./native-local-mock.js";
10
+ const DEFAULT_WHATSAPP_WEBHOOK = {
11
+ host: "127.0.0.1",
12
+ path: "/whatsapp/webhook",
13
+ port: 8789,
9
14
  };
15
+ const MAX_WAIT_CURSORS = 64;
16
+ const MAX_WHATSAPP_WEBHOOK_BODY_BYTES = 1024 * 1024;
17
+ const WHATSAPP_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
18
+ class WhatsAppWebhookBodyError extends Error {
19
+ status;
20
+ constructor(message, status) {
21
+ super(message);
22
+ this.status = status;
23
+ }
24
+ }
25
+ async function readWhatsAppWebhookBody(request, lifecycleSignal) {
26
+ if (!request.body) {
27
+ return "";
28
+ }
29
+ const timeoutController = new AbortController();
30
+ const timeout = setTimeout(() => {
31
+ timeoutController.abort(new WhatsAppWebhookBodyError("webhook request body timed out", 408));
32
+ }, WHATSAPP_WEBHOOK_BODY_TIMEOUT_MS);
33
+ timeout.unref();
34
+ const signal = AbortSignal.any([lifecycleSignal, request.signal, timeoutController.signal]);
35
+ const reader = request.body.getReader();
36
+ const chunks = [];
37
+ let bodyBytes = 0;
38
+ let completed = false;
39
+ let failure;
40
+ try {
41
+ while (true) {
42
+ const result = await new Promise((resolve, reject) => {
43
+ if (signal.aborted) {
44
+ reject(signal.reason);
45
+ return;
46
+ }
47
+ const abort = () => reject(signal.reason);
48
+ signal.addEventListener("abort", abort, { once: true });
49
+ void reader.read().then((value) => {
50
+ signal.removeEventListener("abort", abort);
51
+ resolve(value);
52
+ }, (error) => {
53
+ signal.removeEventListener("abort", abort);
54
+ reject(error);
55
+ });
56
+ });
57
+ if (result.done) {
58
+ completed = true;
59
+ return Buffer.concat(chunks).toString("utf8");
60
+ }
61
+ const chunk = Buffer.from(result.value);
62
+ bodyBytes += chunk.length;
63
+ if (bodyBytes > MAX_WHATSAPP_WEBHOOK_BODY_BYTES) {
64
+ throw new WhatsAppWebhookBodyError("request body too large", 413);
65
+ }
66
+ chunks.push(chunk);
67
+ }
68
+ }
69
+ catch (error) {
70
+ failure = error;
71
+ throw error;
72
+ }
73
+ finally {
74
+ clearTimeout(timeout);
75
+ if (!completed) {
76
+ void reader.cancel(failure ?? signal.reason).catch(() => undefined);
77
+ }
78
+ else {
79
+ reader.releaseLock();
80
+ }
81
+ }
82
+ }
10
83
  export function resolveWhatsAppAdapterConfig(config, env = process.env) {
84
+ const appSecret = config.whatsapp?.appSecret ?? env.WHATSAPP_APP_SECRET;
85
+ const verifyToken = config.whatsapp?.verifyToken ?? env.WHATSAPP_VERIFY_TOKEN;
86
+ if (!appSecret || !verifyToken) {
87
+ throw new CrablineError("WhatsApp webhook operation requires appSecret and verifyToken configuration.", { kind: "config" });
88
+ }
11
89
  return {
12
90
  accessToken: config.whatsapp?.accessToken ?? env.WHATSAPP_ACCESS_TOKEN ?? "local-mock-token",
13
- appSecret: config.whatsapp?.appSecret ?? env.WHATSAPP_APP_SECRET ?? "local-mock-secret",
91
+ appSecret,
14
92
  phoneNumberId: config.whatsapp?.phoneNumberId ?? env.WHATSAPP_PHONE_NUMBER_ID ?? "local-mock-phone",
15
- verifyToken: config.whatsapp?.verifyToken ?? env.WHATSAPP_VERIFY_TOKEN ?? "local-mock-verify",
93
+ verifyToken,
16
94
  };
17
95
  }
96
+ function pruneInactiveWaitCursors(cursors) {
97
+ for (const [key, state] of cursors) {
98
+ if (cursors.size <= MAX_WAIT_CURSORS) {
99
+ return;
100
+ }
101
+ if (state.active === 0) {
102
+ cursors.delete(key);
103
+ }
104
+ }
105
+ }
18
106
  export class WhatsAppProviderAdapter extends LocalMockProviderAdapter {
107
+ #config;
108
+ #lifecycleAbort = new AbortController();
109
+ #publicUrl;
110
+ #recorderPath;
111
+ #webhook;
112
+ #cleanedUp = false;
113
+ #cleanupBegun = false;
114
+ #cleanupPromise = null;
115
+ #inFlightSends = new Set();
116
+ #inFlightWebhookRequests = new Set();
117
+ #waitCursors = new Map();
118
+ #server = null;
119
+ #serverClosing = null;
120
+ #serverStarting = null;
19
121
  constructor(id, config, _userName, _runtime) {
20
122
  super({
21
- codec: createNativeTargetCodec({
22
- channel: WHATSAPP_WA_ID_RULE,
23
- channelLabel: "WhatsApp wa_id",
24
- }),
123
+ codec: getBuiltinTargetCodec("whatsapp"),
25
124
  config,
26
125
  id,
27
126
  options: {
28
- defaultWebhook: { host: "127.0.0.1", path: "/whatsapp/webhook", port: 8789 },
127
+ defaultWebhook: DEFAULT_WHATSAPP_WEBHOOK,
29
128
  endpointLabel: "webhook endpoint",
30
129
  normalizeWebhookPayload: normalizeWhatsAppWebhookPayload,
31
130
  platform: "whatsapp",
@@ -36,13 +135,303 @@ export class WhatsAppProviderAdapter extends LocalMockProviderAdapter {
36
135
  webhook: config.whatsapp?.webhook,
37
136
  },
38
137
  });
138
+ this.#config = config;
139
+ this.#publicUrl = config.whatsapp?.webhook.publicUrl;
140
+ this.#recorderPath = config.whatsapp?.recorder.path
141
+ ? path.resolve(config.whatsapp.recorder.path)
142
+ : path.resolve(".crabline", "recorders", `${id}.jsonl`);
143
+ this.#webhook = config.whatsapp?.webhook;
144
+ }
145
+ async probe(context) {
146
+ context.signal?.throwIfAborted();
147
+ const server = await this.#ensureWebhookServer();
148
+ context.signal?.throwIfAborted();
149
+ const target = this.normalizeTarget(context.fixture.target);
150
+ const details = [
151
+ "whatsapp local mock ready",
152
+ `recorder path ${this.#recorderPath}`,
153
+ `webhook endpoint ${server.endpointUrl}`,
154
+ ];
155
+ if (this.#publicUrl) {
156
+ details.push(`public webhook ${this.#publicUrl}`);
157
+ }
158
+ if (target.threadId) {
159
+ details.push(`thread reachable ${target.threadId}`);
160
+ }
161
+ else if (target.channelId) {
162
+ details.push(`channel reachable ${target.channelId}`);
163
+ }
164
+ else {
165
+ details.push(`dm reachable ${target.id}`);
166
+ }
167
+ return { details, healthy: true };
168
+ }
169
+ async send(context) {
170
+ if (this.#cleanupBegun || this.#cleanedUp) {
171
+ throw this.#cleanedUpError();
172
+ }
173
+ const sending = super.send(context);
174
+ this.#inFlightSends.add(sending);
175
+ try {
176
+ return await sending;
177
+ }
178
+ finally {
179
+ this.#inFlightSends.delete(sending);
180
+ }
181
+ }
182
+ async waitForInbound(context) {
183
+ await this.#ensureWebhookServer();
184
+ const target = this.normalizeTarget(context.fixture.target);
185
+ const channelId = context.threadId ?? target.threadId ?? target.channelId ?? target.id;
186
+ const cursorKey = JSON.stringify([
187
+ context.nonce,
188
+ context.since,
189
+ channelId,
190
+ context.fixture.inboundMatch,
191
+ ]);
192
+ const cursorState = this.#waitCursors.get(cursorKey) ?? { active: 0 };
193
+ const cursor = cursorState.cursor
194
+ ? cloneRecordedInboundCursor(cursorState.cursor)
195
+ : createRecordedInboundCursor();
196
+ cursorState.active++;
197
+ this.#waitCursors.delete(cursorKey);
198
+ this.#waitCursors.set(cursorKey, cursorState);
199
+ pruneInactiveWaitCursors(this.#waitCursors);
200
+ try {
201
+ const event = await waitForRecordedInbound({
202
+ cursor,
203
+ filePath: this.#recorderPath,
204
+ matches: (candidate) => candidate.provider === this.id &&
205
+ !isOutboundRecord(candidate) &&
206
+ isAddressInChannel(candidate.threadId, channelId) &&
207
+ matchesInbound(candidate, context.fixture.inboundMatch, context.nonce),
208
+ since: context.since,
209
+ signal: this.#operationSignal(context.signal),
210
+ timeoutMs: context.timeoutMs,
211
+ });
212
+ if (event && this.#waitCursors.get(cursorKey) === cursorState) {
213
+ cursorState.cursor = cursor;
214
+ }
215
+ return event;
216
+ }
217
+ finally {
218
+ cursorState.active--;
219
+ if (cursorState.active === 0 &&
220
+ !cursorState.cursor &&
221
+ this.#waitCursors.get(cursorKey) === cursorState) {
222
+ this.#waitCursors.delete(cursorKey);
223
+ }
224
+ }
225
+ }
226
+ async *watch(context) {
227
+ await this.#ensureWebhookServer();
228
+ const target = this.normalizeTarget(context.fixture.target);
229
+ for await (const event of watchRecordedInbound({
230
+ filePath: this.#recorderPath,
231
+ matches: (entry) => entry.provider === this.id &&
232
+ !isOutboundRecord(entry) &&
233
+ isAddressInChannel(entry.threadId, target.threadId ?? target.channelId ?? target.id),
234
+ signal: this.#operationSignal(context.signal),
235
+ since: context.since,
236
+ })) {
237
+ yield event;
238
+ }
239
+ }
240
+ beginCleanup() {
241
+ if (this.#cleanupBegun) {
242
+ return;
243
+ }
244
+ this.#cleanupBegun = true;
245
+ this.#lifecycleAbort.abort(this.#cleanedUpError());
246
+ this.#serverClosing = this.#closeWebhookServer();
247
+ void this.#serverClosing.catch(() => undefined);
248
+ }
249
+ async cleanup() {
250
+ this.beginCleanup();
251
+ this.#cleanedUp = true;
252
+ this.#waitCursors.clear();
253
+ this.#cleanupPromise ??= (async () => {
254
+ await Promise.allSettled([...this.#inFlightSends, ...this.#inFlightWebhookRequests]);
255
+ const errors = [];
256
+ try {
257
+ await this.#serverClosing;
258
+ }
259
+ catch (error) {
260
+ errors.push(error);
261
+ }
262
+ try {
263
+ await super.cleanup();
264
+ }
265
+ catch (error) {
266
+ errors.push(error);
267
+ }
268
+ if (errors.length === 1) {
269
+ throw errors[0];
270
+ }
271
+ if (errors.length > 1) {
272
+ throw new AggregateError(errors, "WhatsApp cleanup failed");
273
+ }
274
+ })();
275
+ await this.#cleanupPromise;
276
+ }
277
+ #handleWebhookRequest(request, resolvedConfig) {
278
+ if (this.#cleanupBegun) {
279
+ return Promise.resolve(this.#cleanedUpResponse());
280
+ }
281
+ const handling = this.#handleWebhook(request, resolvedConfig);
282
+ this.#inFlightWebhookRequests.add(handling);
283
+ void handling.then(() => this.#inFlightWebhookRequests.delete(handling), () => this.#inFlightWebhookRequests.delete(handling));
284
+ return handling;
285
+ }
286
+ async #handleWebhook(request, resolvedConfig) {
287
+ if (request.method === "GET") {
288
+ const url = new URL(request.url);
289
+ const mode = url.searchParams.get("hub.mode");
290
+ const providedValue = url.searchParams.get("hub.verify_token");
291
+ const challenge = url.searchParams.get("hub.challenge");
292
+ if (mode === "subscribe" &&
293
+ providedValue === resolvedConfig.verifyToken &&
294
+ challenge !== null) {
295
+ return new Response(challenge, {
296
+ headers: { "content-type": "text/plain; charset=utf-8" },
297
+ status: 200,
298
+ });
299
+ }
300
+ return new Response("forbidden", { status: 403 });
301
+ }
302
+ let rawBody;
303
+ try {
304
+ rawBody = await readWhatsAppWebhookBody(request, this.#lifecycleAbort.signal);
305
+ }
306
+ catch (error) {
307
+ if (this.#cleanupBegun) {
308
+ return this.#cleanedUpResponse();
309
+ }
310
+ if (error instanceof WhatsAppWebhookBodyError) {
311
+ return new Response(error.message, { status: error.status });
312
+ }
313
+ return new Response("invalid request body", { status: 400 });
314
+ }
315
+ if (!hasValidWhatsAppSignature(rawBody, request.headers.get("x-hub-signature-256"), resolvedConfig.appSecret)) {
316
+ return new Response("invalid webhook signature", { status: 401 });
317
+ }
318
+ const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
319
+ if (mediaType !== "application/json") {
320
+ return new Response("expected application/json", { status: 415 });
321
+ }
322
+ let messages;
323
+ try {
324
+ messages = normalizeWhatsAppWebhookPayload(JSON.parse(rawBody), resolvedConfig.phoneNumberId);
325
+ }
326
+ catch (error) {
327
+ return new Response(ensureErrorMessage(error), { status: 400 });
328
+ }
329
+ const ids = [];
330
+ const records = [];
331
+ for (const message of messages) {
332
+ const id = message.message?.id ?? message.id ?? createMessageId();
333
+ const threadId = message.message?.threadId ?? message.threadId;
334
+ const text = message.message?.text ?? message.text;
335
+ if (!threadId || !text) {
336
+ return new Response("payload requires message.threadId and message.text", { status: 400 });
337
+ }
338
+ records.push({
339
+ author: authorFromPayload(message),
340
+ id,
341
+ provider: this.id,
342
+ raw: message.message?.raw ?? message.raw ?? message,
343
+ sentAt: message.message?.sentAt ?? message.sentAt ?? new Date().toISOString(),
344
+ text,
345
+ threadId,
346
+ });
347
+ ids.push(id);
348
+ }
349
+ await appendRecordedInboundBatch(this.#recorderPath, records);
350
+ return new Response(JSON.stringify(ids.length === 1 ? { id: ids[0], ok: true } : { ids, ok: true }), {
351
+ headers: { "content-type": "application/json" },
352
+ status: 200,
353
+ });
354
+ }
355
+ async #ensureWebhookServer() {
356
+ if (this.#cleanupBegun) {
357
+ throw this.#cleanedUpError();
358
+ }
359
+ if (this.#server) {
360
+ return this.#server;
361
+ }
362
+ if (this.#serverStarting) {
363
+ return await this.#serverStarting;
364
+ }
365
+ const resolvedConfig = resolveWhatsAppAdapterConfig(this.#config);
366
+ const host = this.#webhook?.host ?? DEFAULT_WHATSAPP_WEBHOOK.host;
367
+ const port = this.#webhook?.port ?? DEFAULT_WHATSAPP_WEBHOOK.port;
368
+ const webhookPath = this.#webhook?.path ?? DEFAULT_WHATSAPP_WEBHOOK.path;
369
+ const starting = (async () => {
370
+ let server;
371
+ try {
372
+ server = await startWebhookServer({
373
+ handle: (request) => this.#handleWebhookRequest(request, resolvedConfig),
374
+ host,
375
+ methods: ["GET", "POST"],
376
+ path: webhookPath,
377
+ port,
378
+ });
379
+ }
380
+ catch (error) {
381
+ throw new CrablineError(`whatsapp local mock webhook server failed: ${ensureErrorMessage(error)}`, { cause: error, kind: "connectivity" });
382
+ }
383
+ this.#server = server;
384
+ if (this.#cleanupBegun) {
385
+ throw this.#cleanedUpError();
386
+ }
387
+ return server;
388
+ })();
389
+ this.#serverStarting = starting;
390
+ try {
391
+ return await starting;
392
+ }
393
+ finally {
394
+ if (this.#serverStarting === starting) {
395
+ this.#serverStarting = null;
396
+ }
397
+ }
398
+ }
399
+ async #closeWebhookServer() {
400
+ const starting = this.#serverStarting;
401
+ if (starting) {
402
+ try {
403
+ await starting;
404
+ }
405
+ catch {
406
+ // A failed startup has no listener; a cleanup race publishes one below.
407
+ }
408
+ }
409
+ const server = this.#server;
410
+ if (!server) {
411
+ return;
412
+ }
413
+ if (this.#server === server) {
414
+ this.#server = null;
415
+ }
416
+ await server.close();
417
+ }
418
+ #cleanedUpError() {
419
+ return new CrablineError(`Provider "${this.id}" has been cleaned up.`, { kind: "config" });
420
+ }
421
+ #cleanedUpResponse() {
422
+ return new Response(`Provider "${this.id}" has been cleaned up.`, { status: 503 });
423
+ }
424
+ #operationSignal(signal) {
425
+ return signal
426
+ ? AbortSignal.any([signal, this.#lifecycleAbort.signal])
427
+ : this.#lifecycleAbort.signal;
39
428
  }
40
429
  }
41
- export function normalizeWhatsAppWebhookPayload(payload) {
430
+ export function normalizeWhatsAppWebhookPayload(payload, expectedPhoneNumberId) {
42
431
  if (!isRecord(payload)) {
43
432
  throw new CrablineError("WhatsApp webhook payload must be an object", { kind: "inbound" });
44
433
  }
45
- let message;
434
+ const normalized = [];
46
435
  if (Array.isArray(payload.entry)) {
47
436
  for (const entry of payload.entry) {
48
437
  if (!isRecord(entry) || !Array.isArray(entry.changes)) {
@@ -53,23 +442,101 @@ export function normalizeWhatsAppWebhookPayload(payload) {
53
442
  continue;
54
443
  }
55
444
  const value = optionalRecord(change, "value");
56
- const candidate = value && Array.isArray(value.messages) ? value.messages.find(isRecord) : undefined;
57
- if (candidate) {
58
- message = candidate;
59
- break;
445
+ if (!value || !Array.isArray(value.messages)) {
446
+ continue;
447
+ }
448
+ const metadata = optionalRecord(value, "metadata");
449
+ if (expectedPhoneNumberId !== undefined &&
450
+ optionalString(metadata ?? {}, "phone_number_id") !== expectedPhoneNumberId) {
451
+ continue;
452
+ }
453
+ for (const message of value.messages) {
454
+ if (!isRecord(message)) {
455
+ throw new CrablineError("WhatsApp webhook messages[] entries must be objects", {
456
+ kind: "inbound",
457
+ });
458
+ }
459
+ const normalizedMessage = normalizeWhatsAppMessage(message);
460
+ if (normalizedMessage) {
461
+ normalized.push(normalizedMessage);
462
+ }
60
463
  }
61
- }
62
- if (message) {
63
- break;
64
464
  }
65
465
  }
466
+ return normalized;
66
467
  }
67
- if (!message) {
68
- return genericMockPayloadWithNativeThread({
69
- channelRule: WHATSAPP_WA_ID_RULE,
70
- payload,
71
- threadRule: WHATSAPP_WA_ID_RULE,
72
- });
468
+ const fallback = genericMockPayloadWithNativeThread({
469
+ channelRule: WHATSAPP_WA_ID_RULE,
470
+ payload,
471
+ threadRule: WHATSAPP_WA_ID_RULE,
472
+ });
473
+ return [normalizeWhatsAppFallback(fallback, payload)];
474
+ }
475
+ function normalizeWhatsAppFallback(fallback, source) {
476
+ const normalized = {
477
+ raw: fallback.raw ?? fallback,
478
+ };
479
+ const author = normalizeAuthor(fallback.author);
480
+ if (author) {
481
+ normalized.author = author;
482
+ }
483
+ if (typeof fallback.authorIsBot === "boolean") {
484
+ normalized.authorIsBot = fallback.authorIsBot;
485
+ }
486
+ const id = optionalString(fallback, "id");
487
+ if (id) {
488
+ normalized.id = id;
489
+ }
490
+ const text = optionalString(fallback, "text");
491
+ if (text) {
492
+ normalized.text = text;
493
+ }
494
+ const threadId = optionalString(fallback, "threadId");
495
+ if (threadId) {
496
+ normalized.threadId = threadId;
497
+ }
498
+ const sentAt = optionalString(source, "sentAt");
499
+ if (sentAt) {
500
+ normalized.sentAt = whatsAppFallbackTimestampToIso(sentAt, "sentAt");
501
+ }
502
+ const message = optionalRecord(fallback, "message");
503
+ if (message) {
504
+ const sourceMessage = optionalRecord(source, "message");
505
+ const normalizedMessage = {};
506
+ const messageAuthor = normalizeAuthor(message.author);
507
+ if (messageAuthor) {
508
+ normalizedMessage.author = messageAuthor;
509
+ }
510
+ if (typeof message.authorIsBot === "boolean") {
511
+ normalizedMessage.authorIsBot = message.authorIsBot;
512
+ }
513
+ const messageId = optionalString(message, "id");
514
+ if (messageId) {
515
+ normalizedMessage.id = messageId;
516
+ }
517
+ if (message.raw !== undefined) {
518
+ normalizedMessage.raw = message.raw;
519
+ }
520
+ const messageSentAt = sourceMessage ? optionalString(sourceMessage, "sentAt") : undefined;
521
+ if (messageSentAt) {
522
+ normalizedMessage.sentAt = whatsAppFallbackTimestampToIso(messageSentAt, "message.sentAt");
523
+ }
524
+ const messageText = optionalString(message, "text");
525
+ if (messageText) {
526
+ normalizedMessage.text = messageText;
527
+ }
528
+ const messageThreadId = optionalString(message, "threadId");
529
+ if (messageThreadId) {
530
+ normalizedMessage.threadId = messageThreadId;
531
+ }
532
+ normalized.message = normalizedMessage;
533
+ }
534
+ return normalized;
535
+ }
536
+ function normalizeWhatsAppMessage(message) {
537
+ const messageType = optionalString(message, "type");
538
+ if (messageType && messageType !== "text") {
539
+ return null;
73
540
  }
74
541
  const text = optionalRecord(message, "text");
75
542
  const from = optionalString(message, "from");
@@ -79,12 +546,82 @@ export function normalizeWhatsAppWebhookPayload(payload) {
79
546
  kind: "inbound",
80
547
  });
81
548
  }
549
+ const messageId = optionalString(message, "id");
550
+ if (!messageId) {
551
+ throw new CrablineError("WhatsApp webhook payload requires messages[].id", {
552
+ kind: "inbound",
553
+ });
554
+ }
555
+ const timestamp = optionalString(message, "timestamp");
82
556
  return {
83
557
  author: authorFromBotFlag(false),
84
- ...(optionalString(message, "id") ? { id: optionalString(message, "id") } : {}),
85
- raw: payload,
558
+ id: messageId,
559
+ raw: message,
560
+ ...(timestamp ? { sentAt: whatsAppTimestampToIso(timestamp) } : {}),
86
561
  text: body,
87
562
  threadId: requireNativeInboundId(from, WHATSAPP_WA_ID_RULE, "WhatsApp messages[].from"),
88
563
  };
89
564
  }
565
+ function whatsAppTimestampToIso(timestamp) {
566
+ if (!/^\d+$/u.test(timestamp)) {
567
+ throw new CrablineError("WhatsApp messages[].timestamp must be Unix seconds", {
568
+ kind: "inbound",
569
+ });
570
+ }
571
+ const milliseconds = Number(timestamp) * 1000;
572
+ if (!Number.isSafeInteger(milliseconds)) {
573
+ throw new CrablineError("WhatsApp messages[].timestamp is out of range", { kind: "inbound" });
574
+ }
575
+ const date = new Date(milliseconds);
576
+ if (Number.isNaN(date.getTime())) {
577
+ throw new CrablineError("WhatsApp messages[].timestamp is invalid", { kind: "inbound" });
578
+ }
579
+ return date.toISOString();
580
+ }
581
+ function whatsAppFallbackTimestampToIso(timestamp, field) {
582
+ const date = new Date(timestamp);
583
+ if (Number.isNaN(date.getTime())) {
584
+ throw new CrablineError(`WhatsApp fallback ${field} must be a valid timestamp`, {
585
+ kind: "inbound",
586
+ });
587
+ }
588
+ return date.toISOString();
589
+ }
590
+ function authorFromPayload(payload) {
591
+ const explicit = payload.message?.author ?? payload.author;
592
+ if (explicit) {
593
+ return explicit;
594
+ }
595
+ return (payload.message?.authorIsBot ?? payload.authorIsBot ?? true) ? "assistant" : "user";
596
+ }
597
+ function createMessageId() {
598
+ return `whatsapp-mock-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
599
+ }
600
+ function hasValidWhatsAppSignature(rawBody, signatureHeader, signingKey) {
601
+ if (!signatureHeader?.startsWith("sha256=")) {
602
+ return false;
603
+ }
604
+ const signature = signatureHeader.slice("sha256=".length);
605
+ if (!/^[a-f0-9]{64}$/iu.test(signature)) {
606
+ return false;
607
+ }
608
+ const expected = createHmac("sha256", signingKey).update(rawBody).digest();
609
+ const actual = Buffer.from(signature, "hex");
610
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
611
+ }
612
+ function isAddressInChannel(threadId, channelId) {
613
+ if (!channelId) {
614
+ return true;
615
+ }
616
+ return threadId === channelId || threadId.startsWith(`${channelId}:`);
617
+ }
618
+ function isOutboundRecord(event) {
619
+ if (event.recordedDirection !== undefined) {
620
+ return event.recordedDirection === "outbound";
621
+ }
622
+ return (event.raw !== null &&
623
+ typeof event.raw === "object" &&
624
+ "direction" in event.raw &&
625
+ event.raw.direction === "outbound");
626
+ }
90
627
  //# sourceMappingURL=whatsapp.js.map