@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,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
|