@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.
Files changed (106) hide show
  1. package/README.md +236 -0
  2. package/dist/graph-api/client.d.ts +125 -0
  3. package/dist/graph-api/client.d.ts.map +1 -0
  4. package/dist/graph-api/client.js +204 -0
  5. package/dist/graph-api/client.js.map +1 -0
  6. package/dist/graph-api/errors.d.ts +41 -0
  7. package/dist/graph-api/errors.d.ts.map +1 -0
  8. package/dist/graph-api/errors.js +72 -0
  9. package/dist/graph-api/errors.js.map +1 -0
  10. package/dist/graph-api/index.d.ts +15 -0
  11. package/dist/graph-api/index.d.ts.map +1 -0
  12. package/dist/graph-api/index.js +11 -0
  13. package/dist/graph-api/index.js.map +1 -0
  14. package/dist/graph-api/rate-limiter.d.ts +90 -0
  15. package/dist/graph-api/rate-limiter.d.ts.map +1 -0
  16. package/dist/graph-api/rate-limiter.js +172 -0
  17. package/dist/graph-api/rate-limiter.js.map +1 -0
  18. package/dist/graph-api/retry.d.ts +55 -0
  19. package/dist/graph-api/retry.d.ts.map +1 -0
  20. package/dist/graph-api/retry.js +103 -0
  21. package/dist/graph-api/retry.js.map +1 -0
  22. package/dist/index.d.ts +36 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +38 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/instagram/client.d.ts +365 -0
  27. package/dist/instagram/client.d.ts.map +1 -0
  28. package/dist/instagram/client.js +857 -0
  29. package/dist/instagram/client.js.map +1 -0
  30. package/dist/instagram/format.d.ts +62 -0
  31. package/dist/instagram/format.d.ts.map +1 -0
  32. package/dist/instagram/format.js +92 -0
  33. package/dist/instagram/format.js.map +1 -0
  34. package/dist/instagram/ice-breakers.d.ts +63 -0
  35. package/dist/instagram/ice-breakers.d.ts.map +1 -0
  36. package/dist/instagram/ice-breakers.js +87 -0
  37. package/dist/instagram/ice-breakers.js.map +1 -0
  38. package/dist/instagram/index.d.ts +44 -0
  39. package/dist/instagram/index.d.ts.map +1 -0
  40. package/dist/instagram/index.js +46 -0
  41. package/dist/instagram/index.js.map +1 -0
  42. package/dist/instagram/types.d.ts +188 -0
  43. package/dist/instagram/types.d.ts.map +1 -0
  44. package/dist/instagram/types.js +19 -0
  45. package/dist/instagram/types.js.map +1 -0
  46. package/dist/messenger/client.d.ts +339 -0
  47. package/dist/messenger/client.d.ts.map +1 -0
  48. package/dist/messenger/client.js +782 -0
  49. package/dist/messenger/client.js.map +1 -0
  50. package/dist/messenger/format.d.ts +69 -0
  51. package/dist/messenger/format.d.ts.map +1 -0
  52. package/dist/messenger/format.js +98 -0
  53. package/dist/messenger/format.js.map +1 -0
  54. package/dist/messenger/index.d.ts +34 -0
  55. package/dist/messenger/index.d.ts.map +1 -0
  56. package/dist/messenger/index.js +35 -0
  57. package/dist/messenger/index.js.map +1 -0
  58. package/dist/messenger/types.d.ts +181 -0
  59. package/dist/messenger/types.d.ts.map +1 -0
  60. package/dist/messenger/types.js +10 -0
  61. package/dist/messenger/types.js.map +1 -0
  62. package/dist/server.d.ts +31 -0
  63. package/dist/server.d.ts.map +1 -0
  64. package/dist/server.js +29 -0
  65. package/dist/server.js.map +1 -0
  66. package/dist/webhook/index.d.ts +10 -0
  67. package/dist/webhook/index.d.ts.map +1 -0
  68. package/dist/webhook/index.js +8 -0
  69. package/dist/webhook/index.js.map +1 -0
  70. package/dist/webhook/normalizer.d.ts +169 -0
  71. package/dist/webhook/normalizer.d.ts.map +1 -0
  72. package/dist/webhook/normalizer.js +301 -0
  73. package/dist/webhook/normalizer.js.map +1 -0
  74. package/dist/webhook/verifier.d.ts +45 -0
  75. package/dist/webhook/verifier.d.ts.map +1 -0
  76. package/dist/webhook/verifier.js +62 -0
  77. package/dist/webhook/verifier.js.map +1 -0
  78. package/dist/whatsapp/client.d.ts +481 -0
  79. package/dist/whatsapp/client.d.ts.map +1 -0
  80. package/dist/whatsapp/client.js +1043 -0
  81. package/dist/whatsapp/client.js.map +1 -0
  82. package/dist/whatsapp/flows.d.ts +74 -0
  83. package/dist/whatsapp/flows.d.ts.map +1 -0
  84. package/dist/whatsapp/flows.js +77 -0
  85. package/dist/whatsapp/flows.js.map +1 -0
  86. package/dist/whatsapp/format.d.ts +78 -0
  87. package/dist/whatsapp/format.d.ts.map +1 -0
  88. package/dist/whatsapp/format.js +195 -0
  89. package/dist/whatsapp/format.js.map +1 -0
  90. package/dist/whatsapp/index.d.ts +39 -0
  91. package/dist/whatsapp/index.d.ts.map +1 -0
  92. package/dist/whatsapp/index.js +42 -0
  93. package/dist/whatsapp/index.js.map +1 -0
  94. package/dist/whatsapp/split.d.ts +35 -0
  95. package/dist/whatsapp/split.d.ts.map +1 -0
  96. package/dist/whatsapp/split.js +76 -0
  97. package/dist/whatsapp/split.js.map +1 -0
  98. package/dist/whatsapp/templates.d.ts +129 -0
  99. package/dist/whatsapp/templates.d.ts.map +1 -0
  100. package/dist/whatsapp/templates.js +125 -0
  101. package/dist/whatsapp/templates.js.map +1 -0
  102. package/dist/whatsapp/types.d.ts +440 -0
  103. package/dist/whatsapp/types.d.ts.map +1 -0
  104. package/dist/whatsapp/types.js +11 -0
  105. package/dist/whatsapp/types.js.map +1 -0
  106. package/package.json +31 -0
