@crewhaus/channel-adapter-whatsapp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/channel-adapter-whatsapp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "WhatsApp Business Cloud API channel adapter: X-Hub-Signature-256 verification + text/media/interactive parsing + idempotent retry handling (Section 33)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/channel-adapter-whatsapp"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/channel-adapter-whatsapp#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.audio-001",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000600",
18
+ "type": "audio"
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.btn-001",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000200",
18
+ "type": "interactive",
19
+ "interactive": {
20
+ "type": "button_reply",
21
+ "button_reply": { "id": "approve-doc-7", "title": "Approve" }
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.img-002",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000500",
18
+ "type": "image",
19
+ "image": { "mime_type": "image/jpeg" }
20
+ }
21
+ ]
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.img-001",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000400",
18
+ "type": "image",
19
+ "image": { "caption": "look at this graph", "mime_type": "image/jpeg" }
20
+ }
21
+ ]
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.list-001",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000300",
18
+ "type": "interactive",
19
+ "interactive": {
20
+ "type": "list_reply",
21
+ "list_reply": { "id": "tier-pro", "title": "Pro", "description": "$49/mo" }
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "messages": [
12
+ {
13
+ "id": "wamid.missing-meta",
14
+ "from": "15554443333",
15
+ "timestamp": "1700000800",
16
+ "type": "text",
17
+ "text": { "body": "no metadata" }
18
+ }
19
+ ]
20
+ }
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "statuses": [{ "id": "wamid.status-001", "status": "delivered" }]
13
+ }
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": { "phone_number_id": "999000999000" },
12
+ "contacts": [{ "wa_id": "15554443333" }],
13
+ "messages": [
14
+ {
15
+ "id": "wamid.sticker-001",
16
+ "from": "15554443333",
17
+ "timestamp": "1700000700",
18
+ "type": "sticker"
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "object": "whatsapp_business_account",
3
+ "entry": [
4
+ {
5
+ "id": "100100100100",
6
+ "changes": [
7
+ {
8
+ "field": "messages",
9
+ "value": {
10
+ "messaging_product": "whatsapp",
11
+ "metadata": {
12
+ "display_phone_number": "15551112222",
13
+ "phone_number_id": "999000999000"
14
+ },
15
+ "contacts": [{ "profile": { "name": "Alice" }, "wa_id": "15554443333" }],
16
+ "messages": [
17
+ {
18
+ "id": "wamid.HBgNMTU1NTQ0NDMzMzMVAgASGBYzRUI",
19
+ "from": "15554443333",
20
+ "timestamp": "1700000123",
21
+ "type": "text",
22
+ "text": { "body": "hello bot" }
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "object": "page",
3
+ "entry": []
4
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ type ChannelAdapter,
7
+ type InboundEvent,
8
+ type ParsedInbound,
9
+ type RawRequest,
10
+ WhatsAppAdapterError,
11
+ createWhatsAppAdapter,
12
+ signWhatsAppBody,
13
+ verifyWhatsAppSignature,
14
+ } from "./index";
15
+
16
+ // `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
17
+ // source tree so both the src and dist test copies find them.
18
+ const FIXTURES_DIR = join(import.meta.dir.replace(/([/\\])dist$/, "$1src"), "fixtures");
19
+ const fixture = (name: string) => readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8");
20
+
21
+ const APP_SECRET = "wa-app-secret";
22
+ const PHONE_NUMBER_ID = "999000999000";
23
+ const ACCESS_TOKEN = "EAAxxxxxxxxx";
24
+
25
+ function signedHeaders(body: string): Headers {
26
+ const sig = signWhatsAppBody({ body, appSecret: APP_SECRET });
27
+ const h = new Headers();
28
+ h.set("X-Hub-Signature-256", sig);
29
+ return h;
30
+ }
31
+
32
+ function adapter(overrides: { fetch?: typeof fetch } = {}): ChannelAdapter {
33
+ return createWhatsAppAdapter(
34
+ { phoneNumberId: PHONE_NUMBER_ID, accessToken: ACCESS_TOKEN, appSecret: APP_SECRET },
35
+ {
36
+ apiBaseUrl: "https://test.graph.local",
37
+ ...(overrides.fetch ? { fetch: overrides.fetch } : {}),
38
+ },
39
+ );
40
+ }
41
+
42
+ describe("verifyWhatsAppSignature (T8)", () => {
43
+ test("matches a valid signature", () => {
44
+ const body = fixture("text_message");
45
+ const r = verifyWhatsAppSignature({
46
+ headers: signedHeaders(body),
47
+ body,
48
+ appSecret: APP_SECRET,
49
+ });
50
+ expect(r).toBe(true);
51
+ });
52
+
53
+ test("rejects tampered body", () => {
54
+ const body = fixture("text_message");
55
+ const headers = signedHeaders(body);
56
+ expect(
57
+ verifyWhatsAppSignature({ headers, body: `${body}--tampered`, appSecret: APP_SECRET }),
58
+ ).toBe(false);
59
+ });
60
+
61
+ test("rejects wrong secret", () => {
62
+ const body = fixture("text_message");
63
+ expect(
64
+ verifyWhatsAppSignature({
65
+ headers: signedHeaders(body),
66
+ body,
67
+ appSecret: "wrong-secret",
68
+ }),
69
+ ).toBe(false);
70
+ });
71
+
72
+ test("rejects missing X-Hub-Signature-256", () => {
73
+ expect(
74
+ verifyWhatsAppSignature({ headers: new Headers(), body: "{}", appSecret: APP_SECRET }),
75
+ ).toBe(false);
76
+ });
77
+
78
+ test("rejects malformed signature header (missing sha256= prefix)", () => {
79
+ const headers = new Headers({ "X-Hub-Signature-256": "deadbeef" });
80
+ expect(verifyWhatsAppSignature({ headers, body: "{}", appSecret: APP_SECRET })).toBe(false);
81
+ });
82
+
83
+ test("rejects malformed signature hex (wrong length)", () => {
84
+ const headers = new Headers({ "X-Hub-Signature-256": "sha256=deadbeef" });
85
+ expect(verifyWhatsAppSignature({ headers, body: "{}", appSecret: APP_SECRET })).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("parseInbound — fixtures (T2)", () => {
90
+ test("text_message → event with body verbatim", () => {
91
+ const a = adapter();
92
+ const body = fixture("text_message");
93
+ const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
94
+ ParsedInbound,
95
+ { kind: "event" }
96
+ >;
97
+ expect(r.kind).toBe("event");
98
+ expect(r.event.text).toBe("hello bot");
99
+ expect(r.event.userId).toBe("15554443333");
100
+ expect(r.event.channelId).toBe("15554443333");
101
+ expect(r.event.workspaceId).toBe(PHONE_NUMBER_ID);
102
+ expect(r.event.idempotencyKey).toBe("wamid.HBgNMTU1NTQ0NDMzMzMVAgASGBYzRUI");
103
+ });
104
+
105
+ test("button_reply → text [button:<id>]", () => {
106
+ const a = adapter();
107
+ const body = fixture("button_reply");
108
+ const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
109
+ ParsedInbound,
110
+ { kind: "event" }
111
+ >;
112
+ expect(r.event.text).toBe("[button:approve-doc-7]");
113
+ });
114
+
115
+ test("list_reply → text [list:<id>]", () => {
116
+ const a = adapter();
117
+ const body = fixture("list_reply");
118
+ const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
119
+ ParsedInbound,
120
+ { kind: "event" }
121
+ >;
122
+ expect(r.event.text).toBe("[list:tier-pro]");
123
+ });
124
+
125
+ test('image_with_caption → text "[image] <caption>"', () => {
126
+ const a = adapter();
127
+ const body = fixture("image_with_caption");
128
+ const r = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
129
+ ParsedInbound,
130
+ { kind: "event" }
131
+ >;
132
+ expect(r.event.text).toBe("[image] look at this graph");
133
+ });
134
+
135
+ test("image_no_caption → skip (no actionable text)", () => {
136
+ const a = adapter();
137
+ const body = fixture("image_no_caption");
138
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
139
+ });
140
+
141
+ test("audio → skip", () => {
142
+ const a = adapter();
143
+ const body = fixture("audio_message");
144
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
145
+ });
146
+
147
+ test("status_only (delivery receipt) → skip", () => {
148
+ const a = adapter();
149
+ const body = fixture("status_only");
150
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
151
+ });
152
+
153
+ test("sticker → skip", () => {
154
+ const a = adapter();
155
+ const body = fixture("sticker");
156
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
157
+ });
158
+
159
+ test("wrong_object (Facebook page) → skip", () => {
160
+ const a = adapter();
161
+ const body = fixture("wrong_object");
162
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
163
+ });
164
+
165
+ test("missing_metadata → skip", () => {
166
+ const a = adapter();
167
+ const body = fixture("missing_metadata");
168
+ expect(a.parseInbound({ headers: signedHeaders(body), body }).kind).toBe("skip");
169
+ });
170
+
171
+ test("malformed JSON → skip", () => {
172
+ const a = adapter();
173
+ expect(a.parseInbound({ headers: new Headers(), body: "{not json" }).kind).toBe("skip");
174
+ });
175
+
176
+ test("idempotencyKey == messages[].id (gateway dedups across redelivery)", () => {
177
+ const a = adapter();
178
+ const body = fixture("text_message");
179
+ const r1 = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
180
+ ParsedInbound,
181
+ { kind: "event" }
182
+ >;
183
+ const r2 = a.parseInbound({ headers: signedHeaders(body), body }) as Extract<
184
+ ParsedInbound,
185
+ { kind: "event" }
186
+ >;
187
+ expect(r1.event.idempotencyKey).toBe(r2.event.idempotencyKey);
188
+ });
189
+ });
190
+
191
+ describe("sendReply / setTyping (T3)", () => {
192
+ function captureFetch() {
193
+ const calls: Array<{ url: string; init: RequestInit }> = [];
194
+ const f = (async (input: string | Request | URL, init?: RequestInit) => {
195
+ calls.push({ url: String(input), init: init ?? {} });
196
+ return new Response(
197
+ JSON.stringify({ messaging_product: "whatsapp", messages: [{ id: "wamid.xx" }] }),
198
+ {
199
+ status: 200,
200
+ headers: { "content-type": "application/json" },
201
+ },
202
+ );
203
+ }) as unknown as typeof fetch;
204
+ return { calls, fetch: f };
205
+ }
206
+
207
+ const event: InboundEvent = {
208
+ idempotencyKey: "wamid.xx",
209
+ workspaceId: PHONE_NUMBER_ID,
210
+ channelId: "15554443333",
211
+ userId: "15554443333",
212
+ ts: "1700000123",
213
+ text: "hello",
214
+ subtype: "message",
215
+ };
216
+
217
+ test("sendReply POSTs /v22.0/<phoneId>/messages with text body", async () => {
218
+ const { calls, fetch: f } = captureFetch();
219
+ const a = adapter({ fetch: f });
220
+ await a.sendReply({ event, text: "hi back" });
221
+ expect(calls.length).toBe(1);
222
+ expect(calls[0]?.url).toBe(`https://test.graph.local/v22.0/${PHONE_NUMBER_ID}/messages`);
223
+ const body = JSON.parse(String(calls[0]?.init.body));
224
+ expect(body.messaging_product).toBe("whatsapp");
225
+ expect(body.to).toBe("15554443333");
226
+ expect(body.type).toBe("text");
227
+ expect(body.text.body).toBe("hi back");
228
+ const auth = (calls[0]?.init.headers as Record<string, string> | undefined)?.["Authorization"];
229
+ expect(auth).toBe(`Bearer ${ACCESS_TOKEN}`);
230
+ });
231
+
232
+ test("sendReply throws on HTTP error", async () => {
233
+ const f = (async () =>
234
+ new Response("rate limited", {
235
+ status: 429,
236
+ statusText: "Too Many Requests",
237
+ })) as unknown as typeof fetch;
238
+ const a = adapter({ fetch: f });
239
+ await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(WhatsAppAdapterError);
240
+ });
241
+
242
+ test("sendReply throws on Meta-side error envelope", async () => {
243
+ const f = (async () =>
244
+ new Response(JSON.stringify({ error: { message: "Invalid phone number" } }), {
245
+ status: 200,
246
+ headers: { "content-type": "application/json" },
247
+ })) as unknown as typeof fetch;
248
+ const a = adapter({ fetch: f });
249
+ await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(/Invalid phone number/);
250
+ });
251
+
252
+ test("setTyping is a no-op (no public API) — returns without error", async () => {
253
+ const { calls, fetch: f } = captureFetch();
254
+ const a = adapter({ fetch: f });
255
+ await a.setTyping({ event });
256
+ expect(calls.length).toBe(0);
257
+ });
258
+ });
259
+
260
+ describe("createWhatsAppAdapter.verify()", () => {
261
+ test("forwards to verifyWhatsAppSignature", () => {
262
+ const a = adapter();
263
+ const body = fixture("text_message");
264
+ expect(a.verify({ headers: signedHeaders(body), body })).toBe(true);
265
+ expect(a.verify({ headers: new Headers(), body })).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe("WhatsAppAdapterError", () => {
270
+ test('is a CrewhausError with code "channel"', () => {
271
+ const e = new WhatsAppAdapterError("test");
272
+ expect(e).toBeInstanceOf(Error);
273
+ expect(e.code).toBe("channel");
274
+ expect(e.name).toBe("WhatsAppAdapterError");
275
+ });
276
+ });
package/src/index.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @crewhaus/channel-adapter-whatsapp — WhatsApp Business Cloud API
3
+ * channel adapter for the channel target (Section 33).
4
+ *
5
+ * WhatsApp's webhook delivery is aggressive — it redelivers on any
6
+ * non-200 response and on its own internal retry schedule. We surface
7
+ * `messages[].id` (Meta's per-message identifier) as the gateway
8
+ * dedup key so retries don't double-process.
9
+ *
10
+ * `parseInbound` handles text + image + audio + button-reply +
11
+ * list-reply payloads from the v22.0 Cloud API. `sendReply` POSTs to
12
+ * `/v22.0/{phoneNumberId}/messages` with the bot's access token.
13
+ * `setTyping` is a no-op — WhatsApp Business does not expose a typing
14
+ * indicator API for cloud-API-based bots.
15
+ *
16
+ * Per-user session keying: WhatsApp has no thread concept; the gateway
17
+ * keys sessions on the contact's phone number (`messages[].from`).
18
+ */
19
+ import { CrewhausError } from "@crewhaus/errors";
20
+ import { verifyWhatsAppSignature } from "./verify.js";
21
+
22
+ export { signWhatsAppBody, verifyWhatsAppSignature } from "./verify.js";
23
+
24
+ export class WhatsAppAdapterError extends CrewhausError {
25
+ override readonly name = "WhatsAppAdapterError";
26
+ constructor(message: string, cause?: unknown) {
27
+ super("channel", message, cause);
28
+ }
29
+ }
30
+
31
+ export type RawRequest = {
32
+ readonly headers: Headers;
33
+ readonly body: string;
34
+ };
35
+
36
+ export type InboundEvent = {
37
+ readonly idempotencyKey: string;
38
+ readonly workspaceId: string;
39
+ readonly channelId: string;
40
+ readonly userId: string;
41
+ readonly threadTs?: string;
42
+ readonly ts: string;
43
+ readonly text: string;
44
+ readonly subtype: "app_mention" | "message";
45
+ };
46
+
47
+ /**
48
+ * Three result shapes:
49
+ * - `{kind:"event", event}` — a real inbound message to route
50
+ * - `{kind:"verification", challenge}` — Meta's GET-based verification
51
+ * handshake (the gateway echoes back `hub.challenge`)
52
+ * - `{kind:"skip"}` — known-but-uninteresting payload (status update,
53
+ * non-text non-interactive media, etc.)
54
+ */
55
+ export type ParsedInbound =
56
+ | { readonly kind: "event"; readonly event: InboundEvent }
57
+ | { readonly kind: "skip" };
58
+
59
+ export interface ChannelAdapter {
60
+ readonly id: string;
61
+ verify(req: RawRequest): boolean;
62
+ parseInbound(req: RawRequest): ParsedInbound;
63
+ sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
64
+ setTyping(args: { event: InboundEvent }): Promise<void>;
65
+ }
66
+
67
+ export type WhatsAppAdapterConfig = {
68
+ /** Meta WhatsApp Cloud API phone number id (numeric, stringified). */
69
+ readonly phoneNumberId: string;
70
+ /** System-user access token authorising sends. */
71
+ readonly accessToken: string;
72
+ /** Meta app secret, used to verify X-Hub-Signature-256. */
73
+ readonly appSecret: string;
74
+ };
75
+
76
+ export type WhatsAppAdapterOptions = {
77
+ readonly apiBaseUrl?: string;
78
+ readonly fetch?: typeof fetch;
79
+ readonly apiVersion?: string;
80
+ };
81
+
82
+ const DEFAULT_API_BASE_URL = "https://graph.facebook.com";
83
+ const DEFAULT_API_VERSION = "v22.0";
84
+
85
+ export function createWhatsAppAdapter(
86
+ config: WhatsAppAdapterConfig,
87
+ opts: WhatsAppAdapterOptions = {},
88
+ ): ChannelAdapter {
89
+ const apiBaseUrl =
90
+ opts.apiBaseUrl ?? process.env["WHATSAPP_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
91
+ const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION;
92
+ const doFetch = opts.fetch ?? fetch;
93
+ const messagesUrl = `${apiBaseUrl}/${apiVersion}/${config.phoneNumberId}/messages`;
94
+
95
+ return {
96
+ id: "whatsapp",
97
+
98
+ verify(req: RawRequest): boolean {
99
+ return verifyWhatsAppSignature({
100
+ headers: req.headers,
101
+ body: req.body,
102
+ appSecret: config.appSecret,
103
+ });
104
+ },
105
+
106
+ parseInbound(req: RawRequest): ParsedInbound {
107
+ let payload: unknown;
108
+ try {
109
+ payload = JSON.parse(req.body);
110
+ } catch {
111
+ return { kind: "skip" };
112
+ }
113
+ if (typeof payload !== "object" || payload === null) return { kind: "skip" };
114
+ const p = payload as WhatsAppWebhookPayload;
115
+ if (p.object !== "whatsapp_business_account") return { kind: "skip" };
116
+
117
+ // entry[0].changes[0].value contains the message envelope.
118
+ const change = p.entry?.[0]?.changes?.[0];
119
+ if (!change || change.field !== "messages") return { kind: "skip" };
120
+ const value = change.value;
121
+ if (!value) return { kind: "skip" };
122
+
123
+ const messages = value.messages ?? [];
124
+ // Status updates (delivered/read/failed) come as `value.statuses` —
125
+ // we skip those; the gateway acks 200 and moves on.
126
+ if (messages.length === 0) return { kind: "skip" };
127
+
128
+ const msg = messages[0];
129
+ if (!msg || typeof msg.id !== "string" || typeof msg.from !== "string") {
130
+ return { kind: "skip" };
131
+ }
132
+ const phoneNumberId = value.metadata?.phone_number_id ?? "";
133
+ if (!phoneNumberId) return { kind: "skip" };
134
+
135
+ const text = renderInboundText(msg);
136
+ if (text === undefined) return { kind: "skip" };
137
+
138
+ const event: InboundEvent = {
139
+ idempotencyKey: msg.id,
140
+ workspaceId: phoneNumberId,
141
+ channelId: msg.from,
142
+ userId: msg.from,
143
+ ts: msg.timestamp ?? "",
144
+ text,
145
+ subtype: "message",
146
+ };
147
+ return { kind: "event", event };
148
+ },
149
+
150
+ async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
151
+ const body = {
152
+ messaging_product: "whatsapp",
153
+ recipient_type: "individual",
154
+ to: args.event.userId,
155
+ type: "text",
156
+ text: { body: args.text },
157
+ };
158
+ const res = await doFetch(messagesUrl, {
159
+ method: "POST",
160
+ headers: {
161
+ "Content-Type": "application/json",
162
+ Authorization: `Bearer ${config.accessToken}`,
163
+ },
164
+ body: JSON.stringify(body),
165
+ });
166
+ if (!res.ok) {
167
+ throw new WhatsAppAdapterError(`messages POST failed: ${res.status} ${res.statusText}`);
168
+ }
169
+ const ct = res.headers.get("content-type") ?? "";
170
+ if (ct.includes("application/json")) {
171
+ const json = (await res.json()) as { error?: { message?: string } };
172
+ if (json.error) {
173
+ throw new WhatsAppAdapterError(`messages POST error: ${json.error.message ?? "unknown"}`);
174
+ }
175
+ }
176
+ },
177
+
178
+ async setTyping(_args: { event: InboundEvent }): Promise<void> {
179
+ // No public typing-indicator API for Cloud-API-based WhatsApp bots.
180
+ },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Reduce a WhatsApp inbound message to a single text body for the agent.
186
+ * Returns undefined for unsupported types (audio without transcription,
187
+ * image without caption, document, sticker, etc.) — the gateway then
188
+ * skips.
189
+ */
190
+ function renderInboundText(msg: WhatsAppMessage): string | undefined {
191
+ switch (msg.type) {
192
+ case "text":
193
+ return msg.text?.body ?? "";
194
+ case "interactive": {
195
+ const ir = msg.interactive;
196
+ if (ir?.type === "button_reply") return `[button:${ir.button_reply?.id ?? ""}]`;
197
+ if (ir?.type === "list_reply") return `[list:${ir.list_reply?.id ?? ""}]`;
198
+ return undefined;
199
+ }
200
+ case "image":
201
+ return msg.image?.caption !== undefined ? `[image] ${msg.image.caption}` : undefined;
202
+ case "audio":
203
+ return undefined;
204
+ default:
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ // ─── Minimal Cloud API types (no SDK) ───────────────────────────────────────
210
+
211
+ export type WhatsAppWebhookPayload = {
212
+ readonly object: string;
213
+ readonly entry?: ReadonlyArray<WhatsAppEntry>;
214
+ };
215
+
216
+ export type WhatsAppEntry = {
217
+ readonly id?: string;
218
+ readonly changes?: ReadonlyArray<WhatsAppChange>;
219
+ };
220
+
221
+ export type WhatsAppChange = {
222
+ readonly field?: string;
223
+ readonly value?: WhatsAppChangeValue;
224
+ };
225
+
226
+ export type WhatsAppChangeValue = {
227
+ readonly messaging_product?: string;
228
+ readonly metadata?: { readonly display_phone_number?: string; readonly phone_number_id?: string };
229
+ readonly contacts?: ReadonlyArray<{
230
+ readonly profile?: { readonly name?: string };
231
+ readonly wa_id?: string;
232
+ }>;
233
+ readonly messages?: ReadonlyArray<WhatsAppMessage>;
234
+ readonly statuses?: ReadonlyArray<{ readonly id?: string; readonly status?: string }>;
235
+ };
236
+
237
+ export type WhatsAppMessage = {
238
+ readonly id: string;
239
+ readonly from: string;
240
+ readonly timestamp?: string;
241
+ readonly type: "text" | "image" | "audio" | "interactive" | "video" | "document" | "sticker";
242
+ readonly text?: { readonly body?: string };
243
+ readonly image?: { readonly caption?: string; readonly mime_type?: string };
244
+ readonly interactive?: WhatsAppInteractive;
245
+ };
246
+
247
+ export type WhatsAppInteractive = {
248
+ readonly type?: "button_reply" | "list_reply";
249
+ readonly button_reply?: { readonly id?: string; readonly title?: string };
250
+ readonly list_reply?: {
251
+ readonly id?: string;
252
+ readonly title?: string;
253
+ readonly description?: string;
254
+ };
255
+ };
package/src/verify.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * WhatsApp Business Cloud API webhook signature verification per
5
+ * https://developers.facebook.com/docs/graph-api/webhooks/getting-started.
6
+ *
7
+ * Meta signs webhooks with `X-Hub-Signature-256: sha256=<hex>` where the
8
+ * MAC is HMAC-SHA256 of the raw request body using the app secret as key.
9
+ *
10
+ * Returns false on missing header, malformed format, mismatched signature.
11
+ */
12
+ export type WhatsAppVerifyArgs = {
13
+ readonly headers: Headers;
14
+ readonly body: string;
15
+ readonly appSecret: string;
16
+ };
17
+
18
+ export function verifyWhatsAppSignature(args: WhatsAppVerifyArgs): boolean {
19
+ const supplied = args.headers.get("x-hub-signature-256");
20
+ if (!supplied) return false;
21
+ if (!supplied.startsWith("sha256=")) return false;
22
+ const sigHex = supplied.slice("sha256=".length);
23
+ if (!/^[0-9a-f]{64}$/i.test(sigHex)) return false;
24
+
25
+ const expected = createHmac("sha256", args.appSecret).update(args.body).digest("hex");
26
+ const expectedBuf = Buffer.from(expected, "utf8");
27
+ const actualBuf = Buffer.from(sigHex.toLowerCase(), "utf8");
28
+ if (expectedBuf.length !== actualBuf.length) return false;
29
+ return timingSafeEqual(expectedBuf, actualBuf);
30
+ }
31
+
32
+ /**
33
+ * Sign a body — used by smoke tests and the in-repo integration test to
34
+ * construct fixtures the daemon will accept. Production code should NEVER
35
+ * call this — Meta signs requests, not us.
36
+ */
37
+ export function signWhatsAppBody(args: { body: string; appSecret: string }): string {
38
+ return `sha256=${createHmac("sha256", args.appSecret).update(args.body).digest("hex")}`;
39
+ }