@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.
- package/README.md +52 -22
- package/assets/crabline-banner.svg +20 -0
- package/dist/src/bin/crabline.js.map +1 -1
- package/dist/src/cli/program.d.ts +25 -1
- package/dist/src/cli/program.js +579 -82
- package/dist/src/cli/program.js.map +1 -1
- package/dist/src/config/load.js +59 -6
- package/dist/src/config/load.js.map +1 -1
- package/dist/src/config/schema.d.ts +116 -92
- package/dist/src/config/schema.js +179 -73
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/core/errors.js +9 -2
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/exit-codes.js.map +1 -1
- package/dist/src/core/matcher.js +7 -6
- package/dist/src/core/matcher.js.map +1 -1
- package/dist/src/core/message-template.js.map +1 -1
- package/dist/src/core/nonces.d.ts +1 -0
- package/dist/src/core/nonces.js +4 -2
- package/dist/src/core/nonces.js.map +1 -1
- package/dist/src/core/reporters.d.ts +1 -0
- package/dist/src/core/reporters.js +42 -4
- package/dist/src/core/reporters.js.map +1 -1
- package/dist/src/core/run.d.ts +3 -0
- package/dist/src/core/run.js +415 -107
- package/dist/src/core/run.js.map +1 -1
- package/dist/src/core/safe-regex.d.ts +3 -0
- package/dist/src/core/safe-regex.js +21 -0
- package/dist/src/core/safe-regex.js.map +1 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/openclaw/artifact-generation.d.ts +38 -0
- package/dist/src/openclaw/artifact-generation.js +314 -0
- package/dist/src/openclaw/artifact-generation.js.map +1 -0
- package/dist/src/openclaw/bridges/matrix.js +97 -12
- package/dist/src/openclaw/bridges/matrix.js.map +1 -1
- package/dist/src/openclaw/bridges/mattermost.js +32 -10
- package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
- package/dist/src/openclaw/bridges/signal.js +55 -18
- package/dist/src/openclaw/bridges/signal.js.map +1 -1
- package/dist/src/openclaw/bridges/slack.js +85 -7
- package/dist/src/openclaw/bridges/slack.js.map +1 -1
- package/dist/src/openclaw/bridges/telegram.js +124 -30
- package/dist/src/openclaw/bridges/telegram.js.map +1 -1
- package/dist/src/openclaw/bridges/whatsapp.js +78 -17
- package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
- package/dist/src/openclaw/bridges/zalo.js +14 -5
- package/dist/src/openclaw/bridges/zalo.js.map +1 -1
- package/dist/src/openclaw/outbound-contract.d.ts +5 -0
- package/dist/src/openclaw/outbound-contract.js +40 -0
- package/dist/src/openclaw/outbound-contract.js.map +1 -0
- package/dist/src/openclaw/private-file.d.ts +26 -0
- package/dist/src/openclaw/private-file.js +411 -0
- package/dist/src/openclaw/private-file.js.map +1 -0
- package/dist/src/openclaw/shared.d.ts +23 -2
- package/dist/src/openclaw/shared.js +115 -18
- package/dist/src/openclaw/shared.js.map +1 -1
- package/dist/src/openclaw/smoke-lock.d.ts +63 -0
- package/dist/src/openclaw/smoke-lock.js +949 -0
- package/dist/src/openclaw/smoke-lock.js.map +1 -0
- package/dist/src/openclaw.d.ts +16 -8
- package/dist/src/openclaw.js +195 -52
- package/dist/src/openclaw.js.map +1 -1
- package/dist/src/providers/builtin/discord.d.ts +10 -1
- package/dist/src/providers/builtin/discord.js +144 -18
- package/dist/src/providers/builtin/discord.js.map +1 -1
- package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
- package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
- package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
- package/dist/src/providers/builtin/feishu.d.ts +19 -2
- package/dist/src/providers/builtin/feishu.js +307 -25
- package/dist/src/providers/builtin/feishu.js.map +1 -1
- package/dist/src/providers/builtin/googlechat.d.ts +20 -1
- package/dist/src/providers/builtin/googlechat.js +178 -19
- package/dist/src/providers/builtin/googlechat.js.map +1 -1
- package/dist/src/providers/builtin/imessage.d.ts +4 -0
- package/dist/src/providers/builtin/imessage.js +26 -11
- package/dist/src/providers/builtin/imessage.js.map +1 -1
- package/dist/src/providers/builtin/loopback.js +92 -30
- package/dist/src/providers/builtin/loopback.js.map +1 -1
- package/dist/src/providers/builtin/matrix.d.ts +11 -1
- package/dist/src/providers/builtin/matrix.js +53 -29
- package/dist/src/providers/builtin/matrix.js.map +1 -1
- package/dist/src/providers/builtin/mattermost.d.ts +10 -0
- package/dist/src/providers/builtin/mattermost.js +38 -15
- package/dist/src/providers/builtin/mattermost.js.map +1 -1
- package/dist/src/providers/builtin/msteams.d.ts +17 -1
- package/dist/src/providers/builtin/msteams.js +113 -14
- package/dist/src/providers/builtin/msteams.js.map +1 -1
- package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
- package/dist/src/providers/builtin/native-local-mock.js +12 -35
- package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
- package/dist/src/providers/builtin/script.d.ts +11 -15
- package/dist/src/providers/builtin/script.js +566 -104
- package/dist/src/providers/builtin/script.js.map +1 -1
- package/dist/src/providers/builtin/slack.d.ts +6 -0
- package/dist/src/providers/builtin/slack.js +99 -40
- package/dist/src/providers/builtin/slack.js.map +1 -1
- package/dist/src/providers/builtin/telegram.d.ts +6 -0
- package/dist/src/providers/builtin/telegram.js +64 -43
- package/dist/src/providers/builtin/telegram.js.map +1 -1
- package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
- package/dist/src/providers/builtin/whatsapp.js +567 -30
- package/dist/src/providers/builtin/whatsapp.js.map +1 -1
- package/dist/src/providers/builtin/zalo.js +30 -19
- package/dist/src/providers/builtin/zalo.js.map +1 -1
- package/dist/src/providers/catalog.d.ts +3 -3
- package/dist/src/providers/catalog.js +3 -3
- package/dist/src/providers/catalog.js.map +1 -1
- package/dist/src/providers/local-mock.d.ts +11 -2
- package/dist/src/providers/local-mock.js +322 -96
- package/dist/src/providers/local-mock.js.map +1 -1
- package/dist/src/providers/native-ids.d.ts +1 -0
- package/dist/src/providers/native-ids.js +6 -1
- package/dist/src/providers/native-ids.js.map +1 -1
- package/dist/src/providers/recorder.d.ts +30 -2
- package/dist/src/providers/recorder.js +539 -70
- package/dist/src/providers/recorder.js.map +1 -1
- package/dist/src/providers/registry.d.ts +25 -1
- package/dist/src/providers/registry.js +280 -40
- package/dist/src/providers/registry.js.map +1 -1
- package/dist/src/providers/signed-jwt.d.ts +29 -0
- package/dist/src/providers/signed-jwt.js +202 -0
- package/dist/src/providers/signed-jwt.js.map +1 -0
- package/dist/src/providers/slack-ids.js.map +1 -1
- package/dist/src/providers/target-normalizers.d.ts +26 -0
- package/dist/src/providers/target-normalizers.js +276 -0
- package/dist/src/providers/target-normalizers.js.map +1 -0
- package/dist/src/providers/types.d.ts +5 -0
- package/dist/src/providers/types.js.map +1 -1
- package/dist/src/providers/webhook-server.d.ts +2 -0
- package/dist/src/providers/webhook-server.js +72 -25
- package/dist/src/providers/webhook-server.js.map +1 -1
- package/dist/src/servers/http.d.ts +23 -3
- package/dist/src/servers/http.js +213 -20
- package/dist/src/servers/http.js.map +1 -1
- package/dist/src/servers/index.js +20 -21
- package/dist/src/servers/index.js.map +1 -1
- package/dist/src/servers/matrix.d.ts +2 -0
- package/dist/src/servers/matrix.js +466 -52
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +7 -0
- package/dist/src/servers/mattermost.js +307 -59
- package/dist/src/servers/mattermost.js.map +1 -1
- package/dist/src/servers/pending-events.d.ts +2 -0
- package/dist/src/servers/pending-events.js +11 -0
- package/dist/src/servers/pending-events.js.map +1 -0
- package/dist/src/servers/recorder.d.ts +12 -0
- package/dist/src/servers/recorder.js +51 -0
- package/dist/src/servers/recorder.js.map +1 -0
- package/dist/src/servers/signal.d.ts +4 -0
- package/dist/src/servers/signal.js +445 -57
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +7 -0
- package/dist/src/servers/slack.js +516 -50
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram.d.ts +113 -0
- package/dist/src/servers/telegram.js +814 -155
- package/dist/src/servers/telegram.js.map +1 -1
- package/dist/src/servers/webhook-target.d.ts +40 -0
- package/dist/src/servers/webhook-target.js +349 -0
- package/dist/src/servers/webhook-target.js.map +1 -0
- package/dist/src/servers/websocket.d.ts +2 -0
- package/dist/src/servers/websocket.js +29 -0
- package/dist/src/servers/websocket.js.map +1 -0
- package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
- package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
- package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
- package/dist/src/servers/whatsapp-jid.d.ts +5 -0
- package/dist/src/servers/whatsapp-jid.js +32 -0
- package/dist/src/servers/whatsapp-jid.js.map +1 -0
- package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
- package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
- package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
- package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
- package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
- package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
- package/dist/src/servers/whatsapp.d.ts +14 -7
- package/dist/src/servers/whatsapp.js +272 -110
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +16 -0
- package/dist/src/servers/zalo.js +524 -91
- package/dist/src/servers/zalo.js.map +1 -1
- package/docs/channel-setup.md +197 -19
- package/fixtures/examples/crabline.example.yaml +10 -0
- 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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
91
|
+
appSecret,
|
|
14
92
|
phoneNumberId: config.whatsapp?.phoneNumberId ?? env.WHATSAPP_PHONE_NUMBER_ID ?? "local-mock-phone",
|
|
15
|
-
verifyToken
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
85
|
-
raw:
|
|
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
|