@@ -0,0 +1,857 @@
1
+ /**
2
+ * @module instagram/client
3
+ *
4
+ * Instagram Messaging API client implementing the {@link PlatformClient}
5
+ * interface from `@ariaflowagents/messaging`.
6
+ *
7
+ * Provides a complete, production-ready integration with Meta's Instagram
8
+ * Messaging API including:
9
+ *
10
+ * - Sending text, image, quick reply, and template messages
11
+ * - Webhook handling with HMAC-SHA256 signature verification
12
+ * - Generic template (carousel) and button template messages
13
+ * - Private replies to comments on posts and reels
14
+ * - Ice breaker management (set, get, delete)
15
+ * - Typing indicator support
16
+ * - Smart message splitting for the 1000-byte limit
17
+ * - Instagram-specific format conversion (plain text)
18
+ *
19
+ * Key differences from WhatsApp / Messenger:
20
+ * - Uses `graph.instagram.com` as the base URL (not `graph.facebook.com`).
21
+ * - Only IMAGE attachments are supported (no video, audio, or file).
22
+ * - Message limit is 1000 bytes (UTF-8 encoded), not characters.
23
+ * - Send response contains only `message_id` (no `recipient_id`).
24
+ * - Only `HUMAN_AGENT` message tag is supported (7-day window).
25
+ * - Ice breakers replace persistent menus.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { createInstagramClient } from '@ariaflowagents/messaging-meta/instagram';
30
+ *
31
+ * const client = createInstagramClient({
32
+ * accessToken: process.env.INSTAGRAM_ACCESS_TOKEN!,
33
+ * appSecret: process.env.META_APP_SECRET!,
34
+ * igId: process.env.INSTAGRAM_ACCOUNT_ID!,
35
+ * verifyToken: process.env.INSTAGRAM_VERIFY_TOKEN!,
36
+ * });
37
+ *
38
+ * client.onMessage(async (msg) => {
39
+ * await client.sendText(msg.from.id, `Echo: ${msg.text}`);
40
+ * });
41
+ * ```
42
+ */
43
+ import { MessagingError, MediaError, } from '@ariaflowagents/messaging';
44
+ import { GraphAPIClient } from '../graph-api/client.js';
45
+ import { verifySignature } from '../webhook/verifier.js';
46
+ import { normalizeWebhook } from '../webhook/normalizer.js';
47
+ import { InstagramFormatConverter } from './format.js';
48
+ // ---------------------------------------------------------------------------
49
+ // Constants
50
+ // ---------------------------------------------------------------------------
51
+ /**
52
+ * Maximum message size in bytes for Instagram messages.
53
+ * Instagram enforces a 1000-byte limit on text messages (UTF-8 encoded).
54
+ */
55
+ const MAX_MESSAGE_BYTES = 1000;
56
+ /** Maximum number of quick replies per message. */
57
+ const MAX_QUICK_REPLIES = 13;
58
+ // ---------------------------------------------------------------------------
59
+ // Factory
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Create a new {@link InstagramClient} instance.
63
+ *
64
+ * This is the recommended entry point. It constructs the client with all
65
+ * internal dependencies wired up (Graph API client, format converter).
66
+ *
67
+ * @param config - Instagram client configuration.
68
+ * @returns A fully configured {@link InstagramClient}.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const instagram = createInstagramClient({
73
+ * accessToken: process.env.INSTAGRAM_ACCESS_TOKEN!,
74
+ * appSecret: process.env.META_APP_SECRET!,
75
+ * igId: process.env.INSTAGRAM_ACCOUNT_ID!,
76
+ * verifyToken: process.env.INSTAGRAM_VERIFY_TOKEN!,
77
+ * });
78
+ * ```
79
+ */
80
+ export function createInstagramClient(config) {
81
+ return new InstagramClient(config);
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // InstagramClient
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Full-featured Instagram Messaging API client.
88
+ *
89
+ * Implements the `PlatformClient` interface from `@ariaflowagents/messaging`
90
+ * so it can be plugged into the messaging adapter layer, while also exposing
91
+ * Instagram-specific capabilities (quick replies, templates, ice breakers,
92
+ * private replies to comments).
93
+ *
94
+ * Use {@link createInstagramClient} to instantiate.
95
+ */
96
+ export class InstagramClient {
97
+ /** @inheritdoc */
98
+ platform = 'instagram';
99
+ graphApi;
100
+ config;
101
+ messageHandlers = [];
102
+ statusHandlers = [];
103
+ reactionHandlers = [];
104
+ formatConverterInstance;
105
+ mediaCache;
106
+ constructor(config) {
107
+ this.config = config;
108
+ this.graphApi = new GraphAPIClient({
109
+ accessToken: config.accessToken,
110
+ appSecret: config.appSecret,
111
+ apiVersion: config.apiVersion ?? 'v24.0',
112
+ baseUrl: config.baseUrl ?? 'https://graph.instagram.com',
113
+ retry: config.retry,
114
+ rateLimiter: config.rateLimiter,
115
+ logger: config.logger,
116
+ });
117
+ this.formatConverterInstance = new InstagramFormatConverter();
118
+ this.mediaCache = config.mediaCache;
119
+ }
120
+ /** Instagram-specific format converter (plain text). */
121
+ get formatConverter() {
122
+ return this.formatConverterInstance;
123
+ }
124
+ // =========================================================================
125
+ // Lifecycle -- handler registration
126
+ // =========================================================================
127
+ /**
128
+ * Register a handler for inbound messages.
129
+ *
130
+ * Multiple handlers can be registered; they are called in order for
131
+ * each incoming message.
132
+ *
133
+ * @param handler - Callback invoked with the normalised message and raw payload.
134
+ */
135
+ onMessage(handler) {
136
+ this.messageHandlers.push(handler);
137
+ }
138
+ /**
139
+ * Register a handler for delivery/read status updates.
140
+ *
141
+ * @param handler - Callback invoked with the normalised status update.
142
+ */
143
+ onStatus(handler) {
144
+ this.statusHandlers.push(handler);
145
+ }
146
+ /**
147
+ * Register a handler for reaction events.
148
+ *
149
+ * @param handler - Callback invoked with the normalised reaction data.
150
+ */
151
+ onReaction(handler) {
152
+ this.reactionHandlers.push(handler);
153
+ }
154
+ // =========================================================================
155
+ // Webhook handling
156
+ // =========================================================================
157
+ /**
158
+ * Handle an incoming webhook request from Meta.
159
+ *
160
+ * - **GET** requests are treated as subscription verification challenges.
161
+ * - **POST** requests are validated via HMAC-SHA256 signature, normalised,
162
+ * and dispatched to registered handlers.
163
+ *
164
+ * @param request - The incoming HTTP request.
165
+ * @returns An HTTP response (200 OK, 401 Unauthorized, or 403 Forbidden).
166
+ */
167
+ async handleWebhook(request) {
168
+ if (request.method === 'GET') {
169
+ return this.handleVerification(request);
170
+ }
171
+ const rawBody = await request.text();
172
+ const signature = request.headers.get('x-hub-signature-256');
173
+ if (!signature ||
174
+ !verifySignature({
175
+ appSecret: this.config.appSecret,
176
+ rawBody,
177
+ signatureHeader: signature,
178
+ })) {
179
+ return new Response('Unauthorized', { status: 401 });
180
+ }
181
+ const payload = JSON.parse(rawBody);
182
+ const events = normalizeWebhook(payload);
183
+ // Dispatch messages
184
+ for (const msg of events.messages) {
185
+ const inbound = this.toInboundMessage(msg);
186
+ for (const handler of this.messageHandlers) {
187
+ await handler(inbound, msg);
188
+ }
189
+ }
190
+ // Dispatch statuses
191
+ for (const status of events.statuses) {
192
+ const update = this.toStatusUpdate(status);
193
+ for (const handler of this.statusHandlers) {
194
+ await handler(update);
195
+ }
196
+ }
197
+ // Dispatch reactions
198
+ for (const reaction of events.reactions) {
199
+ const data = this.toReactionData(reaction);
200
+ for (const handler of this.reactionHandlers) {
201
+ await handler(data);
202
+ }
203
+ }
204
+ return new Response('OK', { status: 200 });
205
+ }
206
+ /**
207
+ * Handle webhook verification (GET) from Meta.
208
+ *
209
+ * @param request - The GET request with `hub.mode`, `hub.verify_token`, and `hub.challenge`.
210
+ * @returns 200 with the challenge string, or 403 Forbidden.
211
+ */
212
+ handleVerification(request) {
213
+ const url = new URL(request.url);
214
+ const mode = url.searchParams.get('hub.mode');
215
+ const token = url.searchParams.get('hub.verify_token');
216
+ const challenge = url.searchParams.get('hub.challenge');
217
+ if (mode === 'subscribe' && token === this.config.verifyToken) {
218
+ return new Response(challenge ?? '', { status: 200 });
219
+ }
220
+ return new Response('Forbidden', { status: 403 });
221
+ }
222
+ // =========================================================================
223
+ // Outbound -- core PlatformClient methods
224
+ // =========================================================================
225
+ /**
226
+ * Send a text message.
227
+ *
228
+ * Long messages are automatically split at natural boundaries to stay
229
+ * within Instagram's 1000-byte (UTF-8) limit.
230
+ *
231
+ * @param to - Recipient IGSID (Instagram-scoped user ID).
232
+ * @param text - The text content to send.
233
+ * @returns The result of the last chunk sent.
234
+ */
235
+ async sendText(to, text) {
236
+ const chunks = splitByBytes(text, MAX_MESSAGE_BYTES);
237
+ let result;
238
+ for (const chunk of chunks) {
239
+ result = await this.sendSingleText(to, chunk);
240
+ }
241
+ return result;
242
+ }
243
+ /**
244
+ * Send a media message.
245
+ *
246
+ * Instagram only supports IMAGE attachments. Attempting to send video,
247
+ * audio, document, or sticker media will throw a {@link MediaError}.
248
+ *
249
+ * @param to - Recipient IGSID.
250
+ * @param media - The media payload to send. Only `image` type is supported.
251
+ * @returns The send result.
252
+ * @throws {@link MediaError} if the media type is not `image`.
253
+ */
254
+ async sendMedia(to, media) {
255
+ if (media.type !== 'image') {
256
+ throw new MediaError(`Instagram only supports image attachments. Received: ${media.type}`, 'instagram');
257
+ }
258
+ let url;
259
+ if (typeof media.data === 'string') {
260
+ url = media.data;
261
+ }
262
+ else if (Buffer.isBuffer(media.data)) {
263
+ const handle = await this.uploadMedia(media.data, {
264
+ mimeType: media.mimeType,
265
+ filename: media.filename,
266
+ });
267
+ // For Instagram, uploaded media returns an ID; however the send API
268
+ // uses URL-based payloads. We store the URL if available.
269
+ url = handle.url ?? '';
270
+ }
271
+ else {
272
+ // ReadableStream -- convert to Buffer first
273
+ const buffer = await streamToBuffer(media.data);
274
+ const handle = await this.uploadMedia(buffer, {
275
+ mimeType: media.mimeType,
276
+ filename: media.filename,
277
+ });
278
+ url = handle.url ?? '';
279
+ }
280
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
281
+ recipient: { id: to },
282
+ message: {
283
+ attachments: [
284
+ {
285
+ type: 'image',
286
+ payload: { url },
287
+ },
288
+ ],
289
+ },
290
+ });
291
+ return this.toSendResult(to, response);
292
+ }
293
+ /**
294
+ * Send an interactive message (buttons or list).
295
+ *
296
+ * Converts the platform-agnostic {@link InteractiveMessage} into
297
+ * Instagram's template format:
298
+ * - `buttons` action type -> button template
299
+ * - `list` action type -> generic template (carousel)
300
+ *
301
+ * @param to - Recipient IGSID.
302
+ * @param msg - The interactive message definition.
303
+ * @returns The send result.
304
+ * @throws {@link MessagingError} if the interactive type is unsupported.
305
+ */
306
+ async sendInteractive(to, msg) {
307
+ if (msg.action.type === 'buttons') {
308
+ return this.sendButtonTemplate(to, {
309
+ text: msg.body,
310
+ buttons: msg.action.buttons.map((b) => ({
311
+ type: 'postback',
312
+ title: b.title,
313
+ payload: b.id,
314
+ })),
315
+ });
316
+ }
317
+ if (msg.action.type === 'list') {
318
+ // Map list sections to generic template elements
319
+ const elements = [];
320
+ for (const section of msg.action.sections) {
321
+ for (const row of section.rows) {
322
+ elements.push({
323
+ title: row.title,
324
+ subtitle: row.description,
325
+ buttons: [
326
+ {
327
+ type: 'postback',
328
+ title: row.title.slice(0, 20),
329
+ payload: row.id,
330
+ },
331
+ ],
332
+ });
333
+ }
334
+ }
335
+ return this.sendGenericTemplate(to, { elements });
336
+ }
337
+ throw new MessagingError(`Unsupported interactive type: ${msg.action.type}`, 'UNSUPPORTED_TYPE', 'instagram');
338
+ }
339
+ /**
340
+ * Send a raw platform-specific payload.
341
+ *
342
+ * This is an escape hatch for features not covered by the normalised
343
+ * interface. The payload is merged with the standard messaging envelope.
344
+ *
345
+ * @param to - Recipient IGSID.
346
+ * @param payload - Raw Instagram API payload fields.
347
+ * @returns The send result.
348
+ */
349
+ async sendRaw(to, payload) {
350
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
351
+ recipient: { id: to },
352
+ ...payload,
353
+ });
354
+ return this.toSendResult(to, response);
355
+ }
356
+ /**
357
+ * Mark a message as read.
358
+ *
359
+ * Instagram Messaging API does not expose a dedicated mark-as-read
360
+ * endpoint. This method is a no-op that satisfies the {@link PlatformClient}
361
+ * interface.
362
+ *
363
+ * @param _messageId - The message ID (unused).
364
+ */
365
+ async markAsRead(_messageId) {
366
+ // Instagram Messaging API does not support marking messages as read.
367
+ // This is a no-op to satisfy the PlatformClient interface.
368
+ }
369
+ /**
370
+ * Send a typing indicator (composing state) to a conversation.
371
+ *
372
+ * Uses the same `sender_action: "typing_on"` mechanism as Messenger.
373
+ *
374
+ * @param to - Recipient IGSID.
375
+ */
376
+ async sendTypingIndicator(to) {
377
+ await this.graphApi.post(`${this.config.igId}/messages`, {
378
+ recipient: { id: to },
379
+ sender_action: 'typing_on',
380
+ });
381
+ }
382
+ // =========================================================================
383
+ // Media
384
+ // =========================================================================
385
+ /**
386
+ * Upload media to Instagram for later use in messages.
387
+ *
388
+ * Note: Instagram image sending uses URL-based payloads. This method
389
+ * uploads via the Graph API and returns the media ID and URL if available.
390
+ *
391
+ * @param file - The file content as a Buffer or ReadableStream.
392
+ * @param options - Upload options including MIME type and optional filename.
393
+ * @returns A handle containing the platform-assigned media ID.
394
+ */
395
+ async uploadMedia(file, options) {
396
+ const buffer = Buffer.isBuffer(file) ? file : await streamToBuffer(file);
397
+ const blob = new Blob([buffer], { type: options.mimeType });
398
+ const formData = new FormData();
399
+ formData.append('file', blob, options.filename ?? 'file');
400
+ formData.append('type', options.mimeType);
401
+ const response = await this.graphApi.postFormData(`${this.config.igId}/media`, formData);
402
+ return { mediaId: response.id, url: response.url };
403
+ }
404
+ /**
405
+ * Download media from Instagram by its ID.
406
+ *
407
+ * Performs a two-step process: first retrieves the media URL via the
408
+ * Graph API, then downloads the binary content.
409
+ *
410
+ * @param mediaId - The Instagram media ID.
411
+ * @returns The downloaded media content, MIME type, and optional filename.
412
+ */
413
+ async downloadMedia(mediaId) {
414
+ if (this.mediaCache) {
415
+ return this.mediaCache.getOrDownload(mediaId, async () => {
416
+ const mediaInfo = await this.graphApi.get(mediaId);
417
+ const data = await this.graphApi.fetchBinary(mediaInfo.url);
418
+ return { data, mimeType: mediaInfo.mime_type };
419
+ });
420
+ }
421
+ const mediaInfo = await this.graphApi.get(mediaId);
422
+ const data = await this.graphApi.fetchBinary(mediaInfo.url);
423
+ return { data, mimeType: mediaInfo.mime_type };
424
+ }
425
+ // =========================================================================
426
+ // Instagram-specific -- Quick Replies
427
+ // =========================================================================
428
+ /**
429
+ * Send a text message with quick reply buttons.
430
+ *
431
+ * Quick replies appear as tappable buttons below the message. Instagram
432
+ * supports a maximum of 13 quick replies per message, and only
433
+ * `content_type: "text"` is supported.
434
+ *
435
+ * @param to - Recipient IGSID.
436
+ * @param text - The text content displayed above the quick replies.
437
+ * @param replies - Array of quick reply options (max 13).
438
+ * @returns The send result.
439
+ */
440
+ async sendQuickReplies(to, text, replies) {
441
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
442
+ recipient: { id: to },
443
+ messaging_type: 'RESPONSE',
444
+ message: {
445
+ text,
446
+ quick_replies: replies.slice(0, MAX_QUICK_REPLIES),
447
+ },
448
+ });
449
+ return this.toSendResult(to, response);
450
+ }
451
+ // =========================================================================
452
+ // Instagram-specific -- Generic Template (Carousel)
453
+ // =========================================================================
454
+ /**
455
+ * Send a generic template (carousel) message.
456
+ *
457
+ * Generic templates render as horizontally scrollable cards, each with
458
+ * an optional image, title, subtitle, default tap action, and buttons.
459
+ * Supports up to 10 elements.
460
+ *
461
+ * @param to - Recipient IGSID.
462
+ * @param template - The generic template configuration.
463
+ * @returns The send result.
464
+ */
465
+ async sendGenericTemplate(to, template) {
466
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
467
+ recipient: { id: to },
468
+ message: {
469
+ attachment: {
470
+ type: 'template',
471
+ payload: {
472
+ template_type: 'generic',
473
+ elements: template.elements.slice(0, 10),
474
+ },
475
+ },
476
+ },
477
+ });
478
+ return this.toSendResult(to, response);
479
+ }
480
+ // =========================================================================
481
+ // Instagram-specific -- Button Template
482
+ // =========================================================================
483
+ /**
484
+ * Send a button template message.
485
+ *
486
+ * Displays a text message with up to 3 buttons below it. Supports
487
+ * `postback` and `web_url` button types.
488
+ *
489
+ * @param to - Recipient IGSID.
490
+ * @param template - The button template configuration.
491
+ * @returns The send result.
492
+ */
493
+ async sendButtonTemplate(to, template) {
494
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
495
+ recipient: { id: to },
496
+ message: {
497
+ attachment: {
498
+ type: 'template',
499
+ payload: {
500
+ template_type: 'button',
501
+ text: template.text,
502
+ buttons: template.buttons.slice(0, 3),
503
+ },
504
+ },
505
+ },
506
+ });
507
+ return this.toSendResult(to, response);
508
+ }
509
+ // =========================================================================
510
+ // Instagram-specific -- Private Reply
511
+ // =========================================================================
512
+ /**
513
+ * Send a private reply to a comment on a post or reel.
514
+ *
515
+ * Uses `recipient.comment_id` instead of `recipient.id` to initiate
516
+ * a DM thread from a comment. This is rate-limited to:
517
+ * - 100 calls/sec for live comments
518
+ * - 750 calls/hour for post/reel comments
519
+ *
520
+ * @param options - The private reply options (comment ID and text).
521
+ * @returns The send result.
522
+ */
523
+ async sendPrivateReply(options) {
524
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
525
+ recipient: { comment_id: options.commentId },
526
+ message: { text: options.text },
527
+ });
528
+ return {
529
+ messageId: response.message_id,
530
+ threadId: `instagram:${this.config.igId}:comment:${options.commentId}`,
531
+ timestamp: new Date(),
532
+ raw: response,
533
+ };
534
+ }
535
+ // =========================================================================
536
+ // Instagram-specific -- Ice Breakers
537
+ // =========================================================================
538
+ /**
539
+ * Ice breaker management operations.
540
+ *
541
+ * Ice breakers are conversation starters displayed when a user opens
542
+ * a DM thread for the first time. They deliver `messaging_postback`
543
+ * webhook events when tapped.
544
+ */
545
+ iceBreakers = {
546
+ /**
547
+ * Set ice breakers for the Instagram professional account.
548
+ *
549
+ * Replaces any existing ice breakers with the provided configuration.
550
+ *
551
+ * @param breakers - Array of ice breaker configurations.
552
+ */
553
+ set: async (breakers) => {
554
+ await this.graphApi.post(`${this.config.igId}/messenger_profile`, {
555
+ platform: 'instagram',
556
+ ice_breakers: breakers,
557
+ });
558
+ },
559
+ /**
560
+ * Get the current ice breaker configuration.
561
+ *
562
+ * @returns Array of ice breaker configurations currently set.
563
+ */
564
+ get: async () => {
565
+ const result = await this.graphApi.get(`${this.config.igId}/messenger_profile`, { fields: 'ice_breakers' });
566
+ return result.data?.[0]?.ice_breakers ?? [];
567
+ },
568
+ /**
569
+ * Delete all ice breakers for the Instagram professional account.
570
+ *
571
+ * Uses a POST with `_method=DELETE` override since the Graph API
572
+ * client does not expose a dedicated DELETE method.
573
+ */
574
+ delete: async () => {
575
+ await this.graphApi.post(`${this.config.igId}/messenger_profile`, {
576
+ fields: ['ice_breakers'],
577
+ _method: 'DELETE',
578
+ });
579
+ },
580
+ };
581
+ // =========================================================================
582
+ // Instagram-specific -- Message with Tag
583
+ // =========================================================================
584
+ /**
585
+ * Send a text message with a message tag.
586
+ *
587
+ * Instagram only supports the `HUMAN_AGENT` tag, which extends the
588
+ * messaging window to 7 days for live agent handoff scenarios.
589
+ *
590
+ * @param to - Recipient IGSID.
591
+ * @param text - The text content to send.
592
+ * @param tag - The message tag (only `"HUMAN_AGENT"` is supported).
593
+ * @returns The send result.
594
+ */
595
+ async sendTextWithTag(to, text, tag) {
596
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
597
+ recipient: { id: to },
598
+ messaging_type: 'MESSAGE_TAG',
599
+ tag,
600
+ message: { text },
601
+ });
602
+ return this.toSendResult(to, response);
603
+ }
604
+ // =========================================================================
605
+ // Webhook router
606
+ // =========================================================================
607
+ /**
608
+ * Returns a Hono sub-application that handles webhook routes.
609
+ *
610
+ * Mounts GET (verification) and POST (event delivery) handlers at the
611
+ * root path. Use this to integrate the webhook into an existing Hono app.
612
+ *
613
+ * Requires `hono` to be installed as a peer dependency. The import is
614
+ * performed dynamically to avoid a hard compile-time dependency.
615
+ *
616
+ * @returns A Hono application with webhook routes.
617
+ *
618
+ * @example
619
+ * ```ts
620
+ * import { Hono } from 'hono';
621
+ *
622
+ * const app = new Hono();
623
+ * app.route('/webhooks/instagram', client.webhookRouter());
624
+ * ```
625
+ */
626
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
627
+ webhookRouter() {
628
+ // Dynamic import to avoid hard dependency on Hono at the module level.
629
+ // The caller must have Hono installed (it's a peer dependency via @ariaflowagents/messaging).
630
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
631
+ const { Hono } = require('hono');
632
+ const router = new Hono();
633
+ const self = this;
634
+ router.all('/*', async (c) => {
635
+ return self.handleWebhook(c.req.raw);
636
+ });
637
+ return router;
638
+ }
639
+ // =========================================================================
640
+ // Private -- conversion helpers
641
+ // =========================================================================
642
+ /**
643
+ * Convert a normalised webhook message to a platform-agnostic {@link InboundMessage}.
644
+ */
645
+ toInboundMessage(msg) {
646
+ const threadId = `instagram:${this.config.igId}:${msg.from}`;
647
+ return {
648
+ id: msg.id,
649
+ platform: 'instagram',
650
+ threadId,
651
+ from: {
652
+ id: msg.from,
653
+ name: msg.contactName,
654
+ },
655
+ timestamp: new Date(parseInt(msg.timestamp, 10) * 1000),
656
+ type: this.mapMessageType(msg.type),
657
+ text: msg.text?.body ?? this.extractTextFallback(msg),
658
+ media: this.extractMedia(msg),
659
+ location: msg.location,
660
+ interactive: msg.interactive
661
+ ? {
662
+ type: msg.interactive.type,
663
+ id: msg.interactive.button_reply?.id ??
664
+ msg.interactive.list_reply?.id ??
665
+ '',
666
+ title: msg.interactive.button_reply?.title ??
667
+ msg.interactive.list_reply?.title,
668
+ description: msg.interactive.list_reply?.description,
669
+ }
670
+ : undefined,
671
+ context: msg.context
672
+ ? { messageId: msg.context.message_id, from: msg.context.from }
673
+ : undefined,
674
+ raw: msg,
675
+ };
676
+ }
677
+ /**
678
+ * Convert a normalised webhook status to a platform-agnostic {@link StatusUpdate}.
679
+ */
680
+ toStatusUpdate(status) {
681
+ return {
682
+ messageId: status.id,
683
+ status: status.status,
684
+ timestamp: new Date(parseInt(status.timestamp, 10) * 1000),
685
+ recipientId: status.recipientId,
686
+ threadId: `instagram:${this.config.igId}:${status.recipientId}`,
687
+ raw: status,
688
+ };
689
+ }
690
+ /**
691
+ * Convert a normalised webhook reaction to a platform-agnostic {@link ReactionData}.
692
+ */
693
+ toReactionData(reaction) {
694
+ return {
695
+ messageId: reaction.messageId,
696
+ emoji: reaction.emoji,
697
+ action: reaction.emoji ? 'react' : 'unreact',
698
+ userId: reaction.from,
699
+ };
700
+ }
701
+ /**
702
+ * Convert an {@link InstagramSendResponse} to a platform-agnostic {@link SendResult}.
703
+ *
704
+ * Note: Instagram send responses do NOT include `recipient_id` unlike Messenger.
705
+ */
706
+ toSendResult(to, response) {
707
+ return {
708
+ messageId: response.message_id,
709
+ threadId: `instagram:${this.config.igId}:${to}`,
710
+ timestamp: new Date(),
711
+ raw: response,
712
+ };
713
+ }
714
+ /**
715
+ * Map an Instagram message type string to the normalised {@link InboundMessage} type union.
716
+ */
717
+ mapMessageType(type) {
718
+ const typeMap = {
719
+ text: 'text',
720
+ image: 'image',
721
+ video: 'video',
722
+ audio: 'audio',
723
+ sticker: 'sticker',
724
+ interactive: 'interactive',
725
+ postback: 'interactive',
726
+ reaction: 'reaction',
727
+ };
728
+ return typeMap[type] ?? 'unknown';
729
+ }
730
+ /**
731
+ * Extract a text fallback from non-text message types (captions, button text, etc.).
732
+ */
733
+ extractTextFallback(msg) {
734
+ if (msg.image?.caption)
735
+ return msg.image.caption;
736
+ if (msg.button)
737
+ return msg.button.text;
738
+ if (msg.interactive?.button_reply)
739
+ return msg.interactive.button_reply.title;
740
+ if (msg.interactive?.list_reply)
741
+ return msg.interactive.list_reply.title;
742
+ return undefined;
743
+ }
744
+ /**
745
+ * Extract a {@link MediaReference} from a normalised message if it contains media.
746
+ */
747
+ extractMedia(msg) {
748
+ // Instagram primarily deals with images in DMs
749
+ const mediaTypes = ['image', 'video', 'audio', 'sticker'];
750
+ for (const type of mediaTypes) {
751
+ const media = msg[type];
752
+ if (media && 'id' in media) {
753
+ return {
754
+ id: media.id,
755
+ mimeType: 'mime_type' in media ? media.mime_type : undefined,
756
+ caption: 'caption' in media ? media.caption : undefined,
757
+ };
758
+ }
759
+ }
760
+ return undefined;
761
+ }
762
+ /**
763
+ * Send a single text message (without splitting).
764
+ */
765
+ async sendSingleText(to, text) {
766
+ const response = await this.graphApi.post(`${this.config.igId}/messages`, {
767
+ recipient: { id: to },
768
+ message: { text },
769
+ });
770
+ return this.toSendResult(to, response);
771
+ }
772
+ }
773
+ // ---------------------------------------------------------------------------
774
+ // Utility
775
+ // ---------------------------------------------------------------------------
776
+ /**
777
+ * Split a text message into chunks that fit within a byte limit.
778
+ *
779
+ * Instagram enforces a 1000-byte limit on text messages (UTF-8 encoded).
780
+ * This function splits at natural boundaries (paragraph breaks, line breaks,
781
+ * word boundaries) while respecting the byte limit.
782
+ *
783
+ * @param text - The full message text.
784
+ * @param maxBytes - Maximum bytes per chunk. Default `1000`.
785
+ * @returns An array of text chunks, each within `maxBytes`.
786
+ */
787
+ function splitByBytes(text, maxBytes = MAX_MESSAGE_BYTES) {
788
+ const encoder = new TextEncoder();
789
+ if (encoder.encode(text).length <= maxBytes) {
790
+ return [text];
791
+ }
792
+ const chunks = [];
793
+ let remaining = text;
794
+ while (encoder.encode(remaining).length > maxBytes) {
795
+ // Find the longest prefix that fits within the byte limit.
796
+ // Start from a character estimate and adjust.
797
+ let end = Math.min(remaining.length, maxBytes);
798
+ // Binary search for the right split point
799
+ while (encoder.encode(remaining.slice(0, end)).length > maxBytes) {
800
+ end = Math.floor(end * 0.9);
801
+ }
802
+ // Expand to fill as much as possible
803
+ while (end < remaining.length &&
804
+ encoder.encode(remaining.slice(0, end + 1)).length <= maxBytes) {
805
+ end++;
806
+ }
807
+ const minSplit = Math.floor(end / 2);
808
+ // Try to find a natural break point
809
+ let splitIndex = -1;
810
+ // 1. Try paragraph boundary (\n\n)
811
+ splitIndex = remaining.lastIndexOf('\n\n', end);
812
+ if (splitIndex > 0 && splitIndex >= minSplit) {
813
+ chunks.push(remaining.slice(0, splitIndex).trimEnd());
814
+ remaining = remaining.slice(splitIndex + 2).trimStart();
815
+ continue;
816
+ }
817
+ // 2. Try line boundary (\n)
818
+ splitIndex = remaining.lastIndexOf('\n', end);
819
+ if (splitIndex > 0 && splitIndex >= minSplit) {
820
+ chunks.push(remaining.slice(0, splitIndex).trimEnd());
821
+ remaining = remaining.slice(splitIndex + 1).trimStart();
822
+ continue;
823
+ }
824
+ // 3. Try word boundary (space)
825
+ splitIndex = remaining.lastIndexOf(' ', end);
826
+ if (splitIndex > 0 && splitIndex >= minSplit) {
827
+ chunks.push(remaining.slice(0, splitIndex).trimEnd());
828
+ remaining = remaining.slice(splitIndex + 1).trimStart();
829
+ continue;
830
+ }
831
+ // 4. Hard split at the byte boundary
832
+ chunks.push(remaining.slice(0, end));
833
+ remaining = remaining.slice(end);
834
+ }
835
+ if (remaining.length > 0) {
836
+ chunks.push(remaining);
837
+ }
838
+ return chunks;
839
+ }
840
+ /**
841
+ * Convert a `ReadableStream` to a `Buffer`.
842
+ *
843
+ * @param stream - The readable stream to consume.
844
+ * @returns A Buffer containing all chunks from the stream.
845
+ */
846
+ async function streamToBuffer(stream) {
847
+ const chunks = [];
848
+ const reader = stream.getReader();
849
+ for (;;) {
850
+ const { done, value } = await reader.read();
851
+ if (done)
852
+ break;
853
+ chunks.push(value);
854
+ }
855
+ return Buffer.concat(chunks);
856
+ }
857
+ //# sourceMappingURL=client.js.map