@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/LICENSE +9 -0
- package/README.md +86 -0
- package/dist/index.d.ts +441 -0
- package/dist/index.js +1243 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
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
|