@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/channel-adapter-whatsapp",
3
- "version": "0.1.3",
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.3"
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