@crewhaus/channel-adapter-whatsapp 0.1.4 → 0.1.5
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/dist/index.d.ts +147 -0
- package/dist/index.js +189 -0
- package/dist/verify.d.ts +28 -0
- package/dist/verify.js +86 -0
- package/package.json +9 -6
- package/src/fixtures/audio_message.json +0 -26
- package/src/fixtures/button_reply.json +0 -30
- package/src/fixtures/image_no_caption.json +0 -27
- package/src/fixtures/image_with_caption.json +0 -27
- package/src/fixtures/list_reply.json +0 -30
- package/src/fixtures/missing_metadata.json +0 -25
- package/src/fixtures/status_only.json +0 -18
- package/src/fixtures/sticker.json +0 -26
- package/src/fixtures/text_message.json +0 -30
- package/src/fixtures/wrong_object.json +0 -4
- package/src/index.test.ts +0 -645
- package/src/index.ts +0 -311
- package/src/verify.ts +0 -105
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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. `react` posts a
|
|
15
|
+
* `type: "reaction"` message targeting the inbound message id.
|
|
16
|
+
*
|
|
17
|
+
* Per-user session keying: WhatsApp has no thread concept; the gateway
|
|
18
|
+
* keys sessions on the contact's phone number (`messages[].from`).
|
|
19
|
+
*/
|
|
20
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
21
|
+
export { signWhatsAppBody, verifyWhatsAppSignature } from "./verify.js";
|
|
22
|
+
export declare class WhatsAppAdapterError extends CrewhausError {
|
|
23
|
+
readonly name = "WhatsAppAdapterError";
|
|
24
|
+
constructor(message: string, cause?: unknown);
|
|
25
|
+
}
|
|
26
|
+
export type RawRequest = {
|
|
27
|
+
readonly headers: Headers;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
};
|
|
30
|
+
export type InboundEvent = {
|
|
31
|
+
readonly idempotencyKey: string;
|
|
32
|
+
readonly workspaceId: string;
|
|
33
|
+
readonly channelId: string;
|
|
34
|
+
readonly userId: string;
|
|
35
|
+
readonly threadTs?: string;
|
|
36
|
+
readonly ts: string;
|
|
37
|
+
readonly text: string;
|
|
38
|
+
readonly subtype: "app_mention" | "message";
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Three result shapes:
|
|
42
|
+
* - `{kind:"event", event}` — a real inbound message to route
|
|
43
|
+
* - `{kind:"verification", challenge}` — Meta's GET-based verification
|
|
44
|
+
* handshake (the gateway echoes back `hub.challenge`)
|
|
45
|
+
* - `{kind:"skip"}` — known-but-uninteresting payload (status update,
|
|
46
|
+
* non-text non-interactive media, etc.)
|
|
47
|
+
*/
|
|
48
|
+
export type ParsedInbound = {
|
|
49
|
+
readonly kind: "event";
|
|
50
|
+
readonly event: InboundEvent;
|
|
51
|
+
} | {
|
|
52
|
+
readonly kind: "skip";
|
|
53
|
+
};
|
|
54
|
+
export interface ChannelAdapter {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
verify(req: RawRequest): boolean;
|
|
57
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
58
|
+
sendReply(args: {
|
|
59
|
+
event: InboundEvent;
|
|
60
|
+
text: string;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
setTyping(args: {
|
|
63
|
+
event: InboundEvent;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Phase 3 §3.2 — add an emoji reaction to an inbound message as a
|
|
67
|
+
* lightweight status acknowledgement (eyes/white_check_mark/warning).
|
|
68
|
+
* WhatsApp accepts any unicode emoji. Optional — the session-router skips
|
|
69
|
+
* the hook when an adapter leaves it undefined.
|
|
70
|
+
*/
|
|
71
|
+
react?(args: {
|
|
72
|
+
event: InboundEvent;
|
|
73
|
+
emoji: string;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export type WhatsAppAdapterConfig = {
|
|
77
|
+
/** Meta WhatsApp Cloud API phone number id (numeric, stringified). */
|
|
78
|
+
readonly phoneNumberId: string;
|
|
79
|
+
/** System-user access token authorising sends. */
|
|
80
|
+
readonly accessToken: string;
|
|
81
|
+
/** Meta app secret, used to verify X-Hub-Signature-256. */
|
|
82
|
+
readonly appSecret: string;
|
|
83
|
+
};
|
|
84
|
+
export type WhatsAppAdapterOptions = {
|
|
85
|
+
readonly apiBaseUrl?: string;
|
|
86
|
+
readonly fetch?: typeof fetch;
|
|
87
|
+
readonly apiVersion?: string;
|
|
88
|
+
/** Clock injection for the signature replay-window check (tests). */
|
|
89
|
+
readonly now?: () => number;
|
|
90
|
+
};
|
|
91
|
+
export declare function createWhatsAppAdapter(config: WhatsAppAdapterConfig, opts?: WhatsAppAdapterOptions): ChannelAdapter;
|
|
92
|
+
export type WhatsAppWebhookPayload = {
|
|
93
|
+
readonly object: string;
|
|
94
|
+
readonly entry?: ReadonlyArray<WhatsAppEntry>;
|
|
95
|
+
};
|
|
96
|
+
export type WhatsAppEntry = {
|
|
97
|
+
readonly id?: string;
|
|
98
|
+
readonly changes?: ReadonlyArray<WhatsAppChange>;
|
|
99
|
+
};
|
|
100
|
+
export type WhatsAppChange = {
|
|
101
|
+
readonly field?: string;
|
|
102
|
+
readonly value?: WhatsAppChangeValue;
|
|
103
|
+
};
|
|
104
|
+
export type WhatsAppChangeValue = {
|
|
105
|
+
readonly messaging_product?: string;
|
|
106
|
+
readonly metadata?: {
|
|
107
|
+
readonly display_phone_number?: string;
|
|
108
|
+
readonly phone_number_id?: string;
|
|
109
|
+
};
|
|
110
|
+
readonly contacts?: ReadonlyArray<{
|
|
111
|
+
readonly profile?: {
|
|
112
|
+
readonly name?: string;
|
|
113
|
+
};
|
|
114
|
+
readonly wa_id?: string;
|
|
115
|
+
}>;
|
|
116
|
+
readonly messages?: ReadonlyArray<WhatsAppMessage>;
|
|
117
|
+
readonly statuses?: ReadonlyArray<{
|
|
118
|
+
readonly id?: string;
|
|
119
|
+
readonly status?: string;
|
|
120
|
+
}>;
|
|
121
|
+
};
|
|
122
|
+
export type WhatsAppMessage = {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly from: string;
|
|
125
|
+
readonly timestamp?: string;
|
|
126
|
+
readonly type: "text" | "image" | "audio" | "interactive" | "video" | "document" | "sticker";
|
|
127
|
+
readonly text?: {
|
|
128
|
+
readonly body?: string;
|
|
129
|
+
};
|
|
130
|
+
readonly image?: {
|
|
131
|
+
readonly caption?: string;
|
|
132
|
+
readonly mime_type?: string;
|
|
133
|
+
};
|
|
134
|
+
readonly interactive?: WhatsAppInteractive;
|
|
135
|
+
};
|
|
136
|
+
export type WhatsAppInteractive = {
|
|
137
|
+
readonly type?: "button_reply" | "list_reply";
|
|
138
|
+
readonly button_reply?: {
|
|
139
|
+
readonly id?: string;
|
|
140
|
+
readonly title?: string;
|
|
141
|
+
};
|
|
142
|
+
readonly list_reply?: {
|
|
143
|
+
readonly id?: string;
|
|
144
|
+
readonly title?: string;
|
|
145
|
+
readonly description?: string;
|
|
146
|
+
};
|
|
147
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
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. `react` posts a
|
|
15
|
+
* `type: "reaction"` message targeting the inbound message id.
|
|
16
|
+
*
|
|
17
|
+
* Per-user session keying: WhatsApp has no thread concept; the gateway
|
|
18
|
+
* keys sessions on the contact's phone number (`messages[].from`).
|
|
19
|
+
*/
|
|
20
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
21
|
+
import { verifyWhatsAppSignature } from "./verify.js";
|
|
22
|
+
export { signWhatsAppBody, verifyWhatsAppSignature } from "./verify.js";
|
|
23
|
+
export class WhatsAppAdapterError extends CrewhausError {
|
|
24
|
+
name = "WhatsAppAdapterError";
|
|
25
|
+
constructor(message, cause) {
|
|
26
|
+
super("channel", message, cause);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const DEFAULT_API_BASE_URL = "https://graph.facebook.com";
|
|
30
|
+
const DEFAULT_API_VERSION = "v22.0";
|
|
31
|
+
// WhatsApp accepts any unicode emoji for reactions, so the channel-generic
|
|
32
|
+
// status names map to their natural glyphs. An unrecognised name passes
|
|
33
|
+
// through verbatim.
|
|
34
|
+
const WHATSAPP_REACTION_EMOJI = {
|
|
35
|
+
eyes: "👀",
|
|
36
|
+
white_check_mark: "✅",
|
|
37
|
+
warning: "⚠️",
|
|
38
|
+
};
|
|
39
|
+
export function createWhatsAppAdapter(config, opts = {}) {
|
|
40
|
+
const apiBaseUrl = opts.apiBaseUrl ?? process.env["WHATSAPP_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
41
|
+
const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION;
|
|
42
|
+
const doFetch = opts.fetch ?? fetch;
|
|
43
|
+
const messagesUrl = `${apiBaseUrl}/${apiVersion}/${config.phoneNumberId}/messages`;
|
|
44
|
+
return {
|
|
45
|
+
id: "whatsapp",
|
|
46
|
+
verify(req) {
|
|
47
|
+
return verifyWhatsAppSignature({
|
|
48
|
+
headers: req.headers,
|
|
49
|
+
body: req.body,
|
|
50
|
+
appSecret: config.appSecret,
|
|
51
|
+
}, opts.now !== undefined ? { now: opts.now } : {});
|
|
52
|
+
},
|
|
53
|
+
parseInbound(req) {
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = JSON.parse(req.body);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { kind: "skip" };
|
|
60
|
+
}
|
|
61
|
+
if (typeof payload !== "object" || payload === null)
|
|
62
|
+
return { kind: "skip" };
|
|
63
|
+
const p = payload;
|
|
64
|
+
if (p.object !== "whatsapp_business_account")
|
|
65
|
+
return { kind: "skip" };
|
|
66
|
+
// entry[0].changes[0].value contains the message envelope.
|
|
67
|
+
const change = p.entry?.[0]?.changes?.[0];
|
|
68
|
+
if (!change || change.field !== "messages")
|
|
69
|
+
return { kind: "skip" };
|
|
70
|
+
const value = change.value;
|
|
71
|
+
if (!value)
|
|
72
|
+
return { kind: "skip" };
|
|
73
|
+
const messages = value.messages ?? [];
|
|
74
|
+
// Status updates (delivered/read/failed) come as `value.statuses` —
|
|
75
|
+
// we skip those; the gateway acks 200 and moves on.
|
|
76
|
+
if (messages.length === 0)
|
|
77
|
+
return { kind: "skip" };
|
|
78
|
+
const msg = messages[0];
|
|
79
|
+
if (!msg || typeof msg.id !== "string" || typeof msg.from !== "string") {
|
|
80
|
+
return { kind: "skip" };
|
|
81
|
+
}
|
|
82
|
+
const phoneNumberId = value.metadata?.phone_number_id ?? "";
|
|
83
|
+
if (!phoneNumberId)
|
|
84
|
+
return { kind: "skip" };
|
|
85
|
+
const text = renderInboundText(msg);
|
|
86
|
+
if (text === undefined)
|
|
87
|
+
return { kind: "skip" };
|
|
88
|
+
const event = {
|
|
89
|
+
idempotencyKey: msg.id,
|
|
90
|
+
workspaceId: phoneNumberId,
|
|
91
|
+
channelId: msg.from,
|
|
92
|
+
userId: msg.from,
|
|
93
|
+
ts: msg.timestamp ?? "",
|
|
94
|
+
text,
|
|
95
|
+
subtype: "message",
|
|
96
|
+
};
|
|
97
|
+
return { kind: "event", event };
|
|
98
|
+
},
|
|
99
|
+
async sendReply(args) {
|
|
100
|
+
const body = {
|
|
101
|
+
messaging_product: "whatsapp",
|
|
102
|
+
recipient_type: "individual",
|
|
103
|
+
to: args.event.userId,
|
|
104
|
+
type: "text",
|
|
105
|
+
text: { body: args.text },
|
|
106
|
+
};
|
|
107
|
+
const res = await doFetch(messagesUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
throw new WhatsAppAdapterError(`messages POST failed: ${res.status} ${res.statusText}`);
|
|
117
|
+
}
|
|
118
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
119
|
+
if (ct.includes("application/json")) {
|
|
120
|
+
const json = (await res.json());
|
|
121
|
+
if (json.error) {
|
|
122
|
+
throw new WhatsAppAdapterError(`messages POST error: ${json.error.message ?? "unknown"}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
async setTyping(_args) {
|
|
127
|
+
// No public typing-indicator API for Cloud-API-based WhatsApp bots.
|
|
128
|
+
},
|
|
129
|
+
// Phase 3 §3.2 — emoji reactions via a `type: "reaction"` message. The
|
|
130
|
+
// reaction targets the inbound message id (surfaced as idempotencyKey) and
|
|
131
|
+
// is addressed to the same contact (userId).
|
|
132
|
+
async react(args) {
|
|
133
|
+
const emoji = WHATSAPP_REACTION_EMOJI[args.emoji] ?? args.emoji;
|
|
134
|
+
const body = {
|
|
135
|
+
messaging_product: "whatsapp",
|
|
136
|
+
recipient_type: "individual",
|
|
137
|
+
to: args.event.userId,
|
|
138
|
+
type: "reaction",
|
|
139
|
+
reaction: { message_id: args.event.idempotencyKey, emoji },
|
|
140
|
+
};
|
|
141
|
+
const res = await doFetch(messagesUrl, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
});
|
|
149
|
+
// Reactions are best-effort — the session-router catches and continues so
|
|
150
|
+
// a flaky reaction never aborts message processing.
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
throw new WhatsAppAdapterError(`reaction POST failed: ${res.status} ${res.statusText}`);
|
|
153
|
+
}
|
|
154
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
155
|
+
if (ct.includes("application/json")) {
|
|
156
|
+
const json = (await res.json());
|
|
157
|
+
if (json.error) {
|
|
158
|
+
throw new WhatsAppAdapterError(`reaction POST error: ${json.error.message ?? "unknown"}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reduce a WhatsApp inbound message to a single text body for the agent.
|
|
166
|
+
* Returns undefined for unsupported types (audio without transcription,
|
|
167
|
+
* image without caption, document, sticker, etc.) — the gateway then
|
|
168
|
+
* skips.
|
|
169
|
+
*/
|
|
170
|
+
function renderInboundText(msg) {
|
|
171
|
+
switch (msg.type) {
|
|
172
|
+
case "text":
|
|
173
|
+
return msg.text?.body ?? "";
|
|
174
|
+
case "interactive": {
|
|
175
|
+
const ir = msg.interactive;
|
|
176
|
+
if (ir?.type === "button_reply")
|
|
177
|
+
return `[button:${ir.button_reply?.id ?? ""}]`;
|
|
178
|
+
if (ir?.type === "list_reply")
|
|
179
|
+
return `[list:${ir.list_reply?.id ?? ""}]`;
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
case "image":
|
|
183
|
+
return msg.image?.caption !== undefined ? `[image] ${msg.image.caption}` : undefined;
|
|
184
|
+
case "audio":
|
|
185
|
+
return undefined;
|
|
186
|
+
default:
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp Business Cloud API webhook signature verification per
|
|
3
|
+
* https://developers.facebook.com/docs/graph-api/webhooks/getting-started.
|
|
4
|
+
*
|
|
5
|
+
* Meta signs webhooks with `X-Hub-Signature-256: sha256=<hex>` where the
|
|
6
|
+
* MAC is HMAC-SHA256 of the raw request body using the app secret as key.
|
|
7
|
+
*
|
|
8
|
+
* Returns false on missing header, malformed format, mismatched signature.
|
|
9
|
+
*/
|
|
10
|
+
export type WhatsAppVerifyArgs = {
|
|
11
|
+
readonly headers: Headers;
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly appSecret: string;
|
|
14
|
+
};
|
|
15
|
+
export type WhatsAppVerifyOptions = {
|
|
16
|
+
readonly now?: () => number;
|
|
17
|
+
readonly toleranceMs?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function verifyWhatsAppSignature(args: WhatsAppVerifyArgs, opts?: WhatsAppVerifyOptions): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Sign a body — used by smoke tests and the in-repo integration test to
|
|
22
|
+
* construct fixtures the daemon will accept. Production code should NEVER
|
|
23
|
+
* call this — Meta signs requests, not us.
|
|
24
|
+
*/
|
|
25
|
+
export declare function signWhatsAppBody(args: {
|
|
26
|
+
body: string;
|
|
27
|
+
appSecret: string;
|
|
28
|
+
}): string;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
// WhatsApp signs only the body — there is no signed timestamp header — so the
|
|
3
|
+
// replay-window check reads `messages[].timestamp` from the AUTHENTICATED body
|
|
4
|
+
// after the HMAC passes. Unlike the Slack/Discord real-time interaction
|
|
5
|
+
// windows, WhatsApp redelivers failed webhooks on its own (multi-minute) retry
|
|
6
|
+
// schedule, so this default is deliberately more generous: it bounds INDEFINITE
|
|
7
|
+
// replay of a captured message rather than enforcing sub-minute freshness.
|
|
8
|
+
// Operators can widen/tighten it via `toleranceMs`; the gateway's durable
|
|
9
|
+
// cross-process dedup is the complementary, stronger control.
|
|
10
|
+
const DEFAULT_TOLERANCE_MS = 10 * 60 * 1000;
|
|
11
|
+
function asRecord(v) {
|
|
12
|
+
return typeof v === "object" && v !== null ? v : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Newest `messages[].timestamp` (unix seconds) in a WhatsApp webhook body, or
|
|
16
|
+
* undefined when there is none (e.g. status-only webhooks, which are skipped
|
|
17
|
+
* downstream). An attacker cannot strip the timestamp from a signed message
|
|
18
|
+
* body without invalidating the HMAC, so "no timestamp ⇒ accept" is safe.
|
|
19
|
+
*/
|
|
20
|
+
function newestMessageTimestamp(body) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(body);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const entries = asRecord(parsed)?.["entry"];
|
|
29
|
+
if (!Array.isArray(entries))
|
|
30
|
+
return undefined;
|
|
31
|
+
let newest;
|
|
32
|
+
const consider = (raw) => {
|
|
33
|
+
const n = typeof raw === "number"
|
|
34
|
+
? raw
|
|
35
|
+
: typeof raw === "string"
|
|
36
|
+
? Number.parseInt(raw, 10)
|
|
37
|
+
: Number.NaN;
|
|
38
|
+
if (Number.isFinite(n))
|
|
39
|
+
newest = newest === undefined ? n : Math.max(newest, n);
|
|
40
|
+
};
|
|
41
|
+
for (const entryRaw of entries) {
|
|
42
|
+
const changes = asRecord(entryRaw)?.["changes"];
|
|
43
|
+
if (!Array.isArray(changes))
|
|
44
|
+
continue;
|
|
45
|
+
for (const changeRaw of changes) {
|
|
46
|
+
const messages = asRecord(asRecord(changeRaw)?.["value"])?.["messages"];
|
|
47
|
+
if (!Array.isArray(messages))
|
|
48
|
+
continue;
|
|
49
|
+
for (const msgRaw of messages)
|
|
50
|
+
consider(asRecord(msgRaw)?.["timestamp"]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return newest;
|
|
54
|
+
}
|
|
55
|
+
export function verifyWhatsAppSignature(args, opts = {}) {
|
|
56
|
+
const supplied = args.headers.get("x-hub-signature-256");
|
|
57
|
+
if (!supplied)
|
|
58
|
+
return false;
|
|
59
|
+
if (!supplied.startsWith("sha256="))
|
|
60
|
+
return false;
|
|
61
|
+
const sigHex = supplied.slice("sha256=".length);
|
|
62
|
+
if (!/^[0-9a-f]{64}$/i.test(sigHex))
|
|
63
|
+
return false;
|
|
64
|
+
const expected = createHmac("sha256", args.appSecret).update(args.body).digest("hex");
|
|
65
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
66
|
+
const actualBuf = Buffer.from(sigHex.toLowerCase(), "utf8");
|
|
67
|
+
if (expectedBuf.length !== actualBuf.length)
|
|
68
|
+
return false;
|
|
69
|
+
if (!timingSafeEqual(expectedBuf, actualBuf))
|
|
70
|
+
return false;
|
|
71
|
+
// HMAC valid — now bound replay on the authenticated body.
|
|
72
|
+
const now = opts.now ?? (() => Date.now());
|
|
73
|
+
const tolerance = opts.toleranceMs ?? DEFAULT_TOLERANCE_MS;
|
|
74
|
+
const ts = newestMessageTimestamp(args.body);
|
|
75
|
+
if (ts !== undefined && Math.abs(now() - ts * 1000) > tolerance)
|
|
76
|
+
return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sign a body — used by smoke tests and the in-repo integration test to
|
|
81
|
+
* construct fixtures the daemon will accept. Production code should NEVER
|
|
82
|
+
* call this — Meta signs requests, not us.
|
|
83
|
+
*/
|
|
84
|
+
export function signWhatsAppBody(args) {
|
|
85
|
+
return `sha256=${createHmac("sha256", args.appSecret).update(args.body).digest("hex")}`;
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-whatsapp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WhatsApp Business Cloud API channel adapter: X-Hub-Signature-256 verification + text/media/interactive parsing + idempotent retry handling (Section 33)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5"
|
|
16
19
|
},
|
|
17
20
|
"license": "Apache-2.0",
|
|
18
21
|
"author": {
|
|
@@ -32,5 +35,5 @@
|
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
34
37
|
},
|
|
35
|
-
"files": ["
|
|
38
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
36
39
|
}
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|