@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,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module messenger/client
|
|
3
|
+
*
|
|
4
|
+
* Facebook Messenger Platform client implementing the {@link PlatformClient}
|
|
5
|
+
* interface from `@ariaflowagents/messaging`.
|
|
6
|
+
*
|
|
7
|
+
* Provides a complete, production-ready integration with Meta's Messenger
|
|
8
|
+
* Platform including:
|
|
9
|
+
*
|
|
10
|
+
* - Sending text, media, interactive (button/generic templates), and quick replies
|
|
11
|
+
* - Webhook handling with signature verification
|
|
12
|
+
* - Sender actions (typing indicators, mark-seen)
|
|
13
|
+
* - User profile retrieval
|
|
14
|
+
* - Persona management (create, delete)
|
|
15
|
+
* - Media upload and download
|
|
16
|
+
* - Messenger-specific format conversion (plain text)
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { createMessengerClient } from '@ariaflowagents/messaging-meta/messenger';
|
|
21
|
+
*
|
|
22
|
+
* const client = createMessengerClient({
|
|
23
|
+
* pageAccessToken: process.env.MESSENGER_PAGE_ACCESS_TOKEN!,
|
|
24
|
+
* appSecret: process.env.META_APP_SECRET!,
|
|
25
|
+
* pageId: process.env.MESSENGER_PAGE_ID!,
|
|
26
|
+
* verifyToken: process.env.MESSENGER_VERIFY_TOKEN!,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* client.onMessage(async (msg) => {
|
|
30
|
+
* await client.sendText(msg.from.id, `Echo: ${msg.text}`);
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
import { MessagingError, } from '@ariaflowagents/messaging';
|
|
35
|
+
import { GraphAPIClient } from '../graph-api/client.js';
|
|
36
|
+
import { verifySignature } from '../webhook/verifier.js';
|
|
37
|
+
import { normalizeWebhook } from '../webhook/normalizer.js';
|
|
38
|
+
import { MessengerFormatConverter } from './format.js';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Constants
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Maximum text message length for the Messenger Platform. */
|
|
43
|
+
const MESSENGER_TEXT_LIMIT = 2000;
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Factory
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* Create a new {@link MessengerClient} instance.
|
|
49
|
+
*
|
|
50
|
+
* This is the recommended entry point. It constructs the client with all
|
|
51
|
+
* internal dependencies wired up (Graph API client, format converter).
|
|
52
|
+
*
|
|
53
|
+
* @param config - Messenger client configuration.
|
|
54
|
+
* @returns A fully configured {@link MessengerClient}.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const messenger = createMessengerClient({
|
|
59
|
+
* pageAccessToken: process.env.MESSENGER_PAGE_ACCESS_TOKEN!,
|
|
60
|
+
* appSecret: process.env.META_APP_SECRET!,
|
|
61
|
+
* pageId: process.env.MESSENGER_PAGE_ID!,
|
|
62
|
+
* verifyToken: process.env.MESSENGER_VERIFY_TOKEN!,
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function createMessengerClient(config) {
|
|
67
|
+
return new MessengerClient(config);
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// MessengerClient
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* Full-featured Facebook Messenger Platform client.
|
|
74
|
+
*
|
|
75
|
+
* Implements the `PlatformClient` interface from `@ariaflowagents/messaging`
|
|
76
|
+
* so it can be plugged into the messaging adapter layer, while also exposing
|
|
77
|
+
* Messenger-specific capabilities (button templates, generic templates,
|
|
78
|
+
* quick replies, personas, user profiles).
|
|
79
|
+
*
|
|
80
|
+
* Use {@link createMessengerClient} to instantiate.
|
|
81
|
+
*/
|
|
82
|
+
export class MessengerClient {
|
|
83
|
+
/** @inheritdoc */
|
|
84
|
+
platform = 'messenger';
|
|
85
|
+
graphApi;
|
|
86
|
+
config;
|
|
87
|
+
messageHandlers = [];
|
|
88
|
+
statusHandlers = [];
|
|
89
|
+
reactionHandlers = [];
|
|
90
|
+
formatConverterInstance;
|
|
91
|
+
mediaCache;
|
|
92
|
+
constructor(config) {
|
|
93
|
+
this.config = config;
|
|
94
|
+
this.graphApi = new GraphAPIClient({
|
|
95
|
+
accessToken: config.pageAccessToken,
|
|
96
|
+
appSecret: config.appSecret,
|
|
97
|
+
apiVersion: config.apiVersion,
|
|
98
|
+
baseUrl: config.baseUrl,
|
|
99
|
+
retry: config.retry,
|
|
100
|
+
rateLimiter: config.rateLimiter,
|
|
101
|
+
logger: config.logger,
|
|
102
|
+
});
|
|
103
|
+
this.formatConverterInstance = new MessengerFormatConverter();
|
|
104
|
+
this.mediaCache = config.mediaCache;
|
|
105
|
+
}
|
|
106
|
+
/** Messenger-specific format converter. */
|
|
107
|
+
get formatConverter() {
|
|
108
|
+
return this.formatConverterInstance;
|
|
109
|
+
}
|
|
110
|
+
// =========================================================================
|
|
111
|
+
// Lifecycle — handler registration
|
|
112
|
+
// =========================================================================
|
|
113
|
+
/**
|
|
114
|
+
* Register a handler for inbound messages.
|
|
115
|
+
*
|
|
116
|
+
* Multiple handlers can be registered; they are called in order for
|
|
117
|
+
* each incoming message.
|
|
118
|
+
*
|
|
119
|
+
* @param handler - Callback invoked with the normalised message and raw payload.
|
|
120
|
+
*/
|
|
121
|
+
onMessage(handler) {
|
|
122
|
+
this.messageHandlers.push(handler);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Register a handler for delivery/read status updates.
|
|
126
|
+
*
|
|
127
|
+
* @param handler - Callback invoked with the normalised status update.
|
|
128
|
+
*/
|
|
129
|
+
onStatus(handler) {
|
|
130
|
+
this.statusHandlers.push(handler);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Register a handler for reaction events.
|
|
134
|
+
*
|
|
135
|
+
* @param handler - Callback invoked with the normalised reaction data.
|
|
136
|
+
*/
|
|
137
|
+
onReaction(handler) {
|
|
138
|
+
this.reactionHandlers.push(handler);
|
|
139
|
+
}
|
|
140
|
+
// =========================================================================
|
|
141
|
+
// Webhook handling
|
|
142
|
+
// =========================================================================
|
|
143
|
+
/**
|
|
144
|
+
* Handle an incoming webhook request from Meta.
|
|
145
|
+
*
|
|
146
|
+
* - **GET** requests are treated as subscription verification challenges.
|
|
147
|
+
* - **POST** requests are validated via HMAC-SHA256 signature, normalised,
|
|
148
|
+
* and dispatched to registered handlers.
|
|
149
|
+
*
|
|
150
|
+
* @param request - The incoming HTTP request.
|
|
151
|
+
* @returns An HTTP response (200 OK, 401 Unauthorized, or 403 Forbidden).
|
|
152
|
+
*/
|
|
153
|
+
async handleWebhook(request) {
|
|
154
|
+
if (request.method === 'GET') {
|
|
155
|
+
return this.handleVerification(request);
|
|
156
|
+
}
|
|
157
|
+
const rawBody = await request.text();
|
|
158
|
+
const signature = request.headers.get('x-hub-signature-256');
|
|
159
|
+
if (!signature ||
|
|
160
|
+
!verifySignature({
|
|
161
|
+
appSecret: this.config.appSecret,
|
|
162
|
+
rawBody,
|
|
163
|
+
signatureHeader: signature,
|
|
164
|
+
})) {
|
|
165
|
+
return new Response('Unauthorized', { status: 401 });
|
|
166
|
+
}
|
|
167
|
+
const payload = JSON.parse(rawBody);
|
|
168
|
+
const events = normalizeWebhook(payload);
|
|
169
|
+
// Dispatch messages
|
|
170
|
+
for (const msg of events.messages) {
|
|
171
|
+
const inbound = this.toInboundMessage(msg);
|
|
172
|
+
for (const handler of this.messageHandlers) {
|
|
173
|
+
await handler(inbound, msg);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Dispatch statuses
|
|
177
|
+
for (const status of events.statuses) {
|
|
178
|
+
const update = this.toStatusUpdate(status);
|
|
179
|
+
for (const handler of this.statusHandlers) {
|
|
180
|
+
await handler(update);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Dispatch reactions
|
|
184
|
+
for (const reaction of events.reactions) {
|
|
185
|
+
const data = this.toReactionData(reaction);
|
|
186
|
+
for (const handler of this.reactionHandlers) {
|
|
187
|
+
await handler(data);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return new Response('OK', { status: 200 });
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Handle webhook verification (GET) from Meta.
|
|
194
|
+
*
|
|
195
|
+
* @param request - The GET request with `hub.mode`, `hub.verify_token`, and `hub.challenge`.
|
|
196
|
+
* @returns 200 with the challenge string, or 403 Forbidden.
|
|
197
|
+
*/
|
|
198
|
+
handleVerification(request) {
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
const mode = url.searchParams.get('hub.mode');
|
|
201
|
+
const token = url.searchParams.get('hub.verify_token');
|
|
202
|
+
const challenge = url.searchParams.get('hub.challenge');
|
|
203
|
+
if (mode === 'subscribe' && token === this.config.verifyToken) {
|
|
204
|
+
return new Response(challenge ?? '', { status: 200 });
|
|
205
|
+
}
|
|
206
|
+
return new Response('Forbidden', { status: 403 });
|
|
207
|
+
}
|
|
208
|
+
// =========================================================================
|
|
209
|
+
// Outbound — core PlatformClient methods
|
|
210
|
+
// =========================================================================
|
|
211
|
+
/**
|
|
212
|
+
* Send a text message.
|
|
213
|
+
*
|
|
214
|
+
* Messages exceeding the 2000-character Messenger limit are truncated.
|
|
215
|
+
*
|
|
216
|
+
* @param to - Recipient PSID.
|
|
217
|
+
* @param text - The text content to send.
|
|
218
|
+
* @returns The send result.
|
|
219
|
+
*/
|
|
220
|
+
async sendText(to, text) {
|
|
221
|
+
const truncated = text.length > MESSENGER_TEXT_LIMIT
|
|
222
|
+
? text.slice(0, MESSENGER_TEXT_LIMIT)
|
|
223
|
+
: text;
|
|
224
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
225
|
+
recipient: { id: to },
|
|
226
|
+
messaging_type: 'RESPONSE',
|
|
227
|
+
message: { text: truncated },
|
|
228
|
+
});
|
|
229
|
+
return this.toSendResult(to, response);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Send a media message (image, video, audio, or file).
|
|
233
|
+
*
|
|
234
|
+
* Accepts URLs (string), Buffers (uploaded automatically), or ReadableStreams.
|
|
235
|
+
*
|
|
236
|
+
* @param to - Recipient PSID.
|
|
237
|
+
* @param media - The media payload to send.
|
|
238
|
+
* @returns The send result.
|
|
239
|
+
*/
|
|
240
|
+
async sendMedia(to, media) {
|
|
241
|
+
const attachmentType = this.resolveAttachmentType(media.mimeType);
|
|
242
|
+
if (typeof media.data === 'string') {
|
|
243
|
+
// Send via URL
|
|
244
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
245
|
+
recipient: { id: to },
|
|
246
|
+
messaging_type: 'RESPONSE',
|
|
247
|
+
message: {
|
|
248
|
+
attachment: {
|
|
249
|
+
type: attachmentType,
|
|
250
|
+
payload: { url: media.data, is_reusable: true },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
return this.toSendResult(to, response);
|
|
255
|
+
}
|
|
256
|
+
// Buffer or ReadableStream — upload first
|
|
257
|
+
const buffer = Buffer.isBuffer(media.data)
|
|
258
|
+
? media.data
|
|
259
|
+
: await streamToBuffer(media.data);
|
|
260
|
+
const handle = await this.uploadMedia(buffer, {
|
|
261
|
+
mimeType: media.mimeType,
|
|
262
|
+
filename: media.filename,
|
|
263
|
+
});
|
|
264
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
265
|
+
recipient: { id: to },
|
|
266
|
+
messaging_type: 'RESPONSE',
|
|
267
|
+
message: {
|
|
268
|
+
attachment: {
|
|
269
|
+
type: attachmentType,
|
|
270
|
+
payload: { attachment_id: handle.mediaId },
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return this.toSendResult(to, response);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Send an interactive message (buttons or list).
|
|
278
|
+
*
|
|
279
|
+
* Converts the platform-agnostic {@link InteractiveMessage} into
|
|
280
|
+
* Messenger's template format:
|
|
281
|
+
* - `action.type === 'buttons'` maps to a button template
|
|
282
|
+
* - `action.type === 'list'` maps to a generic template (carousel)
|
|
283
|
+
*
|
|
284
|
+
* @param to - Recipient PSID.
|
|
285
|
+
* @param msg - The interactive message definition.
|
|
286
|
+
* @returns The send result.
|
|
287
|
+
* @throws {@link MessagingError} if the interactive type is unsupported.
|
|
288
|
+
*/
|
|
289
|
+
async sendInteractive(to, msg) {
|
|
290
|
+
if (msg.action.type === 'buttons') {
|
|
291
|
+
return this.sendButtonTemplate(to, {
|
|
292
|
+
text: msg.body,
|
|
293
|
+
buttons: msg.action.buttons.map((b) => ({
|
|
294
|
+
type: 'postback',
|
|
295
|
+
title: b.title,
|
|
296
|
+
payload: b.id,
|
|
297
|
+
})),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (msg.action.type === 'list') {
|
|
301
|
+
// Map list sections to generic template elements
|
|
302
|
+
const elements = msg.action.sections.flatMap((section) => section.rows.map((row) => ({
|
|
303
|
+
title: row.title,
|
|
304
|
+
subtitle: row.description,
|
|
305
|
+
buttons: [
|
|
306
|
+
{
|
|
307
|
+
type: 'postback',
|
|
308
|
+
title: row.title.slice(0, 20),
|
|
309
|
+
payload: row.id,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
})));
|
|
313
|
+
return this.sendGenericTemplate(to, { elements: elements.slice(0, 10) });
|
|
314
|
+
}
|
|
315
|
+
throw new MessagingError(`Unsupported interactive type: ${msg.action.type}`, 'UNSUPPORTED_TYPE', 'messenger');
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Send a raw platform-specific payload.
|
|
319
|
+
*
|
|
320
|
+
* This is an escape hatch for features not covered by the normalised
|
|
321
|
+
* interface. The payload is merged with the standard messaging envelope.
|
|
322
|
+
*
|
|
323
|
+
* @param to - Recipient PSID.
|
|
324
|
+
* @param payload - Raw Messenger API payload fields.
|
|
325
|
+
* @returns The send result.
|
|
326
|
+
*/
|
|
327
|
+
async sendRaw(to, payload) {
|
|
328
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
329
|
+
recipient: { id: to },
|
|
330
|
+
messaging_type: 'RESPONSE',
|
|
331
|
+
...payload,
|
|
332
|
+
});
|
|
333
|
+
return this.toSendResult(to, response);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Mark a conversation as seen.
|
|
337
|
+
*
|
|
338
|
+
* Sends a `mark_seen` sender action to the recipient. Note that unlike
|
|
339
|
+
* WhatsApp, Messenger's mark-seen operates on the recipient (PSID) rather
|
|
340
|
+
* than a specific message ID.
|
|
341
|
+
*
|
|
342
|
+
* @param messageId - The message ID (used to identify the recipient via
|
|
343
|
+
* stored mapping; falls back to treating it as a PSID).
|
|
344
|
+
*/
|
|
345
|
+
async markAsRead(messageId) {
|
|
346
|
+
// Messenger's mark_seen is a sender action that requires the recipient ID,
|
|
347
|
+
// not a message ID. We accept the recipient PSID here since the platform
|
|
348
|
+
// does not support marking individual messages.
|
|
349
|
+
await this.sendSenderAction(messageId, 'mark_seen');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Send a typing indicator to a recipient.
|
|
353
|
+
*
|
|
354
|
+
* Sends the `typing_on` sender action. The indicator is automatically
|
|
355
|
+
* dismissed after 20 seconds or when a message is sent.
|
|
356
|
+
*
|
|
357
|
+
* @param to - Recipient PSID.
|
|
358
|
+
*/
|
|
359
|
+
async sendTypingIndicator(to) {
|
|
360
|
+
await this.sendSenderAction(to, 'typing_on');
|
|
361
|
+
}
|
|
362
|
+
// =========================================================================
|
|
363
|
+
// Messenger-specific — Templates
|
|
364
|
+
// =========================================================================
|
|
365
|
+
/**
|
|
366
|
+
* Send a button template message.
|
|
367
|
+
*
|
|
368
|
+
* Displays a text message with up to 3 buttons below it. Buttons can be
|
|
369
|
+
* postback (returns a payload to the webhook) or web URL (opens a browser).
|
|
370
|
+
*
|
|
371
|
+
* @param to - Recipient PSID.
|
|
372
|
+
* @param template - The button template payload.
|
|
373
|
+
* @returns The send result.
|
|
374
|
+
*/
|
|
375
|
+
async sendButtonTemplate(to, template) {
|
|
376
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
377
|
+
recipient: { id: to },
|
|
378
|
+
messaging_type: 'RESPONSE',
|
|
379
|
+
message: {
|
|
380
|
+
attachment: {
|
|
381
|
+
type: 'template',
|
|
382
|
+
payload: {
|
|
383
|
+
template_type: 'button',
|
|
384
|
+
text: template.text,
|
|
385
|
+
buttons: template.buttons.slice(0, 3),
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
return this.toSendResult(to, response);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Send a generic template (carousel) message.
|
|
394
|
+
*
|
|
395
|
+
* Displays a horizontally scrollable set of cards, each with an image,
|
|
396
|
+
* title, subtitle, and optional buttons.
|
|
397
|
+
*
|
|
398
|
+
* @param to - Recipient PSID.
|
|
399
|
+
* @param template - The generic template payload.
|
|
400
|
+
* @returns The send result.
|
|
401
|
+
*/
|
|
402
|
+
async sendGenericTemplate(to, template) {
|
|
403
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
404
|
+
recipient: { id: to },
|
|
405
|
+
messaging_type: 'RESPONSE',
|
|
406
|
+
message: {
|
|
407
|
+
attachment: {
|
|
408
|
+
type: 'template',
|
|
409
|
+
payload: {
|
|
410
|
+
template_type: 'generic',
|
|
411
|
+
elements: template.elements.slice(0, 10),
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
return this.toSendResult(to, response);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Send quick reply options with a text message.
|
|
420
|
+
*
|
|
421
|
+
* Quick replies appear as pill-shaped buttons above the composer and
|
|
422
|
+
* disappear once the user taps one or types a message.
|
|
423
|
+
*
|
|
424
|
+
* @param to - Recipient PSID.
|
|
425
|
+
* @param text - The text message to display above the quick replies.
|
|
426
|
+
* @param replies - Array of quick reply options (max 13).
|
|
427
|
+
* @returns The send result.
|
|
428
|
+
*/
|
|
429
|
+
async sendQuickReplies(to, text, replies) {
|
|
430
|
+
const response = await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
431
|
+
recipient: { id: to },
|
|
432
|
+
messaging_type: 'RESPONSE',
|
|
433
|
+
message: {
|
|
434
|
+
text,
|
|
435
|
+
quick_replies: replies.slice(0, 13),
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
return this.toSendResult(to, response);
|
|
439
|
+
}
|
|
440
|
+
// =========================================================================
|
|
441
|
+
// Messenger-specific — Sender Actions
|
|
442
|
+
// =========================================================================
|
|
443
|
+
/**
|
|
444
|
+
* Send a sender action (typing indicator or mark-seen).
|
|
445
|
+
*
|
|
446
|
+
* Sender actions cannot be combined with a `message` field — they must
|
|
447
|
+
* be sent as separate requests.
|
|
448
|
+
*
|
|
449
|
+
* @param to - Recipient PSID.
|
|
450
|
+
* @param action - The sender action (`"typing_on"`, `"typing_off"`, or `"mark_seen"`).
|
|
451
|
+
*/
|
|
452
|
+
async sendSenderAction(to, action) {
|
|
453
|
+
await this.graphApi.post(`${this.config.pageId}/messages`, {
|
|
454
|
+
recipient: { id: to },
|
|
455
|
+
sender_action: action,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// =========================================================================
|
|
459
|
+
// Messenger-specific — User Profile
|
|
460
|
+
// =========================================================================
|
|
461
|
+
/**
|
|
462
|
+
* Retrieve a user's public profile information.
|
|
463
|
+
*
|
|
464
|
+
* Returns the first name, last name, and profile picture URL for the
|
|
465
|
+
* given PSID.
|
|
466
|
+
*
|
|
467
|
+
* @param psid - The user's page-scoped ID.
|
|
468
|
+
* @returns The user's profile information.
|
|
469
|
+
*/
|
|
470
|
+
async getUserProfile(psid) {
|
|
471
|
+
return this.graphApi.get(psid, {
|
|
472
|
+
fields: 'first_name,last_name,profile_pic',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// =========================================================================
|
|
476
|
+
// Messenger-specific — Persona API
|
|
477
|
+
// =========================================================================
|
|
478
|
+
/**
|
|
479
|
+
* Persona management operations.
|
|
480
|
+
*
|
|
481
|
+
* Personas allow the bot to respond as a named human agent with a
|
|
482
|
+
* custom profile picture. Useful for the HUMAN_AGENT message tag flow.
|
|
483
|
+
*/
|
|
484
|
+
personas = {
|
|
485
|
+
/**
|
|
486
|
+
* Create a new persona for the page.
|
|
487
|
+
*
|
|
488
|
+
* @param config - The persona name and profile picture URL.
|
|
489
|
+
* @returns The created persona with its ID.
|
|
490
|
+
*/
|
|
491
|
+
create: async (config) => {
|
|
492
|
+
return this.graphApi.post(`${this.config.pageId}/personas`, config);
|
|
493
|
+
},
|
|
494
|
+
/**
|
|
495
|
+
* Delete a persona.
|
|
496
|
+
*
|
|
497
|
+
* @param personaId - The persona ID to delete.
|
|
498
|
+
*/
|
|
499
|
+
delete: async (personaId) => {
|
|
500
|
+
await this.graphApi.post(personaId, { _method: 'DELETE' });
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
// =========================================================================
|
|
504
|
+
// Media
|
|
505
|
+
// =========================================================================
|
|
506
|
+
/**
|
|
507
|
+
* Upload media to Messenger for later use in messages.
|
|
508
|
+
*
|
|
509
|
+
* Uses the Attachment Upload API to upload files and receive an
|
|
510
|
+
* `attachment_id` that can be reused across messages.
|
|
511
|
+
*
|
|
512
|
+
* @param file - The file content as a Buffer or ReadableStream.
|
|
513
|
+
* @param options - Upload options including MIME type and optional filename.
|
|
514
|
+
* @returns A handle containing the platform-assigned media ID.
|
|
515
|
+
*/
|
|
516
|
+
async uploadMedia(file, options) {
|
|
517
|
+
const buffer = Buffer.isBuffer(file) ? file : await streamToBuffer(file);
|
|
518
|
+
const blob = new Blob([buffer], { type: options.mimeType });
|
|
519
|
+
const attachmentType = this.resolveAttachmentType(options.mimeType);
|
|
520
|
+
const formData = new FormData();
|
|
521
|
+
formData.append('message', JSON.stringify({
|
|
522
|
+
attachment: {
|
|
523
|
+
type: attachmentType,
|
|
524
|
+
payload: { is_reusable: true },
|
|
525
|
+
},
|
|
526
|
+
}));
|
|
527
|
+
formData.append('filedata', blob, options.filename ?? 'file');
|
|
528
|
+
const response = await this.graphApi.postFormData(`${this.config.pageId}/message_attachments`, formData);
|
|
529
|
+
return { mediaId: response.attachment_id };
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Download media from a URL.
|
|
533
|
+
*
|
|
534
|
+
* Messenger media URLs are typically publicly accessible (unlike WhatsApp
|
|
535
|
+
* which requires a two-step media-ID-to-URL lookup). This method fetches
|
|
536
|
+
* the binary content from the provided URL.
|
|
537
|
+
*
|
|
538
|
+
* @param mediaUrl - The media URL to download from.
|
|
539
|
+
* @returns The downloaded media content and MIME type.
|
|
540
|
+
*/
|
|
541
|
+
async downloadMedia(mediaUrl) {
|
|
542
|
+
if (this.mediaCache) {
|
|
543
|
+
return this.mediaCache.getOrDownload(mediaUrl, async () => {
|
|
544
|
+
const data = await this.graphApi.fetchBinary(mediaUrl);
|
|
545
|
+
const mimeType = inferMimeType(mediaUrl);
|
|
546
|
+
return { data, mimeType };
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const data = await this.graphApi.fetchBinary(mediaUrl);
|
|
550
|
+
// Attempt to infer MIME type from URL extension
|
|
551
|
+
const mimeType = inferMimeType(mediaUrl);
|
|
552
|
+
return { data, mimeType };
|
|
553
|
+
}
|
|
554
|
+
// =========================================================================
|
|
555
|
+
// Webhook router
|
|
556
|
+
// =========================================================================
|
|
557
|
+
/**
|
|
558
|
+
* Returns a Hono sub-application that handles webhook routes.
|
|
559
|
+
*
|
|
560
|
+
* Mounts GET (verification) and POST (event delivery) handlers at the
|
|
561
|
+
* root path. Use this to integrate the webhook into an existing Hono app.
|
|
562
|
+
*
|
|
563
|
+
* Requires `hono` to be installed as a peer dependency. The import is
|
|
564
|
+
* performed dynamically to avoid a hard compile-time dependency.
|
|
565
|
+
*
|
|
566
|
+
* @returns A Hono application with webhook routes.
|
|
567
|
+
*
|
|
568
|
+
* @example
|
|
569
|
+
* ```ts
|
|
570
|
+
* import { Hono } from 'hono';
|
|
571
|
+
*
|
|
572
|
+
* const app = new Hono();
|
|
573
|
+
* app.route('/webhooks/messenger', client.webhookRouter());
|
|
574
|
+
* ```
|
|
575
|
+
*/
|
|
576
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
577
|
+
webhookRouter() {
|
|
578
|
+
// Dynamic import to avoid hard dependency on Hono at the module level.
|
|
579
|
+
// The caller must have Hono installed (it's a peer dependency via @ariaflowagents/messaging).
|
|
580
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
581
|
+
const { Hono } = require('hono');
|
|
582
|
+
const router = new Hono();
|
|
583
|
+
const self = this;
|
|
584
|
+
router.all('/*', async (c) => {
|
|
585
|
+
return self.handleWebhook(c.req.raw);
|
|
586
|
+
});
|
|
587
|
+
return router;
|
|
588
|
+
}
|
|
589
|
+
// =========================================================================
|
|
590
|
+
// Private — conversion helpers
|
|
591
|
+
// =========================================================================
|
|
592
|
+
/**
|
|
593
|
+
* Convert a normalised webhook message to a platform-agnostic {@link InboundMessage}.
|
|
594
|
+
*/
|
|
595
|
+
toInboundMessage(msg) {
|
|
596
|
+
const threadId = `messenger:${this.config.pageId}:${msg.from}`;
|
|
597
|
+
return {
|
|
598
|
+
id: msg.id,
|
|
599
|
+
platform: 'messenger',
|
|
600
|
+
threadId,
|
|
601
|
+
from: {
|
|
602
|
+
id: msg.from,
|
|
603
|
+
name: msg.contactName,
|
|
604
|
+
},
|
|
605
|
+
timestamp: new Date(parseInt(msg.timestamp, 10) * 1000),
|
|
606
|
+
type: this.mapMessageType(msg.type),
|
|
607
|
+
text: msg.text?.body ?? this.extractTextFallback(msg),
|
|
608
|
+
media: this.extractMedia(msg),
|
|
609
|
+
location: msg.location,
|
|
610
|
+
interactive: msg.interactive
|
|
611
|
+
? {
|
|
612
|
+
type: msg.interactive.type,
|
|
613
|
+
id: msg.interactive.button_reply?.id ??
|
|
614
|
+
msg.interactive.list_reply?.id ??
|
|
615
|
+
'',
|
|
616
|
+
title: msg.interactive.button_reply?.title ??
|
|
617
|
+
msg.interactive.list_reply?.title,
|
|
618
|
+
description: msg.interactive.list_reply?.description,
|
|
619
|
+
}
|
|
620
|
+
: undefined,
|
|
621
|
+
context: msg.context
|
|
622
|
+
? { messageId: msg.context.message_id, from: msg.context.from }
|
|
623
|
+
: undefined,
|
|
624
|
+
raw: msg,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Convert a normalised webhook status to a platform-agnostic {@link StatusUpdate}.
|
|
629
|
+
*/
|
|
630
|
+
toStatusUpdate(status) {
|
|
631
|
+
return {
|
|
632
|
+
messageId: status.id,
|
|
633
|
+
status: status.status,
|
|
634
|
+
timestamp: new Date(parseInt(status.timestamp, 10) * 1000),
|
|
635
|
+
recipientId: status.recipientId,
|
|
636
|
+
threadId: `messenger:${status.phoneNumberId}:${status.recipientId}`,
|
|
637
|
+
raw: status,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Convert a normalised webhook reaction to a platform-agnostic {@link ReactionData}.
|
|
642
|
+
*/
|
|
643
|
+
toReactionData(reaction) {
|
|
644
|
+
return {
|
|
645
|
+
messageId: reaction.messageId,
|
|
646
|
+
emoji: reaction.emoji,
|
|
647
|
+
action: reaction.emoji ? 'react' : 'unreact',
|
|
648
|
+
userId: reaction.from,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Convert a {@link MessengerSendResponse} to a platform-agnostic {@link SendResult}.
|
|
653
|
+
*/
|
|
654
|
+
toSendResult(to, response) {
|
|
655
|
+
return {
|
|
656
|
+
messageId: response.message_id ?? '',
|
|
657
|
+
threadId: `messenger:${this.config.pageId}:${to}`,
|
|
658
|
+
timestamp: new Date(),
|
|
659
|
+
raw: response,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Map a Messenger message type string to the normalised {@link InboundMessage} type union.
|
|
664
|
+
*/
|
|
665
|
+
mapMessageType(type) {
|
|
666
|
+
const typeMap = {
|
|
667
|
+
text: 'text',
|
|
668
|
+
image: 'image',
|
|
669
|
+
video: 'video',
|
|
670
|
+
audio: 'audio',
|
|
671
|
+
file: 'document',
|
|
672
|
+
document: 'document',
|
|
673
|
+
sticker: 'sticker',
|
|
674
|
+
location: 'location',
|
|
675
|
+
postback: 'interactive',
|
|
676
|
+
interactive: 'interactive',
|
|
677
|
+
attachment: 'unknown',
|
|
678
|
+
};
|
|
679
|
+
return typeMap[type] ?? 'unknown';
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Extract a text fallback from non-text message types.
|
|
683
|
+
*/
|
|
684
|
+
extractTextFallback(msg) {
|
|
685
|
+
if (msg.image?.caption)
|
|
686
|
+
return msg.image.caption;
|
|
687
|
+
if (msg.video?.caption)
|
|
688
|
+
return msg.video.caption;
|
|
689
|
+
if (msg.document?.caption)
|
|
690
|
+
return msg.document.caption;
|
|
691
|
+
if (msg.button)
|
|
692
|
+
return msg.button.text;
|
|
693
|
+
if (msg.interactive?.button_reply)
|
|
694
|
+
return msg.interactive.button_reply.title;
|
|
695
|
+
if (msg.interactive?.list_reply)
|
|
696
|
+
return msg.interactive.list_reply.title;
|
|
697
|
+
if (msg.location) {
|
|
698
|
+
return msg.location.name ?? `${msg.location.latitude},${msg.location.longitude}`;
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Extract a {@link MediaReference} from a normalised message if it contains media.
|
|
704
|
+
*/
|
|
705
|
+
extractMedia(msg) {
|
|
706
|
+
const mediaTypes = ['image', 'video', 'audio', 'document', 'sticker'];
|
|
707
|
+
for (const type of mediaTypes) {
|
|
708
|
+
const media = msg[type];
|
|
709
|
+
if (media && ('id' in media || 'url' in media)) {
|
|
710
|
+
return {
|
|
711
|
+
id: 'id' in media ? media.id : '',
|
|
712
|
+
mimeType: 'mime_type' in media ? media.mime_type : undefined,
|
|
713
|
+
url: 'url' in media ? media.url : undefined,
|
|
714
|
+
caption: 'caption' in media ? media.caption : undefined,
|
|
715
|
+
filename: 'filename' in media ? media.filename : undefined,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Resolve a MIME type to a Messenger attachment type string.
|
|
723
|
+
*/
|
|
724
|
+
resolveAttachmentType(mimeType) {
|
|
725
|
+
if (mimeType.startsWith('image/'))
|
|
726
|
+
return 'image';
|
|
727
|
+
if (mimeType.startsWith('video/'))
|
|
728
|
+
return 'video';
|
|
729
|
+
if (mimeType.startsWith('audio/'))
|
|
730
|
+
return 'audio';
|
|
731
|
+
return 'file';
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Utility
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
/**
|
|
738
|
+
* Convert a `ReadableStream` to a `Buffer`.
|
|
739
|
+
*
|
|
740
|
+
* @param stream - The readable stream to consume.
|
|
741
|
+
* @returns A Buffer containing all chunks from the stream.
|
|
742
|
+
*/
|
|
743
|
+
async function streamToBuffer(stream) {
|
|
744
|
+
const chunks = [];
|
|
745
|
+
const reader = stream.getReader();
|
|
746
|
+
for (;;) {
|
|
747
|
+
const { done, value } = await reader.read();
|
|
748
|
+
if (done)
|
|
749
|
+
break;
|
|
750
|
+
chunks.push(value);
|
|
751
|
+
}
|
|
752
|
+
return Buffer.concat(chunks);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Infer a MIME type from a URL based on file extension.
|
|
756
|
+
*
|
|
757
|
+
* @param url - The URL to infer from.
|
|
758
|
+
* @returns A best-guess MIME type, defaulting to `"application/octet-stream"`.
|
|
759
|
+
*/
|
|
760
|
+
function inferMimeType(url) {
|
|
761
|
+
const extMatch = url.match(/\.(\w+)(?:\?|$)/);
|
|
762
|
+
if (!extMatch)
|
|
763
|
+
return 'application/octet-stream';
|
|
764
|
+
const ext = extMatch[1].toLowerCase();
|
|
765
|
+
const mimeMap = {
|
|
766
|
+
jpg: 'image/jpeg',
|
|
767
|
+
jpeg: 'image/jpeg',
|
|
768
|
+
png: 'image/png',
|
|
769
|
+
gif: 'image/gif',
|
|
770
|
+
webp: 'image/webp',
|
|
771
|
+
mp4: 'video/mp4',
|
|
772
|
+
mov: 'video/quicktime',
|
|
773
|
+
mp3: 'audio/mpeg',
|
|
774
|
+
ogg: 'audio/ogg',
|
|
775
|
+
wav: 'audio/wav',
|
|
776
|
+
pdf: 'application/pdf',
|
|
777
|
+
doc: 'application/msword',
|
|
778
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
779
|
+
};
|
|
780
|
+
return mimeMap[ext] ?? 'application/octet-stream';
|
|
781
|
+
}
|
|
782
|
+
//# sourceMappingURL=client.js.map
|