@ariaflowagents/messaging-meta 0.8.1
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/README.md +236 -0
- package/dist/graph-api/client.d.ts +125 -0
- package/dist/graph-api/client.d.ts.map +1 -0
- package/dist/graph-api/client.js +204 -0
- package/dist/graph-api/client.js.map +1 -0
- package/dist/graph-api/errors.d.ts +41 -0
- package/dist/graph-api/errors.d.ts.map +1 -0
- package/dist/graph-api/errors.js +72 -0
- package/dist/graph-api/errors.js.map +1 -0
- package/dist/graph-api/index.d.ts +15 -0
- package/dist/graph-api/index.d.ts.map +1 -0
- package/dist/graph-api/index.js +11 -0
- package/dist/graph-api/index.js.map +1 -0
- package/dist/graph-api/rate-limiter.d.ts +90 -0
- package/dist/graph-api/rate-limiter.d.ts.map +1 -0
- package/dist/graph-api/rate-limiter.js +172 -0
- package/dist/graph-api/rate-limiter.js.map +1 -0
- package/dist/graph-api/retry.d.ts +55 -0
- package/dist/graph-api/retry.d.ts.map +1 -0
- package/dist/graph-api/retry.js +103 -0
- package/dist/graph-api/retry.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/instagram/client.d.ts +365 -0
- package/dist/instagram/client.d.ts.map +1 -0
- package/dist/instagram/client.js +857 -0
- package/dist/instagram/client.js.map +1 -0
- package/dist/instagram/format.d.ts +62 -0
- package/dist/instagram/format.d.ts.map +1 -0
- package/dist/instagram/format.js +92 -0
- package/dist/instagram/format.js.map +1 -0
- package/dist/instagram/ice-breakers.d.ts +63 -0
- package/dist/instagram/ice-breakers.d.ts.map +1 -0
- package/dist/instagram/ice-breakers.js +87 -0
- package/dist/instagram/ice-breakers.js.map +1 -0
- package/dist/instagram/index.d.ts +44 -0
- package/dist/instagram/index.d.ts.map +1 -0
- package/dist/instagram/index.js +46 -0
- package/dist/instagram/index.js.map +1 -0
- package/dist/instagram/types.d.ts +188 -0
- package/dist/instagram/types.d.ts.map +1 -0
- package/dist/instagram/types.js +19 -0
- package/dist/instagram/types.js.map +1 -0
- package/dist/messenger/client.d.ts +339 -0
- package/dist/messenger/client.d.ts.map +1 -0
- package/dist/messenger/client.js +782 -0
- package/dist/messenger/client.js.map +1 -0
- package/dist/messenger/format.d.ts +69 -0
- package/dist/messenger/format.d.ts.map +1 -0
- package/dist/messenger/format.js +98 -0
- package/dist/messenger/format.js.map +1 -0
- package/dist/messenger/index.d.ts +34 -0
- package/dist/messenger/index.d.ts.map +1 -0
- package/dist/messenger/index.js +35 -0
- package/dist/messenger/index.js.map +1 -0
- package/dist/messenger/types.d.ts +181 -0
- package/dist/messenger/types.d.ts.map +1 -0
- package/dist/messenger/types.js +10 -0
- package/dist/messenger/types.js.map +1 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +29 -0
- package/dist/server.js.map +1 -0
- package/dist/webhook/index.d.ts +10 -0
- package/dist/webhook/index.d.ts.map +1 -0
- package/dist/webhook/index.js +8 -0
- package/dist/webhook/index.js.map +1 -0
- package/dist/webhook/normalizer.d.ts +169 -0
- package/dist/webhook/normalizer.d.ts.map +1 -0
- package/dist/webhook/normalizer.js +301 -0
- package/dist/webhook/normalizer.js.map +1 -0
- package/dist/webhook/verifier.d.ts +45 -0
- package/dist/webhook/verifier.d.ts.map +1 -0
- package/dist/webhook/verifier.js +62 -0
- package/dist/webhook/verifier.js.map +1 -0
- package/dist/whatsapp/client.d.ts +481 -0
- package/dist/whatsapp/client.d.ts.map +1 -0
- package/dist/whatsapp/client.js +1043 -0
- package/dist/whatsapp/client.js.map +1 -0
- package/dist/whatsapp/flows.d.ts +74 -0
- package/dist/whatsapp/flows.d.ts.map +1 -0
- package/dist/whatsapp/flows.js +77 -0
- package/dist/whatsapp/flows.js.map +1 -0
- package/dist/whatsapp/format.d.ts +78 -0
- package/dist/whatsapp/format.d.ts.map +1 -0
- package/dist/whatsapp/format.js +195 -0
- package/dist/whatsapp/format.js.map +1 -0
- package/dist/whatsapp/index.d.ts +39 -0
- package/dist/whatsapp/index.d.ts.map +1 -0
- package/dist/whatsapp/index.js +42 -0
- package/dist/whatsapp/index.js.map +1 -0
- package/dist/whatsapp/split.d.ts +35 -0
- package/dist/whatsapp/split.d.ts.map +1 -0
- package/dist/whatsapp/split.js +76 -0
- package/dist/whatsapp/split.js.map +1 -0
- package/dist/whatsapp/templates.d.ts +129 -0
- package/dist/whatsapp/templates.d.ts.map +1 -0
- package/dist/whatsapp/templates.js +125 -0
- package/dist/whatsapp/templates.js.map +1 -0
- package/dist/whatsapp/types.d.ts +440 -0
- package/dist/whatsapp/types.d.ts.map +1 -0
- package/dist/whatsapp/types.js +11 -0
- package/dist/whatsapp/types.js.map +1 -0
- package/package.json +31 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module whatsapp/client
|
|
3
|
+
*
|
|
4
|
+
* WhatsApp Cloud API client implementing the {@link PlatformClient} interface
|
|
5
|
+
* from `@ariaflowagents/messaging`.
|
|
6
|
+
*
|
|
7
|
+
* Provides a complete, production-ready integration with Meta's WhatsApp
|
|
8
|
+
* Business Platform including:
|
|
9
|
+
*
|
|
10
|
+
* - Sending text, media, interactive, template, location, and contact messages
|
|
11
|
+
* - Webhook handling with signature verification
|
|
12
|
+
* - Template management (list, create, delete)
|
|
13
|
+
* - Phone number management (register, verify, business profile)
|
|
14
|
+
* - WhatsApp Flows management
|
|
15
|
+
* - Media upload and download
|
|
16
|
+
* - Smart message splitting for the 4096-char limit
|
|
17
|
+
* - WhatsApp-specific format conversion
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { createWhatsAppClient } from '@ariaflowagents/messaging-meta/whatsapp';
|
|
22
|
+
*
|
|
23
|
+
* const client = createWhatsAppClient({
|
|
24
|
+
* accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
|
|
25
|
+
* appSecret: process.env.META_APP_SECRET!,
|
|
26
|
+
* phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
|
|
27
|
+
* verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* client.onMessage(async (msg) => {
|
|
31
|
+
* await client.sendText(msg.from.phone!, `Echo: ${msg.text}`);
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
import { MessagingError, WindowClosedError, } from '@ariaflowagents/messaging';
|
|
36
|
+
import { GraphAPIClient } from '../graph-api/client.js';
|
|
37
|
+
import { verifySignature } from '../webhook/verifier.js';
|
|
38
|
+
import { normalizeWebhook } from '../webhook/normalizer.js';
|
|
39
|
+
import { WhatsAppFormatConverter } from './format.js';
|
|
40
|
+
import { splitMessage } from './split.js';
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Factory
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Create a new {@link WhatsAppClient} instance.
|
|
46
|
+
*
|
|
47
|
+
* This is the recommended entry point. It constructs the client with all
|
|
48
|
+
* internal dependencies wired up (Graph API client, format converter).
|
|
49
|
+
*
|
|
50
|
+
* @param config - WhatsApp client configuration.
|
|
51
|
+
* @returns A fully configured {@link WhatsAppClient}.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* const whatsapp = createWhatsAppClient({
|
|
56
|
+
* accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
|
|
57
|
+
* appSecret: process.env.META_APP_SECRET!,
|
|
58
|
+
* phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
|
|
59
|
+
* verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function createWhatsAppClient(config) {
|
|
64
|
+
return new WhatsAppClient(config);
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// WhatsAppClient
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Full-featured WhatsApp Cloud API client.
|
|
71
|
+
*
|
|
72
|
+
* Implements the `PlatformClient` interface from `@ariaflowagents/messaging`
|
|
73
|
+
* so it can be plugged into the messaging adapter layer, while also exposing
|
|
74
|
+
* WhatsApp-specific capabilities (templates, flows, reactions, CTA buttons).
|
|
75
|
+
*
|
|
76
|
+
* Use {@link createWhatsAppClient} to instantiate.
|
|
77
|
+
*/
|
|
78
|
+
export class WhatsAppClient {
|
|
79
|
+
/** @inheritdoc */
|
|
80
|
+
platform = 'whatsapp';
|
|
81
|
+
graphApi;
|
|
82
|
+
config;
|
|
83
|
+
messageHandlers = [];
|
|
84
|
+
statusHandlers = [];
|
|
85
|
+
reactionHandlers = [];
|
|
86
|
+
formatConverterInstance;
|
|
87
|
+
mediaCache;
|
|
88
|
+
constructor(config) {
|
|
89
|
+
this.config = config;
|
|
90
|
+
this.graphApi = new GraphAPIClient({
|
|
91
|
+
accessToken: config.accessToken,
|
|
92
|
+
appSecret: config.appSecret,
|
|
93
|
+
apiVersion: config.apiVersion,
|
|
94
|
+
baseUrl: config.baseUrl,
|
|
95
|
+
retry: config.retry,
|
|
96
|
+
rateLimiter: config.rateLimiter,
|
|
97
|
+
logger: config.logger,
|
|
98
|
+
});
|
|
99
|
+
this.formatConverterInstance = new WhatsAppFormatConverter();
|
|
100
|
+
this.mediaCache = config.mediaCache;
|
|
101
|
+
}
|
|
102
|
+
/** WhatsApp-specific format converter. */
|
|
103
|
+
get formatConverter() {
|
|
104
|
+
return this.formatConverterInstance;
|
|
105
|
+
}
|
|
106
|
+
// =========================================================================
|
|
107
|
+
// Lifecycle — handler registration
|
|
108
|
+
// =========================================================================
|
|
109
|
+
/**
|
|
110
|
+
* Register a handler for inbound messages.
|
|
111
|
+
*
|
|
112
|
+
* Multiple handlers can be registered; they are called in order for
|
|
113
|
+
* each incoming message.
|
|
114
|
+
*
|
|
115
|
+
* @param handler - Callback invoked with the normalised message and raw payload.
|
|
116
|
+
*/
|
|
117
|
+
onMessage(handler) {
|
|
118
|
+
this.messageHandlers.push(handler);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Register a handler for delivery/read status updates.
|
|
122
|
+
*
|
|
123
|
+
* @param handler - Callback invoked with the normalised status update.
|
|
124
|
+
*/
|
|
125
|
+
onStatus(handler) {
|
|
126
|
+
this.statusHandlers.push(handler);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Register a handler for reaction events.
|
|
130
|
+
*
|
|
131
|
+
* @param handler - Callback invoked with the normalised reaction data.
|
|
132
|
+
*/
|
|
133
|
+
onReaction(handler) {
|
|
134
|
+
this.reactionHandlers.push(handler);
|
|
135
|
+
}
|
|
136
|
+
// =========================================================================
|
|
137
|
+
// Webhook handling
|
|
138
|
+
// =========================================================================
|
|
139
|
+
/**
|
|
140
|
+
* Handle an incoming webhook request from Meta.
|
|
141
|
+
*
|
|
142
|
+
* - **GET** requests are treated as subscription verification challenges.
|
|
143
|
+
* - **POST** requests are validated via HMAC-SHA256 signature, normalised,
|
|
144
|
+
* and dispatched to registered handlers.
|
|
145
|
+
*
|
|
146
|
+
* @param request - The incoming HTTP request.
|
|
147
|
+
* @returns An HTTP response (200 OK, 401 Unauthorized, or 403 Forbidden).
|
|
148
|
+
*/
|
|
149
|
+
async handleWebhook(request) {
|
|
150
|
+
if (request.method === 'GET') {
|
|
151
|
+
return this.handleVerification(request);
|
|
152
|
+
}
|
|
153
|
+
const rawBody = await request.text();
|
|
154
|
+
const signature = request.headers.get('x-hub-signature-256');
|
|
155
|
+
if (!signature ||
|
|
156
|
+
!verifySignature({
|
|
157
|
+
appSecret: this.config.appSecret,
|
|
158
|
+
rawBody,
|
|
159
|
+
signatureHeader: signature,
|
|
160
|
+
})) {
|
|
161
|
+
return new Response('Unauthorized', { status: 401 });
|
|
162
|
+
}
|
|
163
|
+
const payload = JSON.parse(rawBody);
|
|
164
|
+
const events = normalizeWebhook(payload);
|
|
165
|
+
// Dispatch messages
|
|
166
|
+
for (const msg of events.messages) {
|
|
167
|
+
const inbound = this.toInboundMessage(msg);
|
|
168
|
+
for (const handler of this.messageHandlers) {
|
|
169
|
+
await handler(inbound, msg);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Dispatch statuses
|
|
173
|
+
for (const status of events.statuses) {
|
|
174
|
+
const update = this.toStatusUpdate(status);
|
|
175
|
+
for (const handler of this.statusHandlers) {
|
|
176
|
+
await handler(update);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Dispatch reactions
|
|
180
|
+
for (const reaction of events.reactions) {
|
|
181
|
+
const data = this.toReactionData(reaction);
|
|
182
|
+
for (const handler of this.reactionHandlers) {
|
|
183
|
+
await handler(data);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return new Response('OK', { status: 200 });
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Handle webhook verification (GET) from Meta.
|
|
190
|
+
*
|
|
191
|
+
* @param request - The GET request with `hub.mode`, `hub.verify_token`, and `hub.challenge`.
|
|
192
|
+
* @returns 200 with the challenge string, or 403 Forbidden.
|
|
193
|
+
*/
|
|
194
|
+
handleVerification(request) {
|
|
195
|
+
const url = new URL(request.url);
|
|
196
|
+
const mode = url.searchParams.get('hub.mode');
|
|
197
|
+
const token = url.searchParams.get('hub.verify_token');
|
|
198
|
+
const challenge = url.searchParams.get('hub.challenge');
|
|
199
|
+
if (mode === 'subscribe' && token === this.config.verifyToken) {
|
|
200
|
+
return new Response(challenge ?? '', { status: 200 });
|
|
201
|
+
}
|
|
202
|
+
return new Response('Forbidden', { status: 403 });
|
|
203
|
+
}
|
|
204
|
+
// =========================================================================
|
|
205
|
+
// Outbound — core PlatformClient methods
|
|
206
|
+
// =========================================================================
|
|
207
|
+
/**
|
|
208
|
+
* Send a text message.
|
|
209
|
+
*
|
|
210
|
+
* Long messages are automatically split at natural boundaries
|
|
211
|
+
* (paragraphs, lines, words) to stay within WhatsApp's 4096-character limit.
|
|
212
|
+
*
|
|
213
|
+
* @param to - Recipient phone number in E.164 format.
|
|
214
|
+
* @param text - The text content to send.
|
|
215
|
+
* @returns The result of the last chunk sent.
|
|
216
|
+
*/
|
|
217
|
+
async sendText(to, text) {
|
|
218
|
+
const chunks = splitMessage(text);
|
|
219
|
+
let result;
|
|
220
|
+
for (const chunk of chunks) {
|
|
221
|
+
result = await this.sendSingleText(to, chunk);
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Send a media message (image, video, audio, document, or sticker).
|
|
227
|
+
*
|
|
228
|
+
* Accepts URLs (string), Buffers (uploaded automatically), or ReadableStreams.
|
|
229
|
+
*
|
|
230
|
+
* @param to - Recipient phone number in E.164 format.
|
|
231
|
+
* @param media - The media payload to send.
|
|
232
|
+
* @returns The send result.
|
|
233
|
+
*/
|
|
234
|
+
async sendMedia(to, media) {
|
|
235
|
+
const mediaType = this.resolveMediaType(media.mimeType);
|
|
236
|
+
const mediaObj = {};
|
|
237
|
+
if (typeof media.data === 'string') {
|
|
238
|
+
mediaObj.link = media.data;
|
|
239
|
+
}
|
|
240
|
+
else if (Buffer.isBuffer(media.data)) {
|
|
241
|
+
const handle = await this.uploadMedia(media.data, {
|
|
242
|
+
mimeType: media.mimeType,
|
|
243
|
+
filename: media.filename,
|
|
244
|
+
});
|
|
245
|
+
mediaObj.id = handle.mediaId;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// ReadableStream — convert to Buffer first
|
|
249
|
+
const buffer = await streamToBuffer(media.data);
|
|
250
|
+
const handle = await this.uploadMedia(buffer, {
|
|
251
|
+
mimeType: media.mimeType,
|
|
252
|
+
filename: media.filename,
|
|
253
|
+
});
|
|
254
|
+
mediaObj.id = handle.mediaId;
|
|
255
|
+
}
|
|
256
|
+
if (media.caption)
|
|
257
|
+
mediaObj.caption = media.caption;
|
|
258
|
+
if (media.filename)
|
|
259
|
+
mediaObj.filename = media.filename;
|
|
260
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
261
|
+
messaging_product: 'whatsapp',
|
|
262
|
+
recipient_type: 'individual',
|
|
263
|
+
to,
|
|
264
|
+
type: mediaType,
|
|
265
|
+
[mediaType]: mediaObj,
|
|
266
|
+
});
|
|
267
|
+
return this.toSendResult(to, response);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Send an interactive message (buttons or list).
|
|
271
|
+
*
|
|
272
|
+
* Converts the platform-agnostic {@link InteractiveMessage} into WhatsApp's
|
|
273
|
+
* interactive message format.
|
|
274
|
+
*
|
|
275
|
+
* @param to - Recipient phone number in E.164 format.
|
|
276
|
+
* @param msg - The interactive message definition.
|
|
277
|
+
* @returns The send result.
|
|
278
|
+
* @throws {@link MessagingError} if the interactive type is unsupported.
|
|
279
|
+
*/
|
|
280
|
+
async sendInteractive(to, msg) {
|
|
281
|
+
if (msg.action.type === 'buttons') {
|
|
282
|
+
return this.sendInteractiveButtons(to, {
|
|
283
|
+
body: { text: msg.body },
|
|
284
|
+
header: msg.header?.type === 'text' ? { type: 'text', text: msg.header.content } : undefined,
|
|
285
|
+
footer: msg.footer ? { text: msg.footer } : undefined,
|
|
286
|
+
buttons: msg.action.buttons.map((b) => ({ id: b.id, title: b.title })),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (msg.action.type === 'list') {
|
|
290
|
+
return this.sendListMessage(to, {
|
|
291
|
+
body: { text: msg.body },
|
|
292
|
+
header: msg.header?.type === 'text' ? { type: 'text', text: msg.header.content } : undefined,
|
|
293
|
+
footer: msg.footer ? { text: msg.footer } : undefined,
|
|
294
|
+
button: msg.action.button,
|
|
295
|
+
sections: msg.action.sections,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (msg.action.type === 'flow') {
|
|
299
|
+
return this.sendInteractiveFlow(to, {
|
|
300
|
+
body: { text: msg.body },
|
|
301
|
+
footer: msg.footer ? { text: msg.footer } : undefined,
|
|
302
|
+
flowId: msg.action.flowId,
|
|
303
|
+
flowCta: 'Continue',
|
|
304
|
+
flowToken: msg.action.flowToken ?? '',
|
|
305
|
+
flowAction: 'navigate',
|
|
306
|
+
flowActionPayload: msg.action.parameters,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
throw new MessagingError(`Unsupported interactive type: ${msg.action.type}`, 'UNSUPPORTED_TYPE', 'whatsapp');
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Send a raw platform-specific payload.
|
|
313
|
+
*
|
|
314
|
+
* This is an escape hatch for features not covered by the normalised
|
|
315
|
+
* interface. The payload is merged with the standard messaging envelope.
|
|
316
|
+
*
|
|
317
|
+
* @param to - Recipient phone number in E.164 format.
|
|
318
|
+
* @param payload - Raw WhatsApp API payload fields.
|
|
319
|
+
* @returns The send result.
|
|
320
|
+
*/
|
|
321
|
+
async sendRaw(to, payload) {
|
|
322
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
323
|
+
messaging_product: 'whatsapp',
|
|
324
|
+
recipient_type: 'individual',
|
|
325
|
+
to,
|
|
326
|
+
...payload,
|
|
327
|
+
});
|
|
328
|
+
return this.toSendResult(to, response);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Mark a message as read on WhatsApp.
|
|
332
|
+
*
|
|
333
|
+
* Sends the blue double-tick indicator to the sender.
|
|
334
|
+
*
|
|
335
|
+
* @param messageId - The WhatsApp message ID to mark as read.
|
|
336
|
+
*/
|
|
337
|
+
async markAsRead(messageId) {
|
|
338
|
+
await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
339
|
+
messaging_product: 'whatsapp',
|
|
340
|
+
status: 'read',
|
|
341
|
+
message_id: messageId,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Send a typing indicator.
|
|
346
|
+
*
|
|
347
|
+
* WhatsApp Cloud API does not currently support typing indicators,
|
|
348
|
+
* so this method is a no-op that satisfies the {@link PlatformClient} interface.
|
|
349
|
+
*
|
|
350
|
+
* @param _to - Recipient phone number (unused).
|
|
351
|
+
*/
|
|
352
|
+
async sendTypingIndicator(_to) {
|
|
353
|
+
// WhatsApp Cloud API does not support typing indicators.
|
|
354
|
+
// This is a no-op to satisfy the PlatformClient interface.
|
|
355
|
+
}
|
|
356
|
+
// =========================================================================
|
|
357
|
+
// WhatsApp-specific — Templates
|
|
358
|
+
// =========================================================================
|
|
359
|
+
/**
|
|
360
|
+
* Send a pre-approved template message.
|
|
361
|
+
*
|
|
362
|
+
* Templates are the only message type allowed outside the 24-hour
|
|
363
|
+
* customer service window. They must be approved by Meta before use.
|
|
364
|
+
*
|
|
365
|
+
* @param to - Recipient phone number in E.164 format.
|
|
366
|
+
* @param template - The template message payload.
|
|
367
|
+
* @returns The send result.
|
|
368
|
+
*/
|
|
369
|
+
async sendTemplate(to, template) {
|
|
370
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
371
|
+
messaging_product: 'whatsapp',
|
|
372
|
+
recipient_type: 'individual',
|
|
373
|
+
to,
|
|
374
|
+
type: 'template',
|
|
375
|
+
template,
|
|
376
|
+
});
|
|
377
|
+
return this.toSendResult(to, response);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Send a text message with automatic template fallback.
|
|
381
|
+
*
|
|
382
|
+
* Attempts to send a free-form text message first. If the 24-hour
|
|
383
|
+
* customer service window has closed (indicated by a {@link WindowClosedError}),
|
|
384
|
+
* automatically falls back to the specified template message.
|
|
385
|
+
*
|
|
386
|
+
* @param to - Recipient phone number in E.164 format.
|
|
387
|
+
* @param opts - Text content and fallback template configuration.
|
|
388
|
+
* @returns The send result (from either the text or template delivery).
|
|
389
|
+
*/
|
|
390
|
+
async sendTextOrTemplate(to, opts) {
|
|
391
|
+
try {
|
|
392
|
+
return await this.sendText(to, opts.text);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (error instanceof WindowClosedError) {
|
|
396
|
+
return this.sendTemplate(to, opts.fallbackTemplate);
|
|
397
|
+
}
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// =========================================================================
|
|
402
|
+
// WhatsApp-specific — Interactive messages
|
|
403
|
+
// =========================================================================
|
|
404
|
+
/**
|
|
405
|
+
* Send a list interactive message.
|
|
406
|
+
*
|
|
407
|
+
* Lists display an expandable set of sections with selectable rows,
|
|
408
|
+
* useful for menus, catalogs, or multi-option selections.
|
|
409
|
+
*
|
|
410
|
+
* @param to - Recipient phone number in E.164 format.
|
|
411
|
+
* @param list - The list message configuration.
|
|
412
|
+
* @returns The send result.
|
|
413
|
+
*/
|
|
414
|
+
async sendListMessage(to, list) {
|
|
415
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
416
|
+
messaging_product: 'whatsapp',
|
|
417
|
+
recipient_type: 'individual',
|
|
418
|
+
to,
|
|
419
|
+
type: 'interactive',
|
|
420
|
+
interactive: {
|
|
421
|
+
type: 'list',
|
|
422
|
+
header: list.header,
|
|
423
|
+
body: list.body,
|
|
424
|
+
footer: list.footer,
|
|
425
|
+
action: {
|
|
426
|
+
button: list.button,
|
|
427
|
+
sections: list.sections,
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
return this.toSendResult(to, response);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Send a button interactive message.
|
|
435
|
+
*
|
|
436
|
+
* Supports up to 3 quick-reply buttons. Button titles are truncated
|
|
437
|
+
* to 20 characters per WhatsApp's requirements.
|
|
438
|
+
*
|
|
439
|
+
* @param to - Recipient phone number in E.164 format.
|
|
440
|
+
* @param msg - The button message configuration.
|
|
441
|
+
* @returns The send result.
|
|
442
|
+
*/
|
|
443
|
+
async sendInteractiveButtons(to, msg) {
|
|
444
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
445
|
+
messaging_product: 'whatsapp',
|
|
446
|
+
recipient_type: 'individual',
|
|
447
|
+
to,
|
|
448
|
+
type: 'interactive',
|
|
449
|
+
interactive: {
|
|
450
|
+
type: 'button',
|
|
451
|
+
header: msg.header,
|
|
452
|
+
body: msg.body,
|
|
453
|
+
footer: msg.footer,
|
|
454
|
+
action: {
|
|
455
|
+
buttons: msg.buttons.slice(0, 3).map((b) => ({
|
|
456
|
+
type: 'reply',
|
|
457
|
+
reply: { id: b.id, title: b.title.slice(0, 20) },
|
|
458
|
+
})),
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
return this.toSendResult(to, response);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Send a call-to-action URL button message.
|
|
466
|
+
*
|
|
467
|
+
* Renders a button that opens the specified URL in the user's browser
|
|
468
|
+
* when tapped.
|
|
469
|
+
*
|
|
470
|
+
* @param to - Recipient phone number in E.164 format.
|
|
471
|
+
* @param cta - The CTA button configuration.
|
|
472
|
+
* @returns The send result.
|
|
473
|
+
*/
|
|
474
|
+
async sendCTAButton(to, cta) {
|
|
475
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
476
|
+
messaging_product: 'whatsapp',
|
|
477
|
+
recipient_type: 'individual',
|
|
478
|
+
to,
|
|
479
|
+
type: 'interactive',
|
|
480
|
+
interactive: {
|
|
481
|
+
type: 'cta_url',
|
|
482
|
+
header: cta.header,
|
|
483
|
+
body: cta.body,
|
|
484
|
+
footer: cta.footer,
|
|
485
|
+
action: {
|
|
486
|
+
name: cta.name,
|
|
487
|
+
parameters: cta.parameters,
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
return this.toSendResult(to, response);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Send a WhatsApp Flow as an interactive message.
|
|
495
|
+
*
|
|
496
|
+
* WhatsApp Flows are multi-screen forms that run natively inside WhatsApp,
|
|
497
|
+
* enabling structured data collection without leaving the chat.
|
|
498
|
+
*
|
|
499
|
+
* @param to - Recipient phone number in E.164 format.
|
|
500
|
+
* @param flow - The flow interactive input configuration.
|
|
501
|
+
* @returns The send result.
|
|
502
|
+
*/
|
|
503
|
+
async sendInteractiveFlow(to, flow) {
|
|
504
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
505
|
+
messaging_product: 'whatsapp',
|
|
506
|
+
recipient_type: 'individual',
|
|
507
|
+
to,
|
|
508
|
+
type: 'interactive',
|
|
509
|
+
interactive: {
|
|
510
|
+
type: 'flow',
|
|
511
|
+
body: flow.body,
|
|
512
|
+
footer: flow.footer,
|
|
513
|
+
action: {
|
|
514
|
+
name: 'flow',
|
|
515
|
+
parameters: {
|
|
516
|
+
flow_message_version: '3',
|
|
517
|
+
flow_id: flow.flowId,
|
|
518
|
+
flow_cta: flow.flowCta,
|
|
519
|
+
flow_token: flow.flowToken,
|
|
520
|
+
flow_action: flow.flowAction,
|
|
521
|
+
flow_action_payload: flow.flowActionPayload,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
return this.toSendResult(to, response);
|
|
527
|
+
}
|
|
528
|
+
// =========================================================================
|
|
529
|
+
// WhatsApp-specific — Reactions, location, contacts
|
|
530
|
+
// =========================================================================
|
|
531
|
+
/**
|
|
532
|
+
* Send a reaction emoji on an existing message.
|
|
533
|
+
*
|
|
534
|
+
* To remove a reaction, pass an empty string as the emoji.
|
|
535
|
+
*
|
|
536
|
+
* @param to - Recipient phone number in E.164 format.
|
|
537
|
+
* @param messageId - The message to react to.
|
|
538
|
+
* @param emoji - The emoji to react with (e.g. `"\ud83d\udc4d"`).
|
|
539
|
+
* @returns The send result.
|
|
540
|
+
*/
|
|
541
|
+
async sendReaction(to, messageId, emoji) {
|
|
542
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
543
|
+
messaging_product: 'whatsapp',
|
|
544
|
+
recipient_type: 'individual',
|
|
545
|
+
to,
|
|
546
|
+
type: 'reaction',
|
|
547
|
+
reaction: { message_id: messageId, emoji },
|
|
548
|
+
});
|
|
549
|
+
return this.toSendResult(to, response);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Send a location pin message.
|
|
553
|
+
*
|
|
554
|
+
* Renders a map preview with the specified coordinates, name, and address.
|
|
555
|
+
*
|
|
556
|
+
* @param to - Recipient phone number in E.164 format.
|
|
557
|
+
* @param location - The location payload with coordinates and optional metadata.
|
|
558
|
+
* @returns The send result.
|
|
559
|
+
*/
|
|
560
|
+
async sendLocation(to, location) {
|
|
561
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
562
|
+
messaging_product: 'whatsapp',
|
|
563
|
+
recipient_type: 'individual',
|
|
564
|
+
to,
|
|
565
|
+
type: 'location',
|
|
566
|
+
location,
|
|
567
|
+
});
|
|
568
|
+
return this.toSendResult(to, response);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Send one or more contact cards.
|
|
572
|
+
*
|
|
573
|
+
* @param to - Recipient phone number in E.164 format.
|
|
574
|
+
* @param contacts - Array of contact payloads.
|
|
575
|
+
* @returns The send result.
|
|
576
|
+
*/
|
|
577
|
+
async sendContacts(to, contacts) {
|
|
578
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
579
|
+
messaging_product: 'whatsapp',
|
|
580
|
+
recipient_type: 'individual',
|
|
581
|
+
to,
|
|
582
|
+
type: 'contacts',
|
|
583
|
+
contacts,
|
|
584
|
+
});
|
|
585
|
+
return this.toSendResult(to, response);
|
|
586
|
+
}
|
|
587
|
+
// =========================================================================
|
|
588
|
+
// Template management
|
|
589
|
+
// =========================================================================
|
|
590
|
+
/**
|
|
591
|
+
* Template management operations.
|
|
592
|
+
*
|
|
593
|
+
* Provides methods to list, create, and delete message templates
|
|
594
|
+
* via the WhatsApp Business Management API.
|
|
595
|
+
*/
|
|
596
|
+
templates = {
|
|
597
|
+
/**
|
|
598
|
+
* List all message templates for a WhatsApp Business Account.
|
|
599
|
+
*
|
|
600
|
+
* @param wabaId - The WhatsApp Business Account ID.
|
|
601
|
+
* @returns Array of template information objects.
|
|
602
|
+
*/
|
|
603
|
+
list: async (wabaId) => {
|
|
604
|
+
const result = await this.graphApi.get(`${wabaId}/message_templates`);
|
|
605
|
+
return result.data;
|
|
606
|
+
},
|
|
607
|
+
/**
|
|
608
|
+
* Create a new message template.
|
|
609
|
+
*
|
|
610
|
+
* The template must be approved by Meta before it can be used for sending.
|
|
611
|
+
*
|
|
612
|
+
* @param wabaId - The WhatsApp Business Account ID.
|
|
613
|
+
* @param template - The template definition.
|
|
614
|
+
* @returns The created template information.
|
|
615
|
+
*/
|
|
616
|
+
create: async (wabaId, template) => {
|
|
617
|
+
return this.graphApi.post(`${wabaId}/message_templates`, template);
|
|
618
|
+
},
|
|
619
|
+
/**
|
|
620
|
+
* Delete a message template by name.
|
|
621
|
+
*
|
|
622
|
+
* Deletes all language variants of the template. Uses a POST with
|
|
623
|
+
* a `_method=DELETE` override since the Graph API client does not
|
|
624
|
+
* expose a dedicated DELETE method.
|
|
625
|
+
*
|
|
626
|
+
* @param wabaId - The WhatsApp Business Account ID.
|
|
627
|
+
* @param name - The template name to delete.
|
|
628
|
+
*/
|
|
629
|
+
delete: async (wabaId, name) => {
|
|
630
|
+
// The Graph API accepts DELETE for template removal. We use the
|
|
631
|
+
// GET endpoint with the name parameter for the query, but to
|
|
632
|
+
// actually delete we need to call the endpoint with method override.
|
|
633
|
+
// For simplicity, use a direct fetch via the low-level post with
|
|
634
|
+
// hsm_id approach — but the standard pattern is a POST to the
|
|
635
|
+
// message_templates endpoint with the name and a method override.
|
|
636
|
+
await this.graphApi.post(`${wabaId}/message_templates`, {
|
|
637
|
+
name,
|
|
638
|
+
_method: 'DELETE',
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
// =========================================================================
|
|
643
|
+
// Phone number management
|
|
644
|
+
// =========================================================================
|
|
645
|
+
/**
|
|
646
|
+
* Phone number management operations.
|
|
647
|
+
*
|
|
648
|
+
* Provides methods for phone number registration, verification,
|
|
649
|
+
* and business profile management.
|
|
650
|
+
*/
|
|
651
|
+
phoneNumbers = {
|
|
652
|
+
/**
|
|
653
|
+
* Request a verification code for a phone number.
|
|
654
|
+
*
|
|
655
|
+
* @param phoneNumberId - The phone number ID to verify.
|
|
656
|
+
* @param method - Delivery method (`"SMS"` or `"VOICE"`).
|
|
657
|
+
* @param language - Language code for the verification message.
|
|
658
|
+
*/
|
|
659
|
+
requestCode: async (phoneNumberId, method, language) => {
|
|
660
|
+
await this.graphApi.post(`${phoneNumberId}/request_code`, {
|
|
661
|
+
code_method: method,
|
|
662
|
+
language,
|
|
663
|
+
});
|
|
664
|
+
},
|
|
665
|
+
/**
|
|
666
|
+
* Verify a phone number with the received code.
|
|
667
|
+
*
|
|
668
|
+
* @param phoneNumberId - The phone number ID to verify.
|
|
669
|
+
* @param code - The verification code received via SMS or voice.
|
|
670
|
+
*/
|
|
671
|
+
verifyCode: async (phoneNumberId, code) => {
|
|
672
|
+
await this.graphApi.post(`${phoneNumberId}/verify_code`, { code });
|
|
673
|
+
},
|
|
674
|
+
/**
|
|
675
|
+
* Register a phone number for WhatsApp Business.
|
|
676
|
+
*
|
|
677
|
+
* @param phoneNumberId - The phone number ID to register.
|
|
678
|
+
* @param pin - A 6-digit PIN for two-step verification.
|
|
679
|
+
*/
|
|
680
|
+
register: async (phoneNumberId, pin) => {
|
|
681
|
+
await this.graphApi.post(`${phoneNumberId}/register`, {
|
|
682
|
+
messaging_product: 'whatsapp',
|
|
683
|
+
pin,
|
|
684
|
+
});
|
|
685
|
+
},
|
|
686
|
+
/**
|
|
687
|
+
* Get the WhatsApp Business profile for a phone number.
|
|
688
|
+
*
|
|
689
|
+
* @param phoneNumberId - The phone number ID.
|
|
690
|
+
* @returns The business profile data.
|
|
691
|
+
*/
|
|
692
|
+
getBusinessProfile: async (phoneNumberId) => {
|
|
693
|
+
const result = await this.graphApi.get(`${phoneNumberId}/whatsapp_business_profile`, { fields: 'about,address,description,email,profile_picture_url,websites,vertical' });
|
|
694
|
+
return result.data[0] ?? {};
|
|
695
|
+
},
|
|
696
|
+
/**
|
|
697
|
+
* Update the WhatsApp Business profile for a phone number.
|
|
698
|
+
*
|
|
699
|
+
* @param phoneNumberId - The phone number ID.
|
|
700
|
+
* @param profile - The profile fields to update.
|
|
701
|
+
*/
|
|
702
|
+
updateBusinessProfile: async (phoneNumberId, profile) => {
|
|
703
|
+
await this.graphApi.post(`${phoneNumberId}/whatsapp_business_profile`, {
|
|
704
|
+
messaging_product: 'whatsapp',
|
|
705
|
+
...profile,
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
// =========================================================================
|
|
710
|
+
// WhatsApp Flows management
|
|
711
|
+
// =========================================================================
|
|
712
|
+
/**
|
|
713
|
+
* WhatsApp Flows management operations.
|
|
714
|
+
*
|
|
715
|
+
* Provides methods to create, update, publish, deprecate, and inspect
|
|
716
|
+
* WhatsApp Flows.
|
|
717
|
+
*/
|
|
718
|
+
flows = {
|
|
719
|
+
/**
|
|
720
|
+
* Create a new WhatsApp Flow.
|
|
721
|
+
*
|
|
722
|
+
* @param wabaId - The WhatsApp Business Account ID.
|
|
723
|
+
* @param flow - The flow definition.
|
|
724
|
+
* @returns The created flow information.
|
|
725
|
+
*/
|
|
726
|
+
create: async (wabaId, flow) => {
|
|
727
|
+
return this.graphApi.post(`${wabaId}/flows`, flow);
|
|
728
|
+
},
|
|
729
|
+
/**
|
|
730
|
+
* Update an existing WhatsApp Flow.
|
|
731
|
+
*
|
|
732
|
+
* @param flowId - The flow ID to update.
|
|
733
|
+
* @param flow - The fields to update.
|
|
734
|
+
* @returns The updated flow information.
|
|
735
|
+
*/
|
|
736
|
+
update: async (flowId, flow) => {
|
|
737
|
+
return this.graphApi.post(flowId, flow);
|
|
738
|
+
},
|
|
739
|
+
/**
|
|
740
|
+
* Publish a draft WhatsApp Flow, making it available for use in messages.
|
|
741
|
+
*
|
|
742
|
+
* @param flowId - The flow ID to publish.
|
|
743
|
+
*/
|
|
744
|
+
publish: async (flowId) => {
|
|
745
|
+
await this.graphApi.post(`${flowId}/publish`, {});
|
|
746
|
+
},
|
|
747
|
+
/**
|
|
748
|
+
* Deprecate a WhatsApp Flow (soft delete).
|
|
749
|
+
*
|
|
750
|
+
* Deprecated flows can no longer be sent but existing instances continue working.
|
|
751
|
+
*
|
|
752
|
+
* @param flowId - The flow ID to deprecate.
|
|
753
|
+
*/
|
|
754
|
+
delete: async (flowId) => {
|
|
755
|
+
await this.graphApi.post(flowId, { status: 'DEPRECATED' });
|
|
756
|
+
},
|
|
757
|
+
/**
|
|
758
|
+
* Get the assets (JSON definition) for a WhatsApp Flow.
|
|
759
|
+
*
|
|
760
|
+
* @param flowId - The flow ID.
|
|
761
|
+
* @returns The flow assets including download URLs.
|
|
762
|
+
*/
|
|
763
|
+
getAssets: async (flowId) => {
|
|
764
|
+
return this.graphApi.get(`${flowId}/assets`);
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
// =========================================================================
|
|
768
|
+
// Media
|
|
769
|
+
// =========================================================================
|
|
770
|
+
/**
|
|
771
|
+
* Upload media to WhatsApp for later use in messages.
|
|
772
|
+
*
|
|
773
|
+
* @param file - The file content as a Buffer or ReadableStream.
|
|
774
|
+
* @param options - Upload options including MIME type and optional filename.
|
|
775
|
+
* @returns A handle containing the platform-assigned media ID.
|
|
776
|
+
*/
|
|
777
|
+
async uploadMedia(file, options) {
|
|
778
|
+
const buffer = Buffer.isBuffer(file) ? file : await streamToBuffer(file);
|
|
779
|
+
const blob = new Blob([buffer], { type: options.mimeType });
|
|
780
|
+
const formData = new FormData();
|
|
781
|
+
formData.append('file', blob, options.filename ?? 'file');
|
|
782
|
+
formData.append('messaging_product', 'whatsapp');
|
|
783
|
+
formData.append('type', options.mimeType);
|
|
784
|
+
const response = await this.graphApi.postFormData(`${this.config.phoneNumberId}/media`, formData);
|
|
785
|
+
return { mediaId: response.id };
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Download media from WhatsApp by its ID.
|
|
789
|
+
*
|
|
790
|
+
* Performs a two-step process: first retrieves the temporary download URL
|
|
791
|
+
* via the media info endpoint, then downloads the binary content.
|
|
792
|
+
*
|
|
793
|
+
* @param mediaId - The WhatsApp media ID.
|
|
794
|
+
* @returns The downloaded media content, MIME type, and optional filename.
|
|
795
|
+
*/
|
|
796
|
+
async downloadMedia(mediaId) {
|
|
797
|
+
if (this.mediaCache) {
|
|
798
|
+
return this.mediaCache.getOrDownload(mediaId, async () => {
|
|
799
|
+
const mediaInfo = await this.graphApi.get(mediaId);
|
|
800
|
+
const data = await this.graphApi.fetchBinary(mediaInfo.url);
|
|
801
|
+
return { data, mimeType: mediaInfo.mime_type };
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const mediaInfo = await this.graphApi.get(mediaId);
|
|
805
|
+
const data = await this.graphApi.fetchBinary(mediaInfo.url);
|
|
806
|
+
return { data, mimeType: mediaInfo.mime_type };
|
|
807
|
+
}
|
|
808
|
+
// =========================================================================
|
|
809
|
+
// Webhook router
|
|
810
|
+
// =========================================================================
|
|
811
|
+
/**
|
|
812
|
+
* Returns a Hono sub-application that handles webhook routes.
|
|
813
|
+
*
|
|
814
|
+
* Mounts GET (verification) and POST (event delivery) handlers at the
|
|
815
|
+
* root path. Use this to integrate the webhook into an existing Hono app.
|
|
816
|
+
*
|
|
817
|
+
* Requires `hono` to be installed as a peer dependency. The import is
|
|
818
|
+
* performed dynamically to avoid a hard compile-time dependency.
|
|
819
|
+
*
|
|
820
|
+
* @returns A Hono application with webhook routes.
|
|
821
|
+
*
|
|
822
|
+
* @example
|
|
823
|
+
* ```ts
|
|
824
|
+
* import { Hono } from 'hono';
|
|
825
|
+
*
|
|
826
|
+
* const app = new Hono();
|
|
827
|
+
* app.route('/webhooks/whatsapp', client.webhookRouter());
|
|
828
|
+
* ```
|
|
829
|
+
*/
|
|
830
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
831
|
+
webhookRouter() {
|
|
832
|
+
// Dynamic import to avoid hard dependency on Hono at the module level.
|
|
833
|
+
// The caller must have Hono installed (it's a peer dependency via @ariaflowagents/messaging).
|
|
834
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
835
|
+
const { Hono } = require('hono');
|
|
836
|
+
const router = new Hono();
|
|
837
|
+
const self = this;
|
|
838
|
+
router.all('/*', async (c) => {
|
|
839
|
+
return self.handleWebhook(c.req.raw);
|
|
840
|
+
});
|
|
841
|
+
return router;
|
|
842
|
+
}
|
|
843
|
+
// =========================================================================
|
|
844
|
+
// Private — conversion helpers
|
|
845
|
+
// =========================================================================
|
|
846
|
+
/**
|
|
847
|
+
* Convert a normalised webhook message to a platform-agnostic {@link InboundMessage}.
|
|
848
|
+
*/
|
|
849
|
+
toInboundMessage(msg) {
|
|
850
|
+
const threadId = `whatsapp:${this.config.phoneNumberId}:${msg.from}`;
|
|
851
|
+
return {
|
|
852
|
+
id: msg.id,
|
|
853
|
+
platform: 'whatsapp',
|
|
854
|
+
threadId,
|
|
855
|
+
from: {
|
|
856
|
+
id: msg.from,
|
|
857
|
+
name: msg.contactName,
|
|
858
|
+
phone: msg.from,
|
|
859
|
+
},
|
|
860
|
+
timestamp: new Date(parseInt(msg.timestamp, 10) * 1000),
|
|
861
|
+
type: this.mapMessageType(msg.type),
|
|
862
|
+
text: msg.text?.body ?? this.extractTextFallback(msg),
|
|
863
|
+
media: this.extractMedia(msg),
|
|
864
|
+
location: msg.location,
|
|
865
|
+
interactive: msg.interactive
|
|
866
|
+
? {
|
|
867
|
+
type: msg.interactive.type,
|
|
868
|
+
id: msg.interactive.button_reply?.id ??
|
|
869
|
+
msg.interactive.list_reply?.id ??
|
|
870
|
+
'',
|
|
871
|
+
title: msg.interactive.button_reply?.title ??
|
|
872
|
+
msg.interactive.list_reply?.title,
|
|
873
|
+
description: msg.interactive.list_reply?.description,
|
|
874
|
+
}
|
|
875
|
+
: undefined,
|
|
876
|
+
context: msg.context
|
|
877
|
+
? { messageId: msg.context.message_id, from: msg.context.from }
|
|
878
|
+
: undefined,
|
|
879
|
+
raw: msg,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Convert a normalised webhook status to a platform-agnostic {@link StatusUpdate}.
|
|
884
|
+
*/
|
|
885
|
+
toStatusUpdate(status) {
|
|
886
|
+
return {
|
|
887
|
+
messageId: status.id,
|
|
888
|
+
status: status.status,
|
|
889
|
+
timestamp: new Date(parseInt(status.timestamp, 10) * 1000),
|
|
890
|
+
recipientId: status.recipientId,
|
|
891
|
+
threadId: `whatsapp:${status.phoneNumberId}:${status.recipientId}`,
|
|
892
|
+
error: status.errors?.[0]
|
|
893
|
+
? {
|
|
894
|
+
code: String(status.errors[0].code),
|
|
895
|
+
title: status.errors[0].title,
|
|
896
|
+
message: status.errors[0].message,
|
|
897
|
+
}
|
|
898
|
+
: undefined,
|
|
899
|
+
conversation: status.conversation
|
|
900
|
+
? {
|
|
901
|
+
id: status.conversation.id,
|
|
902
|
+
expirationTimestamp: status.conversation.expiration_timestamp
|
|
903
|
+
? new Date(parseInt(status.conversation.expiration_timestamp, 10) * 1000)
|
|
904
|
+
: undefined,
|
|
905
|
+
origin: status.conversation.origin?.type,
|
|
906
|
+
}
|
|
907
|
+
: undefined,
|
|
908
|
+
pricing: status.pricing
|
|
909
|
+
? {
|
|
910
|
+
model: status.pricing.pricing_model,
|
|
911
|
+
category: status.pricing.category,
|
|
912
|
+
}
|
|
913
|
+
: undefined,
|
|
914
|
+
raw: status,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Convert a normalised webhook reaction to a platform-agnostic {@link ReactionData}.
|
|
919
|
+
*/
|
|
920
|
+
toReactionData(reaction) {
|
|
921
|
+
return {
|
|
922
|
+
messageId: reaction.messageId,
|
|
923
|
+
emoji: reaction.emoji,
|
|
924
|
+
action: reaction.emoji ? 'react' : 'unreact',
|
|
925
|
+
userId: reaction.from,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Convert a {@link WhatsAppSendResponse} to a platform-agnostic {@link SendResult}.
|
|
930
|
+
*/
|
|
931
|
+
toSendResult(to, response) {
|
|
932
|
+
return {
|
|
933
|
+
messageId: response.messages[0]?.id ?? '',
|
|
934
|
+
threadId: `whatsapp:${this.config.phoneNumberId}:${to}`,
|
|
935
|
+
timestamp: new Date(),
|
|
936
|
+
raw: response,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Map a WhatsApp message type string to the normalised {@link InboundMessage} type union.
|
|
941
|
+
*/
|
|
942
|
+
mapMessageType(type) {
|
|
943
|
+
const typeMap = {
|
|
944
|
+
text: 'text',
|
|
945
|
+
image: 'image',
|
|
946
|
+
video: 'video',
|
|
947
|
+
audio: 'audio',
|
|
948
|
+
document: 'document',
|
|
949
|
+
sticker: 'sticker',
|
|
950
|
+
location: 'location',
|
|
951
|
+
contacts: 'contacts',
|
|
952
|
+
interactive: 'interactive',
|
|
953
|
+
button: 'interactive',
|
|
954
|
+
reaction: 'reaction',
|
|
955
|
+
};
|
|
956
|
+
return typeMap[type] ?? 'unknown';
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Extract a text fallback from non-text message types (captions, button text, etc.).
|
|
960
|
+
*/
|
|
961
|
+
extractTextFallback(msg) {
|
|
962
|
+
if (msg.image?.caption)
|
|
963
|
+
return msg.image.caption;
|
|
964
|
+
if (msg.video?.caption)
|
|
965
|
+
return msg.video.caption;
|
|
966
|
+
if (msg.document?.caption)
|
|
967
|
+
return msg.document.caption;
|
|
968
|
+
if (msg.button)
|
|
969
|
+
return msg.button.text;
|
|
970
|
+
if (msg.interactive?.button_reply)
|
|
971
|
+
return msg.interactive.button_reply.title;
|
|
972
|
+
if (msg.interactive?.list_reply)
|
|
973
|
+
return msg.interactive.list_reply.title;
|
|
974
|
+
if (msg.location) {
|
|
975
|
+
return msg.location.name ?? `${msg.location.latitude},${msg.location.longitude}`;
|
|
976
|
+
}
|
|
977
|
+
return undefined;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Extract a {@link MediaReference} from a normalised message if it contains media.
|
|
981
|
+
*/
|
|
982
|
+
extractMedia(msg) {
|
|
983
|
+
const mediaTypes = ['image', 'video', 'audio', 'document', 'sticker'];
|
|
984
|
+
for (const type of mediaTypes) {
|
|
985
|
+
const media = msg[type];
|
|
986
|
+
if (media && 'id' in media) {
|
|
987
|
+
return {
|
|
988
|
+
id: media.id,
|
|
989
|
+
mimeType: 'mime_type' in media ? media.mime_type : undefined,
|
|
990
|
+
caption: 'caption' in media ? media.caption : undefined,
|
|
991
|
+
filename: 'filename' in media ? media.filename : undefined,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return undefined;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Send a single text message (without splitting).
|
|
999
|
+
*/
|
|
1000
|
+
async sendSingleText(to, text) {
|
|
1001
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
1002
|
+
messaging_product: 'whatsapp',
|
|
1003
|
+
recipient_type: 'individual',
|
|
1004
|
+
to,
|
|
1005
|
+
type: 'text',
|
|
1006
|
+
text: { preview_url: false, body: text },
|
|
1007
|
+
});
|
|
1008
|
+
return this.toSendResult(to, response);
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Resolve a MIME type to a WhatsApp media type string.
|
|
1012
|
+
*/
|
|
1013
|
+
resolveMediaType(mimeType) {
|
|
1014
|
+
if (mimeType.startsWith('image/'))
|
|
1015
|
+
return 'image';
|
|
1016
|
+
if (mimeType.startsWith('video/'))
|
|
1017
|
+
return 'video';
|
|
1018
|
+
if (mimeType.startsWith('audio/'))
|
|
1019
|
+
return 'audio';
|
|
1020
|
+
return 'document';
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
// Utility
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
/**
|
|
1027
|
+
* Convert a `ReadableStream` to a `Buffer`.
|
|
1028
|
+
*
|
|
1029
|
+
* @param stream - The readable stream to consume.
|
|
1030
|
+
* @returns A Buffer containing all chunks from the stream.
|
|
1031
|
+
*/
|
|
1032
|
+
async function streamToBuffer(stream) {
|
|
1033
|
+
const chunks = [];
|
|
1034
|
+
const reader = stream.getReader();
|
|
1035
|
+
for (;;) {
|
|
1036
|
+
const { done, value } = await reader.read();
|
|
1037
|
+
if (done)
|
|
1038
|
+
break;
|
|
1039
|
+
chunks.push(value);
|
|
1040
|
+
}
|
|
1041
|
+
return Buffer.concat(chunks);
|
|
1042
|
+
}
|
|
1043
|
+
//# sourceMappingURL=client.js.map
|