@crewhaus/channel-adapter-whatsapp 0.1.3 → 0.1.4
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 +2 -2
- package/src/index.test.ts +25 -0
- package/src/index.ts +52 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-whatsapp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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
6
|
"main": "src/index.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.4"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
package/src/index.test.ts
CHANGED
|
@@ -290,6 +290,31 @@ describe("sendReply / setTyping (T3)", () => {
|
|
|
290
290
|
await a.setTyping({ event });
|
|
291
291
|
expect(calls.length).toBe(0);
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
test("react POSTs a type:reaction message targeting the inbound message id", async () => {
|
|
295
|
+
const { calls, fetch: f } = captureFetch();
|
|
296
|
+
const a = adapter({ fetch: f });
|
|
297
|
+
expect(a.react).toBeDefined();
|
|
298
|
+
await a.react?.({ event, emoji: "white_check_mark" });
|
|
299
|
+
expect(calls.length).toBe(1);
|
|
300
|
+
expect(calls[0]?.url).toBe(`https://test.graph.local/v22.0/${PHONE_NUMBER_ID}/messages`);
|
|
301
|
+
const body = JSON.parse(String(calls[0]?.init.body));
|
|
302
|
+
expect(body.type).toBe("reaction");
|
|
303
|
+
expect(body.to).toBe("15554443333");
|
|
304
|
+
expect(body.reaction).toEqual({ message_id: "wamid.xx", emoji: "✅" });
|
|
305
|
+
const auth = (calls[0]?.init.headers as Record<string, string> | undefined)?.["Authorization"];
|
|
306
|
+
expect(auth).toBe(`Bearer ${ACCESS_TOKEN}`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("react throws on a Meta-side error envelope (router swallows it)", async () => {
|
|
310
|
+
const f = (async () =>
|
|
311
|
+
new Response(JSON.stringify({ error: { message: "message not found" } }), {
|
|
312
|
+
status: 200,
|
|
313
|
+
headers: { "content-type": "application/json" },
|
|
314
|
+
})) as unknown as typeof fetch;
|
|
315
|
+
const a = adapter({ fetch: f });
|
|
316
|
+
await expect(a.react?.({ event, emoji: "eyes" })).rejects.toThrow(/message not found/);
|
|
317
|
+
});
|
|
293
318
|
});
|
|
294
319
|
|
|
295
320
|
describe("createWhatsAppAdapter.verify()", () => {
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* list-reply payloads from the v22.0 Cloud API. `sendReply` POSTs to
|
|
12
12
|
* `/v22.0/{phoneNumberId}/messages` with the bot's access token.
|
|
13
13
|
* `setTyping` is a no-op — WhatsApp Business does not expose a typing
|
|
14
|
-
* indicator API for cloud-API-based bots.
|
|
14
|
+
* indicator API for cloud-API-based bots. `react` posts a
|
|
15
|
+
* `type: "reaction"` message targeting the inbound message id.
|
|
15
16
|
*
|
|
16
17
|
* Per-user session keying: WhatsApp has no thread concept; the gateway
|
|
17
18
|
* keys sessions on the contact's phone number (`messages[].from`).
|
|
@@ -62,6 +63,13 @@ export interface ChannelAdapter {
|
|
|
62
63
|
parseInbound(req: RawRequest): ParsedInbound;
|
|
63
64
|
sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
|
|
64
65
|
setTyping(args: { event: InboundEvent }): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Phase 3 §3.2 — add an emoji reaction to an inbound message as a
|
|
68
|
+
* lightweight status acknowledgement (eyes/white_check_mark/warning).
|
|
69
|
+
* WhatsApp accepts any unicode emoji. Optional — the session-router skips
|
|
70
|
+
* the hook when an adapter leaves it undefined.
|
|
71
|
+
*/
|
|
72
|
+
react?(args: { event: InboundEvent; emoji: string }): Promise<void>;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
export type WhatsAppAdapterConfig = {
|
|
@@ -84,6 +92,15 @@ export type WhatsAppAdapterOptions = {
|
|
|
84
92
|
const DEFAULT_API_BASE_URL = "https://graph.facebook.com";
|
|
85
93
|
const DEFAULT_API_VERSION = "v22.0";
|
|
86
94
|
|
|
95
|
+
// WhatsApp accepts any unicode emoji for reactions, so the channel-generic
|
|
96
|
+
// status names map to their natural glyphs. An unrecognised name passes
|
|
97
|
+
// through verbatim.
|
|
98
|
+
const WHATSAPP_REACTION_EMOJI: Record<string, string> = {
|
|
99
|
+
eyes: "👀",
|
|
100
|
+
white_check_mark: "✅",
|
|
101
|
+
warning: "⚠️",
|
|
102
|
+
};
|
|
103
|
+
|
|
87
104
|
export function createWhatsAppAdapter(
|
|
88
105
|
config: WhatsAppAdapterConfig,
|
|
89
106
|
opts: WhatsAppAdapterOptions = {},
|
|
@@ -183,6 +200,40 @@ export function createWhatsAppAdapter(
|
|
|
183
200
|
async setTyping(_args: { event: InboundEvent }): Promise<void> {
|
|
184
201
|
// No public typing-indicator API for Cloud-API-based WhatsApp bots.
|
|
185
202
|
},
|
|
203
|
+
|
|
204
|
+
// Phase 3 §3.2 — emoji reactions via a `type: "reaction"` message. The
|
|
205
|
+
// reaction targets the inbound message id (surfaced as idempotencyKey) and
|
|
206
|
+
// is addressed to the same contact (userId).
|
|
207
|
+
async react(args: { event: InboundEvent; emoji: string }): Promise<void> {
|
|
208
|
+
const emoji = WHATSAPP_REACTION_EMOJI[args.emoji] ?? args.emoji;
|
|
209
|
+
const body = {
|
|
210
|
+
messaging_product: "whatsapp",
|
|
211
|
+
recipient_type: "individual",
|
|
212
|
+
to: args.event.userId,
|
|
213
|
+
type: "reaction",
|
|
214
|
+
reaction: { message_id: args.event.idempotencyKey, emoji },
|
|
215
|
+
};
|
|
216
|
+
const res = await doFetch(messagesUrl, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
});
|
|
224
|
+
// Reactions are best-effort — the session-router catches and continues so
|
|
225
|
+
// a flaky reaction never aborts message processing.
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
throw new WhatsAppAdapterError(`reaction POST failed: ${res.status} ${res.statusText}`);
|
|
228
|
+
}
|
|
229
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
230
|
+
if (ct.includes("application/json")) {
|
|
231
|
+
const json = (await res.json()) as { error?: { message?: string } };
|
|
232
|
+
if (json.error) {
|
|
233
|
+
throw new WhatsAppAdapterError(`reaction POST error: ${json.error.message ?? "unknown"}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
186
237
|
};
|
|
187
238
|
}
|
|
188
239
|
|