@chat-adapter/whatsapp 4.20.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/dist/index.js ADDED
@@ -0,0 +1,1243 @@
1
+ // src/index.ts
2
+ import { createHmac, timingSafeEqual } from "crypto";
3
+ import { extractCard, ValidationError } from "@chat-adapter/shared";
4
+ import {
5
+ ConsoleLogger,
6
+ convertEmojiPlaceholders,
7
+ defaultEmojiResolver,
8
+ getEmoji,
9
+ Message
10
+ } from "chat";
11
+
12
+ // src/cards.ts
13
+ var CALLBACK_DATA_PREFIX = "chat:";
14
+ var MAX_REPLY_BUTTONS = 3;
15
+ var MAX_BUTTON_TITLE_LENGTH = 20;
16
+ var MAX_BODY_LENGTH = 1024;
17
+ function encodeWhatsAppCallbackData(actionId, value) {
18
+ const payload = { a: actionId };
19
+ if (typeof value === "string") {
20
+ payload.v = value;
21
+ }
22
+ return `${CALLBACK_DATA_PREFIX}${JSON.stringify(payload)}`;
23
+ }
24
+ function decodeWhatsAppCallbackData(data) {
25
+ if (!data) {
26
+ return { actionId: "whatsapp_callback", value: void 0 };
27
+ }
28
+ if (!data.startsWith(CALLBACK_DATA_PREFIX)) {
29
+ return { actionId: data, value: data };
30
+ }
31
+ try {
32
+ const decoded = JSON.parse(
33
+ data.slice(CALLBACK_DATA_PREFIX.length)
34
+ );
35
+ if (typeof decoded.a === "string" && decoded.a) {
36
+ return {
37
+ actionId: decoded.a,
38
+ value: typeof decoded.v === "string" ? decoded.v : void 0
39
+ };
40
+ }
41
+ } catch {
42
+ }
43
+ return { actionId: data, value: data };
44
+ }
45
+ function cardToWhatsApp(card) {
46
+ const actions = findActions(card.children);
47
+ const actionButtons = actions ? extractReplyButtons(actions) : null;
48
+ if (actionButtons && actionButtons.length > 0) {
49
+ const bodyText = buildBodyText(card);
50
+ return {
51
+ type: "interactive",
52
+ interactive: {
53
+ type: "button",
54
+ ...card.title ? { header: { type: "text", text: truncate(card.title, 60) } } : {},
55
+ body: {
56
+ text: truncate(
57
+ bodyText || "Please choose an option",
58
+ MAX_BODY_LENGTH
59
+ )
60
+ },
61
+ action: {
62
+ buttons: actionButtons.map((btn) => ({
63
+ type: "reply",
64
+ reply: {
65
+ id: encodeWhatsAppCallbackData(btn.id, btn.value),
66
+ title: truncate(btn.label, MAX_BUTTON_TITLE_LENGTH)
67
+ }
68
+ }))
69
+ }
70
+ }
71
+ };
72
+ }
73
+ return {
74
+ type: "text",
75
+ text: cardToWhatsAppText(card)
76
+ };
77
+ }
78
+ function cardToWhatsAppText(card) {
79
+ const lines = [];
80
+ if (card.title) {
81
+ lines.push(`*${escapeWhatsApp(card.title)}*`);
82
+ }
83
+ if (card.subtitle) {
84
+ lines.push(escapeWhatsApp(card.subtitle));
85
+ }
86
+ if ((card.title || card.subtitle) && card.children.length > 0) {
87
+ lines.push("");
88
+ }
89
+ if (card.imageUrl) {
90
+ lines.push(card.imageUrl);
91
+ lines.push("");
92
+ }
93
+ for (let i = 0; i < card.children.length; i++) {
94
+ const child = card.children[i];
95
+ const childLines = renderChild(child);
96
+ if (childLines.length > 0) {
97
+ lines.push(...childLines);
98
+ if (i < card.children.length - 1) {
99
+ lines.push("");
100
+ }
101
+ }
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+ function renderChild(child) {
106
+ switch (child.type) {
107
+ case "text":
108
+ return renderText(child);
109
+ case "fields":
110
+ return renderFields(child);
111
+ case "actions":
112
+ return renderActions(child);
113
+ case "section":
114
+ return child.children.flatMap(renderChild);
115
+ case "image":
116
+ if (child.alt) {
117
+ return [`${child.alt}: ${child.url}`];
118
+ }
119
+ return [child.url];
120
+ case "divider":
121
+ return ["---"];
122
+ default:
123
+ return [];
124
+ }
125
+ }
126
+ function renderText(text) {
127
+ switch (text.style) {
128
+ case "bold":
129
+ return [`*${escapeWhatsApp(text.content)}*`];
130
+ case "muted":
131
+ return [`_${escapeWhatsApp(text.content)}_`];
132
+ default:
133
+ return [escapeWhatsApp(text.content)];
134
+ }
135
+ }
136
+ function renderFields(fields) {
137
+ return fields.children.map(
138
+ (field) => `*${escapeWhatsApp(field.label)}:* ${escapeWhatsApp(field.value)}`
139
+ );
140
+ }
141
+ function renderActions(actions) {
142
+ const buttonTexts = actions.children.map((button) => {
143
+ if (button.type === "link-button") {
144
+ return `${escapeWhatsApp(button.label)}: ${button.url}`;
145
+ }
146
+ return `[${escapeWhatsApp(button.label)}]`;
147
+ });
148
+ return [buttonTexts.join(" | ")];
149
+ }
150
+ function childToPlainText(child) {
151
+ switch (child.type) {
152
+ case "text":
153
+ return child.content;
154
+ case "fields":
155
+ return child.children.map((f) => `${f.label}: ${f.value}`).join("\n");
156
+ case "actions":
157
+ return null;
158
+ case "section":
159
+ return child.children.map(childToPlainText).filter(Boolean).join("\n");
160
+ default:
161
+ return null;
162
+ }
163
+ }
164
+ function findActions(children) {
165
+ for (const child of children) {
166
+ if (child.type === "actions") {
167
+ return child;
168
+ }
169
+ if (child.type === "section") {
170
+ const nested = findActions(child.children);
171
+ if (nested) {
172
+ return nested;
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ function extractReplyButtons(actions) {
179
+ const buttons = [];
180
+ for (const child of actions.children) {
181
+ if (child.type === "button" && child.id) {
182
+ buttons.push(child);
183
+ }
184
+ }
185
+ if (buttons.length === 0) {
186
+ return null;
187
+ }
188
+ return buttons.slice(0, MAX_REPLY_BUTTONS);
189
+ }
190
+ function buildBodyText(card) {
191
+ const parts = [];
192
+ if (card.subtitle) {
193
+ parts.push(card.subtitle);
194
+ }
195
+ for (const child of card.children) {
196
+ if (child.type === "actions") {
197
+ continue;
198
+ }
199
+ const text = childToPlainText(child);
200
+ if (text) {
201
+ parts.push(text);
202
+ }
203
+ }
204
+ return parts.join("\n");
205
+ }
206
+ function escapeWhatsApp(text) {
207
+ return text.replace(/\\/g, "\\\\").replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/~/g, "\\~").replace(/`/g, "\\`");
208
+ }
209
+ function truncate(text, maxLength) {
210
+ if (text.length <= maxLength) {
211
+ return text;
212
+ }
213
+ return `${text.slice(0, maxLength - 1)}\u2026`;
214
+ }
215
+
216
+ // src/markdown.ts
217
+ import {
218
+ BaseFormatConverter,
219
+ isTableNode,
220
+ parseMarkdown,
221
+ stringifyMarkdown,
222
+ tableToAscii,
223
+ walkAst
224
+ } from "chat";
225
+ var WhatsAppFormatConverter = class extends BaseFormatConverter {
226
+ /**
227
+ * Convert an AST to WhatsApp markdown format.
228
+ *
229
+ * Transforms unsupported nodes (headings, thematic breaks, tables)
230
+ * into WhatsApp-compatible equivalents, then converts standard markdown
231
+ * bold/strikethrough to WhatsApp syntax.
232
+ */
233
+ fromAst(ast) {
234
+ const transformed = walkAst(structuredClone(ast), (node) => {
235
+ if (node.type === "heading") {
236
+ const heading = node;
237
+ const children = heading.children.flatMap(
238
+ (child) => child.type === "strong" ? child.children : [child]
239
+ );
240
+ return {
241
+ type: "paragraph",
242
+ children: [{ type: "strong", children }]
243
+ };
244
+ }
245
+ if (node.type === "thematicBreak") {
246
+ return {
247
+ type: "paragraph",
248
+ children: [{ type: "text", value: "\u2501\u2501\u2501" }]
249
+ };
250
+ }
251
+ if (isTableNode(node)) {
252
+ return {
253
+ type: "code",
254
+ value: tableToAscii(node),
255
+ lang: void 0
256
+ };
257
+ }
258
+ return node;
259
+ });
260
+ const markdown = stringifyMarkdown(transformed, {
261
+ emphasis: "_",
262
+ bullet: "-"
263
+ }).trim();
264
+ return this.toWhatsAppFormat(markdown);
265
+ }
266
+ /**
267
+ * Parse WhatsApp markdown into an AST.
268
+ *
269
+ * Transforms WhatsApp-specific formatting to standard markdown first,
270
+ * then parses with the standard parser.
271
+ */
272
+ toAst(markdown) {
273
+ const standardMarkdown = this.fromWhatsAppFormat(markdown);
274
+ return parseMarkdown(standardMarkdown);
275
+ }
276
+ /**
277
+ * Render a postable message to WhatsApp-compatible string.
278
+ */
279
+ renderPostable(message) {
280
+ if (typeof message === "string") {
281
+ return message;
282
+ }
283
+ if ("raw" in message) {
284
+ return message.raw;
285
+ }
286
+ if ("markdown" in message) {
287
+ return this.fromMarkdown(message.markdown);
288
+ }
289
+ if ("ast" in message) {
290
+ return this.fromAst(message.ast);
291
+ }
292
+ return super.renderPostable(message);
293
+ }
294
+ /**
295
+ * Convert remaining standard markdown markers to WhatsApp format.
296
+ * The stringifier already outputs _italic_ and - bullets.
297
+ * This only converts **bold** -> *bold* and ~~strike~~ -> ~strike~.
298
+ */
299
+ toWhatsAppFormat(text) {
300
+ let result = text;
301
+ result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
302
+ result = result.replace(/~~(.+?)~~/g, "~$1~");
303
+ return result;
304
+ }
305
+ /**
306
+ * Convert WhatsApp format to standard markdown.
307
+ * Converts single-asterisk bold to double-asterisk bold,
308
+ * and single-tilde strikethrough to double-tilde strikethrough.
309
+ *
310
+ * Careful not to convert _italic_ (which is the same in both formats).
311
+ */
312
+ fromWhatsAppFormat(text) {
313
+ let result = text.replace(
314
+ /(?<!\*)\*(?!\*)([^\n*]+?)(?<!\*)\*(?!\*)/g,
315
+ "**$1**"
316
+ );
317
+ result = result.replace(/(?<!~)~(?!~)([^\n~]+?)(?<!~)~(?!~)/g, "~~$1~~");
318
+ return result;
319
+ }
320
+ };
321
+
322
+ // src/index.ts
323
+ var DEFAULT_API_VERSION = "v21.0";
324
+ var WHATSAPP_MESSAGE_LIMIT = 4096;
325
+ function splitMessage(text) {
326
+ if (text.length <= WHATSAPP_MESSAGE_LIMIT) {
327
+ return [text];
328
+ }
329
+ const chunks = [];
330
+ let remaining = text;
331
+ while (remaining.length > WHATSAPP_MESSAGE_LIMIT) {
332
+ const slice = remaining.slice(0, WHATSAPP_MESSAGE_LIMIT);
333
+ let breakIndex = slice.lastIndexOf("\n\n");
334
+ if (breakIndex === -1 || breakIndex < WHATSAPP_MESSAGE_LIMIT / 2) {
335
+ breakIndex = slice.lastIndexOf("\n");
336
+ }
337
+ if (breakIndex === -1 || breakIndex < WHATSAPP_MESSAGE_LIMIT / 2) {
338
+ breakIndex = WHATSAPP_MESSAGE_LIMIT;
339
+ }
340
+ chunks.push(remaining.slice(0, breakIndex).trimEnd());
341
+ remaining = remaining.slice(breakIndex).trimStart();
342
+ }
343
+ if (remaining.length > 0) {
344
+ chunks.push(remaining);
345
+ }
346
+ return chunks;
347
+ }
348
+ var WhatsAppAdapter = class {
349
+ name = "whatsapp";
350
+ persistMessageHistory = true;
351
+ userName;
352
+ accessToken;
353
+ appSecret;
354
+ phoneNumberId;
355
+ verifyToken;
356
+ graphApiUrl;
357
+ chat = null;
358
+ logger;
359
+ _botUserId = null;
360
+ formatConverter = new WhatsAppFormatConverter();
361
+ /** Bot user ID used for self-message detection */
362
+ get botUserId() {
363
+ return this._botUserId ?? void 0;
364
+ }
365
+ constructor(config) {
366
+ this.accessToken = config.accessToken;
367
+ this.appSecret = config.appSecret;
368
+ this.phoneNumberId = config.phoneNumberId;
369
+ this.verifyToken = config.verifyToken;
370
+ this.logger = config.logger;
371
+ this.userName = config.userName;
372
+ const apiVersion = config.apiVersion ?? DEFAULT_API_VERSION;
373
+ this.graphApiUrl = `https://graph.facebook.com/${apiVersion}`;
374
+ }
375
+ /**
376
+ * Initialize the adapter and fetch business profile info.
377
+ */
378
+ async initialize(chat) {
379
+ this.chat = chat;
380
+ this._botUserId = this.phoneNumberId;
381
+ this.logger.info("WhatsApp adapter initialized", {
382
+ phoneNumberId: this.phoneNumberId
383
+ });
384
+ }
385
+ /**
386
+ * Handle incoming webhook from WhatsApp.
387
+ *
388
+ * Handles both the GET verification challenge and POST event notifications.
389
+ *
390
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks
391
+ */
392
+ async handleWebhook(request, options) {
393
+ if (request.method === "GET") {
394
+ return this.handleVerificationChallenge(request);
395
+ }
396
+ const body = await request.text();
397
+ this.logger.debug("WhatsApp webhook raw body", {
398
+ body: body.substring(0, 500)
399
+ });
400
+ const signature = request.headers.get("x-hub-signature-256");
401
+ if (!this.verifySignature(body, signature)) {
402
+ return new Response("Invalid signature", { status: 401 });
403
+ }
404
+ let payload;
405
+ try {
406
+ payload = JSON.parse(body);
407
+ } catch {
408
+ this.logger.error("WhatsApp webhook invalid JSON", {
409
+ contentType: request.headers.get("content-type"),
410
+ bodyPreview: body.substring(0, 200)
411
+ });
412
+ return new Response("Invalid JSON", { status: 400 });
413
+ }
414
+ for (const entry of payload.entry) {
415
+ for (const change of entry.changes) {
416
+ if (change.field !== "messages") {
417
+ continue;
418
+ }
419
+ const { value } = change;
420
+ if (value.messages) {
421
+ for (const message of value.messages) {
422
+ try {
423
+ this.handleInboundMessage(
424
+ message,
425
+ value.contacts?.[0],
426
+ value.metadata.phone_number_id,
427
+ options
428
+ );
429
+ } catch (error) {
430
+ this.logger.error("Failed to handle inbound message", {
431
+ messageId: message.id,
432
+ error
433
+ });
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+ return new Response("ok", { status: 200 });
440
+ }
441
+ /**
442
+ * Handle the webhook verification challenge from Meta.
443
+ *
444
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks
445
+ */
446
+ handleVerificationChallenge(request) {
447
+ const url = new URL(request.url);
448
+ const mode = url.searchParams.get("hub.mode");
449
+ const token = url.searchParams.get("hub.verify_token");
450
+ const challenge = url.searchParams.get("hub.challenge");
451
+ if (mode === "subscribe" && token === this.verifyToken) {
452
+ this.logger.info("WhatsApp webhook verification succeeded");
453
+ return new Response(challenge ?? "", { status: 200 });
454
+ }
455
+ this.logger.warn("WhatsApp webhook verification failed", {
456
+ mode,
457
+ tokenMatch: token === this.verifyToken
458
+ });
459
+ return new Response("Forbidden", { status: 403 });
460
+ }
461
+ /**
462
+ * Verify webhook signature using HMAC-SHA256 with the App Secret.
463
+ *
464
+ * @see https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests
465
+ */
466
+ verifySignature(body, signature) {
467
+ if (!signature) {
468
+ return false;
469
+ }
470
+ const expectedSignature = `sha256=${createHmac("sha256", this.appSecret).update(body).digest("hex")}`;
471
+ try {
472
+ return timingSafeEqual(
473
+ Buffer.from(signature),
474
+ Buffer.from(expectedSignature)
475
+ );
476
+ } catch {
477
+ return false;
478
+ }
479
+ }
480
+ /**
481
+ * Handle an inbound message from a user.
482
+ */
483
+ handleInboundMessage(inbound, contact, phoneNumberId, options) {
484
+ if (!this.chat) {
485
+ this.logger.warn("Chat instance not initialized, ignoring message");
486
+ return;
487
+ }
488
+ if (inbound.type === "reaction" && inbound.reaction) {
489
+ this.handleReaction(inbound, contact, phoneNumberId, options);
490
+ return;
491
+ }
492
+ if (inbound.type === "interactive" && inbound.interactive) {
493
+ this.handleInteractiveReply(inbound, contact, phoneNumberId, options);
494
+ return;
495
+ }
496
+ if (inbound.type === "button" && inbound.button) {
497
+ this.handleButtonResponse(inbound, contact, phoneNumberId, options);
498
+ return;
499
+ }
500
+ const text = this.extractTextContent(inbound);
501
+ if (text === null) {
502
+ this.logger.debug("Unsupported message type, ignoring", {
503
+ type: inbound.type,
504
+ messageId: inbound.id
505
+ });
506
+ return;
507
+ }
508
+ const threadId = this.encodeThreadId({
509
+ phoneNumberId,
510
+ userWaId: inbound.from
511
+ });
512
+ const message = this.buildMessage(
513
+ inbound,
514
+ contact,
515
+ threadId,
516
+ text,
517
+ phoneNumberId
518
+ );
519
+ this.chat.processMessage(this, threadId, message, options);
520
+ }
521
+ /**
522
+ * Handle reaction events.
523
+ */
524
+ handleReaction(inbound, contact, phoneNumberId, options) {
525
+ if (!(this.chat && inbound.reaction)) {
526
+ return;
527
+ }
528
+ const threadId = this.encodeThreadId({
529
+ phoneNumberId,
530
+ userWaId: inbound.from
531
+ });
532
+ const rawEmoji = inbound.reaction.emoji;
533
+ const added = rawEmoji !== "";
534
+ const emojiValue = added ? getEmoji(rawEmoji) : getEmoji("");
535
+ const user = {
536
+ userId: inbound.from,
537
+ userName: contact?.profile.name || inbound.from,
538
+ fullName: contact?.profile.name || inbound.from,
539
+ isBot: false,
540
+ isMe: false
541
+ };
542
+ const event = {
543
+ emoji: emojiValue,
544
+ rawEmoji,
545
+ added,
546
+ user,
547
+ messageId: inbound.reaction.message_id,
548
+ threadId,
549
+ raw: inbound
550
+ };
551
+ this.chat.processReaction({ ...event, adapter: this }, options);
552
+ }
553
+ /**
554
+ * Handle interactive message replies (button/list selection).
555
+ */
556
+ handleInteractiveReply(inbound, contact, phoneNumberId, options) {
557
+ if (!(this.chat && inbound.interactive)) {
558
+ return;
559
+ }
560
+ const threadId = this.encodeThreadId({
561
+ phoneNumberId,
562
+ userWaId: inbound.from
563
+ });
564
+ const { interactive } = inbound;
565
+ let rawId;
566
+ let fallbackValue;
567
+ if (interactive.type === "button_reply" && interactive.button_reply) {
568
+ rawId = interactive.button_reply.id;
569
+ fallbackValue = interactive.button_reply.title;
570
+ } else if (interactive.type === "list_reply" && interactive.list_reply) {
571
+ rawId = interactive.list_reply.id;
572
+ fallbackValue = interactive.list_reply.title;
573
+ } else {
574
+ return;
575
+ }
576
+ const { actionId, value } = decodeWhatsAppCallbackData(rawId);
577
+ this.chat.processAction(
578
+ {
579
+ adapter: this,
580
+ actionId,
581
+ value: value ?? fallbackValue,
582
+ user: {
583
+ userId: inbound.from,
584
+ userName: contact?.profile.name || inbound.from,
585
+ fullName: contact?.profile.name || inbound.from,
586
+ isBot: false,
587
+ isMe: false
588
+ },
589
+ messageId: inbound.id,
590
+ threadId,
591
+ raw: inbound
592
+ },
593
+ options
594
+ );
595
+ }
596
+ /**
597
+ * Handle legacy button responses (from template quick replies).
598
+ */
599
+ handleButtonResponse(inbound, contact, phoneNumberId, options) {
600
+ if (!(this.chat && inbound.button)) {
601
+ return;
602
+ }
603
+ const threadId = this.encodeThreadId({
604
+ phoneNumberId,
605
+ userWaId: inbound.from
606
+ });
607
+ this.chat.processAction(
608
+ {
609
+ adapter: this,
610
+ actionId: inbound.button.payload,
611
+ value: inbound.button.text,
612
+ user: {
613
+ userId: inbound.from,
614
+ userName: contact?.profile.name || inbound.from,
615
+ fullName: contact?.profile.name || inbound.from,
616
+ isBot: false,
617
+ isMe: false
618
+ },
619
+ messageId: inbound.id,
620
+ threadId,
621
+ raw: inbound
622
+ },
623
+ options
624
+ );
625
+ }
626
+ /**
627
+ * Extract text content from an inbound message.
628
+ * Returns null for unsupported message types.
629
+ */
630
+ extractTextContent(message) {
631
+ switch (message.type) {
632
+ case "text":
633
+ return message.text?.body ?? null;
634
+ case "image":
635
+ return message.image?.caption ?? "[Image]";
636
+ case "document":
637
+ return message.document?.caption ?? `[Document: ${message.document?.filename ?? "file"}]`;
638
+ case "audio":
639
+ return "[Audio message]";
640
+ case "voice":
641
+ return "[Voice message]";
642
+ case "video":
643
+ return "[Video]";
644
+ case "sticker":
645
+ return "[Sticker]";
646
+ case "location": {
647
+ const loc = message.location;
648
+ if (loc) {
649
+ const parts = [`[Location: ${loc.latitude}, ${loc.longitude}`];
650
+ if (loc.name) {
651
+ parts[0] = `[Location: ${loc.name}`;
652
+ }
653
+ if (loc.address) {
654
+ parts.push(loc.address);
655
+ }
656
+ return `${parts.join(" - ")}]`;
657
+ }
658
+ return "[Location]";
659
+ }
660
+ default:
661
+ return null;
662
+ }
663
+ }
664
+ /**
665
+ * Build a Message from a WhatsApp inbound message.
666
+ */
667
+ buildMessage(inbound, contact, threadId, text, phoneNumberId) {
668
+ const author = {
669
+ userId: inbound.from,
670
+ userName: contact?.profile.name || inbound.from,
671
+ fullName: contact?.profile.name || inbound.from,
672
+ isBot: false,
673
+ isMe: false
674
+ };
675
+ const formatted = this.formatConverter.toAst(text);
676
+ const raw = {
677
+ message: inbound,
678
+ contact,
679
+ phoneNumberId: phoneNumberId || this.phoneNumberId
680
+ };
681
+ const attachments = this.buildAttachments(inbound);
682
+ return new Message({
683
+ id: inbound.id,
684
+ threadId,
685
+ text,
686
+ formatted,
687
+ raw,
688
+ author,
689
+ metadata: {
690
+ dateSent: new Date(Number.parseInt(inbound.timestamp, 10) * 1e3),
691
+ edited: false
692
+ },
693
+ attachments
694
+ });
695
+ }
696
+ /**
697
+ * Build attachments from an inbound message.
698
+ */
699
+ buildAttachments(inbound) {
700
+ const attachments = [];
701
+ if (inbound.image) {
702
+ attachments.push(
703
+ this.buildMediaAttachment(
704
+ inbound.image.id,
705
+ "image",
706
+ inbound.image.mime_type
707
+ )
708
+ );
709
+ }
710
+ if (inbound.document) {
711
+ attachments.push(
712
+ this.buildMediaAttachment(
713
+ inbound.document.id,
714
+ "file",
715
+ inbound.document.mime_type,
716
+ inbound.document.filename
717
+ )
718
+ );
719
+ }
720
+ if (inbound.audio) {
721
+ attachments.push(
722
+ this.buildMediaAttachment(
723
+ inbound.audio.id,
724
+ "audio",
725
+ inbound.audio.mime_type
726
+ )
727
+ );
728
+ }
729
+ if (inbound.video) {
730
+ attachments.push(
731
+ this.buildMediaAttachment(
732
+ inbound.video.id,
733
+ "video",
734
+ inbound.video.mime_type
735
+ )
736
+ );
737
+ }
738
+ if (inbound.voice) {
739
+ attachments.push(
740
+ this.buildMediaAttachment(
741
+ inbound.voice.id,
742
+ "audio",
743
+ inbound.voice.mime_type,
744
+ "voice"
745
+ )
746
+ );
747
+ }
748
+ if (inbound.sticker) {
749
+ attachments.push(
750
+ this.buildMediaAttachment(
751
+ inbound.sticker.id,
752
+ "image",
753
+ inbound.sticker.mime_type,
754
+ "sticker"
755
+ )
756
+ );
757
+ }
758
+ if (inbound.location) {
759
+ const loc = inbound.location;
760
+ const lat = Number(loc.latitude);
761
+ const lng = Number(loc.longitude);
762
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
763
+ const mapUrl = `https://www.google.com/maps?q=${lat},${lng}`;
764
+ attachments.push({
765
+ type: "file",
766
+ name: loc.name || "Location",
767
+ url: mapUrl,
768
+ mimeType: "application/geo+json"
769
+ });
770
+ }
771
+ }
772
+ return attachments;
773
+ }
774
+ /**
775
+ * Build a single media attachment with a lazy fetchData function.
776
+ */
777
+ buildMediaAttachment(mediaId, type, mimeType, name) {
778
+ return {
779
+ type,
780
+ mimeType,
781
+ name,
782
+ fetchData: () => this.downloadMedia(mediaId)
783
+ };
784
+ }
785
+ /**
786
+ * Download media from WhatsApp.
787
+ *
788
+ * WhatsApp media is fetched in two steps:
789
+ * 1. GET the media metadata to obtain the download URL
790
+ * 2. GET the actual binary data from the download URL
791
+ *
792
+ * @param mediaId - The media ID from the inbound message
793
+ * @returns The media data as a Buffer
794
+ *
795
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#download-media
796
+ */
797
+ async downloadMedia(mediaId) {
798
+ const metaResponse = await fetch(`${this.graphApiUrl}/${mediaId}`, {
799
+ headers: { Authorization: `Bearer ${this.accessToken}` }
800
+ });
801
+ if (!metaResponse.ok) {
802
+ const errorBody = await metaResponse.text();
803
+ this.logger.error("Failed to get media URL", {
804
+ status: metaResponse.status,
805
+ body: errorBody,
806
+ mediaId
807
+ });
808
+ throw new Error(
809
+ `Failed to get media URL: ${metaResponse.status} ${errorBody}`
810
+ );
811
+ }
812
+ const mediaInfo = await metaResponse.json();
813
+ const dataResponse = await fetch(mediaInfo.url, {
814
+ headers: { Authorization: `Bearer ${this.accessToken}` }
815
+ });
816
+ if (!dataResponse.ok) {
817
+ this.logger.error("Failed to download media", {
818
+ status: dataResponse.status,
819
+ mediaId
820
+ });
821
+ throw new Error(`Failed to download media: ${dataResponse.status}`);
822
+ }
823
+ const arrayBuffer = await dataResponse.arrayBuffer();
824
+ return Buffer.from(arrayBuffer);
825
+ }
826
+ /**
827
+ * Send a message to a WhatsApp user.
828
+ *
829
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages
830
+ */
831
+ async postMessage(threadId, message) {
832
+ const { userWaId } = this.decodeThreadId(threadId);
833
+ const card = extractCard(message);
834
+ if (card) {
835
+ const result = cardToWhatsApp(card);
836
+ if (result.type === "interactive") {
837
+ const interactive = JSON.parse(
838
+ convertEmojiPlaceholders(
839
+ JSON.stringify(result.interactive),
840
+ "whatsapp"
841
+ )
842
+ );
843
+ return this.sendInteractiveMessage(threadId, userWaId, interactive);
844
+ }
845
+ return this.sendTextMessage(
846
+ threadId,
847
+ userWaId,
848
+ convertEmojiPlaceholders(result.text, "whatsapp")
849
+ );
850
+ }
851
+ const body = convertEmojiPlaceholders(
852
+ this.formatConverter.renderPostable(message),
853
+ "whatsapp"
854
+ );
855
+ return this.sendTextMessage(threadId, userWaId, body);
856
+ }
857
+ /**
858
+ * Split text into chunks that fit within WhatsApp's message limit,
859
+ * breaking on paragraph boundaries (\n\n) when possible, then line
860
+ * boundaries (\n), and finally at the character limit as a last resort.
861
+ */
862
+ splitMessage(text) {
863
+ return splitMessage(text);
864
+ }
865
+ /**
866
+ * Send a single text message via the Cloud API (must be within the
867
+ * 4096-character limit).
868
+ */
869
+ async sendSingleTextMessage(threadId, to, text) {
870
+ const response = await this.graphApiRequest(
871
+ `/${this.phoneNumberId}/messages`,
872
+ {
873
+ messaging_product: "whatsapp",
874
+ recipient_type: "individual",
875
+ to,
876
+ type: "text",
877
+ text: { preview_url: false, body: text }
878
+ }
879
+ );
880
+ if (!(response.messages?.length && response.messages[0]?.id)) {
881
+ throw new Error(
882
+ "WhatsApp API did not return a message ID for text message"
883
+ );
884
+ }
885
+ const messageId = response.messages[0].id;
886
+ return {
887
+ id: messageId,
888
+ threadId,
889
+ raw: {
890
+ message: {
891
+ id: messageId,
892
+ from: this.phoneNumberId,
893
+ timestamp: String(Math.floor(Date.now() / 1e3)),
894
+ type: "text",
895
+ text: { body: text }
896
+ },
897
+ phoneNumberId: this.phoneNumberId
898
+ }
899
+ };
900
+ }
901
+ /**
902
+ * Send a text message, splitting into multiple messages if it exceeds
903
+ * WhatsApp's 4096-character limit. Returns the last message sent.
904
+ */
905
+ async sendTextMessage(threadId, to, text) {
906
+ const chunks = this.splitMessage(text);
907
+ let result;
908
+ for (const chunk of chunks) {
909
+ result = await this.sendSingleTextMessage(threadId, to, chunk);
910
+ }
911
+ return result;
912
+ }
913
+ /**
914
+ * Send an interactive message (buttons or list) via the Cloud API.
915
+ */
916
+ async sendInteractiveMessage(threadId, to, interactive) {
917
+ const response = await this.graphApiRequest(
918
+ `/${this.phoneNumberId}/messages`,
919
+ {
920
+ messaging_product: "whatsapp",
921
+ recipient_type: "individual",
922
+ to,
923
+ type: "interactive",
924
+ interactive
925
+ }
926
+ );
927
+ if (!(response.messages?.length && response.messages[0]?.id)) {
928
+ throw new Error(
929
+ "WhatsApp API did not return a message ID for interactive message"
930
+ );
931
+ }
932
+ const messageId = response.messages[0].id;
933
+ return {
934
+ id: messageId,
935
+ threadId,
936
+ raw: {
937
+ message: {
938
+ id: messageId,
939
+ from: this.phoneNumberId,
940
+ timestamp: String(Math.floor(Date.now() / 1e3)),
941
+ type: "interactive"
942
+ },
943
+ phoneNumberId: this.phoneNumberId
944
+ }
945
+ };
946
+ }
947
+ /**
948
+ * Edit a message. Not supported by WhatsApp Cloud API — throws an error.
949
+ *
950
+ * Callers should use postMessage directly if they want to send a follow-up.
951
+ */
952
+ async editMessage(_threadId, _messageId, _message) {
953
+ throw new Error(
954
+ "WhatsApp does not support editing messages. Use postMessage to send a new message instead."
955
+ );
956
+ }
957
+ /**
958
+ * Stream a message by buffering all chunks and sending as a single message.
959
+ * WhatsApp doesn't support message editing, so we can't do incremental updates.
960
+ */
961
+ async stream(threadId, textStream, _options) {
962
+ let accumulated = "";
963
+ for await (const chunk of textStream) {
964
+ if (typeof chunk === "string") {
965
+ accumulated += chunk;
966
+ } else if (chunk.type === "markdown_text") {
967
+ accumulated += chunk.text;
968
+ }
969
+ }
970
+ return this.postMessage(threadId, { markdown: accumulated });
971
+ }
972
+ /**
973
+ * Delete a message. Not supported by WhatsApp Cloud API — throws an error.
974
+ */
975
+ async deleteMessage(_threadId, _messageId) {
976
+ throw new Error("WhatsApp does not support deleting messages.");
977
+ }
978
+ /**
979
+ * Add a reaction to a message.
980
+ *
981
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/reaction-messages
982
+ */
983
+ async addReaction(threadId, messageId, emoji) {
984
+ const { userWaId } = this.decodeThreadId(threadId);
985
+ const emojiStr = this.resolveEmoji(emoji);
986
+ await this.graphApiRequest(`/${this.phoneNumberId}/messages`, {
987
+ messaging_product: "whatsapp",
988
+ recipient_type: "individual",
989
+ to: userWaId,
990
+ type: "reaction",
991
+ reaction: {
992
+ message_id: messageId,
993
+ emoji: emojiStr
994
+ }
995
+ });
996
+ }
997
+ /**
998
+ * Remove a reaction from a message.
999
+ *
1000
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/reaction-messages
1001
+ */
1002
+ async removeReaction(threadId, messageId, _emoji) {
1003
+ const { userWaId } = this.decodeThreadId(threadId);
1004
+ await this.graphApiRequest(`/${this.phoneNumberId}/messages`, {
1005
+ messaging_product: "whatsapp",
1006
+ recipient_type: "individual",
1007
+ to: userWaId,
1008
+ type: "reaction",
1009
+ reaction: {
1010
+ message_id: messageId,
1011
+ emoji: ""
1012
+ }
1013
+ });
1014
+ }
1015
+ /**
1016
+ * Start typing indicator.
1017
+ *
1018
+ * WhatsApp supports typing indicators via the messages endpoint.
1019
+ * The indicator displays for up to 25 seconds or until the next message.
1020
+ *
1021
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/mark-messages-as-read
1022
+ */
1023
+ async startTyping(_threadId, _status) {
1024
+ }
1025
+ /**
1026
+ * Fetch messages. Not supported by WhatsApp Cloud API.
1027
+ *
1028
+ * WhatsApp does not provide an API to retrieve message history.
1029
+ */
1030
+ async fetchMessages(_threadId, _options) {
1031
+ this.logger.debug(
1032
+ "fetchMessages not supported on WhatsApp - message history is not available via Cloud API"
1033
+ );
1034
+ return { messages: [] };
1035
+ }
1036
+ /**
1037
+ * Fetch thread info.
1038
+ */
1039
+ async fetchThread(threadId) {
1040
+ const { phoneNumberId, userWaId } = this.decodeThreadId(threadId);
1041
+ return {
1042
+ id: threadId,
1043
+ channelId: `whatsapp:${phoneNumberId}`,
1044
+ channelName: `WhatsApp: ${userWaId}`,
1045
+ isDM: true,
1046
+ metadata: { phoneNumberId, userWaId }
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Encode a WhatsApp thread ID.
1051
+ *
1052
+ * Format: whatsapp:{phoneNumberId}:{userWaId}
1053
+ */
1054
+ encodeThreadId(platformData) {
1055
+ return `whatsapp:${platformData.phoneNumberId}:${platformData.userWaId}`;
1056
+ }
1057
+ /**
1058
+ * Decode a WhatsApp thread ID.
1059
+ *
1060
+ * Format: whatsapp:{phoneNumberId}:{userWaId}
1061
+ */
1062
+ decodeThreadId(threadId) {
1063
+ if (!threadId.startsWith("whatsapp:")) {
1064
+ throw new ValidationError(
1065
+ "whatsapp",
1066
+ `Invalid WhatsApp thread ID: ${threadId}`
1067
+ );
1068
+ }
1069
+ const withoutPrefix = threadId.slice(9);
1070
+ if (!withoutPrefix) {
1071
+ throw new ValidationError(
1072
+ "whatsapp",
1073
+ `Invalid WhatsApp thread ID format: ${threadId}`
1074
+ );
1075
+ }
1076
+ const parts = withoutPrefix.split(":");
1077
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
1078
+ throw new ValidationError(
1079
+ "whatsapp",
1080
+ `Invalid WhatsApp thread ID format: ${threadId}`
1081
+ );
1082
+ }
1083
+ return {
1084
+ phoneNumberId: parts[0],
1085
+ userWaId: parts[1]
1086
+ };
1087
+ }
1088
+ /**
1089
+ * Derive channel ID from a WhatsApp thread ID.
1090
+ * On WhatsApp every conversation is a 1:1 DM, so channel === thread.
1091
+ */
1092
+ channelIdFromThreadId(threadId) {
1093
+ return threadId;
1094
+ }
1095
+ /**
1096
+ * All WhatsApp conversations are DMs.
1097
+ */
1098
+ isDM(_threadId) {
1099
+ return true;
1100
+ }
1101
+ /**
1102
+ * Open a DM with a user. Returns the thread ID for the conversation.
1103
+ *
1104
+ * For WhatsApp, this simply constructs the thread ID since all
1105
+ * conversations are inherently DMs. Note: you can only message users
1106
+ * who have messaged you first (within the 24-hour window) or
1107
+ * via approved template messages.
1108
+ */
1109
+ async openDM(userId) {
1110
+ return this.encodeThreadId({
1111
+ phoneNumberId: this.phoneNumberId,
1112
+ userWaId: userId
1113
+ });
1114
+ }
1115
+ /**
1116
+ * Parse platform message format to normalized format.
1117
+ */
1118
+ parseMessage(raw) {
1119
+ const text = this.extractTextContent(raw.message) || "";
1120
+ const formatted = this.formatConverter.toAst(text);
1121
+ const attachments = this.buildAttachments(raw.message);
1122
+ const threadId = this.encodeThreadId({
1123
+ phoneNumberId: raw.phoneNumberId,
1124
+ userWaId: raw.message.from
1125
+ });
1126
+ return new Message({
1127
+ id: raw.message.id,
1128
+ threadId,
1129
+ text,
1130
+ formatted,
1131
+ author: {
1132
+ userId: raw.message.from,
1133
+ userName: raw.contact?.profile.name || raw.message.from,
1134
+ fullName: raw.contact?.profile.name || raw.message.from,
1135
+ isBot: false,
1136
+ isMe: raw.message.from === this._botUserId
1137
+ },
1138
+ metadata: {
1139
+ dateSent: new Date(Number.parseInt(raw.message.timestamp, 10) * 1e3),
1140
+ edited: false
1141
+ },
1142
+ attachments,
1143
+ raw
1144
+ });
1145
+ }
1146
+ /**
1147
+ * Render formatted content to WhatsApp markdown.
1148
+ */
1149
+ renderFormatted(content) {
1150
+ return this.formatConverter.fromAst(content);
1151
+ }
1152
+ /**
1153
+ * Mark an inbound message as read.
1154
+ *
1155
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/mark-messages-as-read
1156
+ */
1157
+ async markAsRead(messageId) {
1158
+ await this.graphApiRequest(`/${this.phoneNumberId}/messages`, {
1159
+ messaging_product: "whatsapp",
1160
+ status: "read",
1161
+ message_id: messageId
1162
+ });
1163
+ }
1164
+ // =============================================================================
1165
+ // Private helpers
1166
+ // =============================================================================
1167
+ /**
1168
+ * Make a request to the Meta Graph API.
1169
+ */
1170
+ async graphApiRequest(path, body) {
1171
+ const response = await fetch(`${this.graphApiUrl}${path}`, {
1172
+ method: "POST",
1173
+ headers: {
1174
+ Authorization: `Bearer ${this.accessToken}`,
1175
+ "Content-Type": "application/json"
1176
+ },
1177
+ body: JSON.stringify(body)
1178
+ });
1179
+ if (!response.ok) {
1180
+ const errorBody = await response.text();
1181
+ this.logger.error("WhatsApp API error", {
1182
+ status: response.status,
1183
+ body: errorBody,
1184
+ path
1185
+ });
1186
+ throw new Error(`WhatsApp API error: ${response.status} ${errorBody}`);
1187
+ }
1188
+ return response.json();
1189
+ }
1190
+ /**
1191
+ * Resolve an emoji value to a unicode string.
1192
+ */
1193
+ resolveEmoji(emoji) {
1194
+ return defaultEmojiResolver.toGChat(emoji);
1195
+ }
1196
+ };
1197
+ function createWhatsAppAdapter(config) {
1198
+ const logger = config?.logger ?? new ConsoleLogger("info").child("whatsapp");
1199
+ const accessToken = config?.accessToken ?? process.env.WHATSAPP_ACCESS_TOKEN;
1200
+ if (!accessToken) {
1201
+ throw new ValidationError(
1202
+ "whatsapp",
1203
+ "accessToken is required. Set WHATSAPP_ACCESS_TOKEN or provide it in config."
1204
+ );
1205
+ }
1206
+ const appSecret = config?.appSecret ?? process.env.WHATSAPP_APP_SECRET;
1207
+ if (!appSecret) {
1208
+ throw new ValidationError(
1209
+ "whatsapp",
1210
+ "appSecret is required. Set WHATSAPP_APP_SECRET or provide it in config."
1211
+ );
1212
+ }
1213
+ const phoneNumberId = config?.phoneNumberId ?? process.env.WHATSAPP_PHONE_NUMBER_ID;
1214
+ if (!phoneNumberId) {
1215
+ throw new ValidationError(
1216
+ "whatsapp",
1217
+ "phoneNumberId is required. Set WHATSAPP_PHONE_NUMBER_ID or provide it in config."
1218
+ );
1219
+ }
1220
+ const verifyToken = config?.verifyToken ?? process.env.WHATSAPP_VERIFY_TOKEN;
1221
+ if (!verifyToken) {
1222
+ throw new ValidationError(
1223
+ "whatsapp",
1224
+ "verifyToken is required. Set WHATSAPP_VERIFY_TOKEN or provide it in config."
1225
+ );
1226
+ }
1227
+ const userName = config?.userName ?? process.env.WHATSAPP_BOT_USERNAME ?? "whatsapp-bot";
1228
+ return new WhatsAppAdapter({
1229
+ accessToken,
1230
+ apiVersion: config?.apiVersion,
1231
+ appSecret,
1232
+ phoneNumberId,
1233
+ verifyToken,
1234
+ userName,
1235
+ logger
1236
+ });
1237
+ }
1238
+ export {
1239
+ WhatsAppAdapter,
1240
+ createWhatsAppAdapter,
1241
+ splitMessage
1242
+ };
1243
+ //# sourceMappingURL=index.js.map