@mono-agent/messenger-adapter 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1021 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import { AgentResponseCancelledError, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_EMPTY_FINAL_TEXT, createChannelUserCancelReason, isAgentResponseCancelledError, isChannelUserCancelReason, normalizeTrailing, } from "@mono-agent/agent-contracts";
4
+ import { isMessengerAmbiguousDeliveryError } from "./graph-client.js";
5
+ import { MessengerMessageStream } from "./message-stream.js";
6
+ import { MESSENGER_MAX_MESSAGE_CHARS, stripMarkdownForMessenger } from "./text.js";
7
+ export const MESSENGER_CHANNEL_ID = "messenger";
8
+ /** Conversation id for a Messenger user: `messenger:<psid>`. */
9
+ export function messengerConversationId(userId) {
10
+ return `${MESSENGER_CHANNEL_ID}:${userId}`;
11
+ }
12
+ /** Parse `messenger:<psid>` back into the PSID; undefined for any other shape. */
13
+ export function messengerUserIdFromConversation(conversationId) {
14
+ const prefix = `${MESSENGER_CHANNEL_ID}:`;
15
+ if (!conversationId.startsWith(prefix)) {
16
+ return undefined;
17
+ }
18
+ const userId = conversationId.slice(prefix.length);
19
+ return /^\d{1,64}$/u.test(userId) ? userId : undefined;
20
+ }
21
+ const DEFAULT_MESSAGES = {
22
+ welcomeText: "Hello! Send me a message and I will pass it to the configured agent.",
23
+ helpText: "Send a message to talk to the agent. Use /cancel to stop the current response.",
24
+ busyText: "I am still working on your previous messages. Use /cancel to stop.",
25
+ unauthorizedText: "This Messenger account is not authorized to use this bot.",
26
+ cancelledText: "Cancelled.",
27
+ errorText: "The agent failed while processing your message.",
28
+ unsupportedText: "I can only handle text, images, and documents here for now.",
29
+ };
30
+ const DEFAULT_MAX_QUEUED_PER_USER = 4;
31
+ const DEDUP_MAX_SIZE = 512;
32
+ const ATTACHMENT_TIMEOUT_MS = 30_000;
33
+ /**
34
+ * Meta's media CDNs. Messenger attachment payload URLs are signed links into
35
+ * these origins, so an explicit allowlist — rather than "any public-looking
36
+ * HTTPS host" — is what actually bounds this downloader.
37
+ */
38
+ export const DEFAULT_MESSENGER_ATTACHMENT_HOST_SUFFIXES = [
39
+ "fbcdn.net",
40
+ "fbsbx.com",
41
+ ];
42
+ /** Redirect hops followed before a download is abandoned. */
43
+ const MAX_ATTACHMENT_REDIRECTS = 3;
44
+ const PROCESS_JOB_WAKE_DELIVERY_METADATA = Symbol.for("mono-agent.process-job-wake.delivery-key.v1");
45
+ function createDeferred() {
46
+ let resolve = () => undefined;
47
+ const promise = new Promise((innerResolve) => {
48
+ resolve = innerResolve;
49
+ });
50
+ return { promise, resolve };
51
+ }
52
+ /** Bounded insertion-ordered set of seen message ids. */
53
+ class MessageDeduplicator {
54
+ maxSize;
55
+ seen = new Set();
56
+ constructor(maxSize = DEDUP_MAX_SIZE) {
57
+ this.maxSize = maxSize;
58
+ }
59
+ isDuplicate(key) {
60
+ if (this.seen.has(key)) {
61
+ return true;
62
+ }
63
+ this.seen.add(key);
64
+ if (this.seen.size > this.maxSize) {
65
+ const oldest = this.seen.values().next().value;
66
+ if (oldest !== undefined) {
67
+ this.seen.delete(oldest);
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+ }
73
+ export class MessengerAdapter {
74
+ client;
75
+ responder;
76
+ allowAllUsers;
77
+ allowedUserIds;
78
+ messages;
79
+ logger;
80
+ ingest;
81
+ proactive;
82
+ maxQueuedPerUser;
83
+ maxMessageChars;
84
+ dedup = new MessageDeduplicator();
85
+ /**
86
+ * Every controller admitted for a user, registered BEFORE the work reaches
87
+ * the per-user queue. Registering eagerly is what lets `/cancel` retire a
88
+ * prompt that is still parked behind an earlier run — a controller created
89
+ * only at execution time would not exist yet.
90
+ */
91
+ pendingControllers = new Map();
92
+ queueTails = new Map();
93
+ /** Admitted work per user: at most one active turn plus `maxQueuedPerUser` waiting. */
94
+ admitted = new Map();
95
+ stopping = false;
96
+ constructor(options) {
97
+ this.client = options.client;
98
+ this.responder = options.responder;
99
+ this.allowAllUsers = options.allowAllUsers === true;
100
+ this.allowedUserIds = new Set((options.allowedUserIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0));
101
+ this.messages = { ...DEFAULT_MESSAGES, ...options.messages };
102
+ this.logger = options.logger;
103
+ this.ingest = {
104
+ fetch: options.attachments?.fetch ?? fetch,
105
+ maxBytes: options.attachments?.maxBytes ?? DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
106
+ timeoutMs: options.attachments?.timeoutMs ?? ATTACHMENT_TIMEOUT_MS,
107
+ allowedHostSuffixes: normalizeHostSuffixes(options.attachments?.allowedHostSuffixes ?? DEFAULT_MESSENGER_ATTACHMENT_HOST_SUFFIXES),
108
+ resolveAddresses: options.attachments?.resolveAddresses ?? resolveHostAddresses,
109
+ };
110
+ this.proactive = options.proactive ?? { messagingType: "RESPONSE" };
111
+ this.maxQueuedPerUser = options.maxQueuedPerUser ?? DEFAULT_MAX_QUEUED_PER_USER;
112
+ this.maxMessageChars = options.maxMessageChars ?? MESSENGER_MAX_MESSAGE_CHARS;
113
+ if (!this.allowAllUsers && this.allowedUserIds.size === 0) {
114
+ throw new TypeError("MessengerAdapter requires allowedUserIds or allowAllUsers: true.");
115
+ }
116
+ }
117
+ /** Process one full webhook payload (`{ object: "page", entry: [...] }`). */
118
+ async handleWebhookPayload(payload) {
119
+ const results = [];
120
+ if (!isRecord(payload) || payload.object !== "page" || !Array.isArray(payload.entry)) {
121
+ this.logger?.debug?.("Ignoring non-page Messenger webhook payload.");
122
+ return results;
123
+ }
124
+ for (const entry of payload.entry) {
125
+ if (!isRecord(entry) || !Array.isArray(entry.messaging)) {
126
+ continue;
127
+ }
128
+ for (const event of entry.messaging) {
129
+ if (!isRecord(event)) {
130
+ continue;
131
+ }
132
+ try {
133
+ results.push(await this.handleEvent(event));
134
+ }
135
+ catch (error) {
136
+ this.logger?.error?.("Messenger webhook event failed.", { error: errorMessage(error) });
137
+ results.push({ kind: "error", error });
138
+ }
139
+ }
140
+ }
141
+ return results;
142
+ }
143
+ async handleEvent(event) {
144
+ if (this.stopping) {
145
+ return { kind: "error", error: new AgentResponseCancelledError("Messenger adapter is stopping.") };
146
+ }
147
+ if (event.message?.is_echo === true) {
148
+ return { kind: "ignored", reason: "echo" };
149
+ }
150
+ if (event.delivery !== undefined || event.read !== undefined) {
151
+ return { kind: "ignored", reason: "receipt" };
152
+ }
153
+ const userId = normalizeId(event.sender?.id);
154
+ if (userId === undefined) {
155
+ return { kind: "ignored", reason: "no_sender" };
156
+ }
157
+ if (event.message === undefined && event.postback === undefined) {
158
+ return { kind: "ignored", reason: "no_content", userId };
159
+ }
160
+ const messageId = normalizeId(event.message?.mid ?? event.postback?.mid);
161
+ const dedupKey = messageId ?? `${userId}:${event.timestamp ?? ""}:${event.postback?.payload ?? event.postback?.title ?? ""}`;
162
+ if (this.dedup.isDuplicate(dedupKey)) {
163
+ return withMessageId({ kind: "ignored", reason: "duplicate", userId }, messageId);
164
+ }
165
+ if (!this.isAuthorized(userId)) {
166
+ this.logger?.warn?.("Messenger message from unauthorized user dropped.", { userId });
167
+ await this.sendTextSafely(userId, this.messages.unauthorizedText);
168
+ return withMessageId({ kind: "unauthorized", userId }, messageId);
169
+ }
170
+ const inbound = await this.normalizeInbound(userId, event, messageId);
171
+ const command = parseCommand(inbound.text);
172
+ if (command === "start") {
173
+ await this.sendTextSafely(userId, this.messages.welcomeText);
174
+ return withMessageId({ kind: "handled", userId, action: "command", command: "start" }, messageId);
175
+ }
176
+ if (command === "help") {
177
+ await this.sendTextSafely(userId, this.messages.helpText);
178
+ return withMessageId({ kind: "handled", userId, action: "command", command: "help" }, messageId);
179
+ }
180
+ if (command === "cancel") {
181
+ const reason = createChannelUserCancelReason("Messenger");
182
+ // Clear the harness's queued follow-ups first, then abort every
183
+ // controller admitted so far — the active turn AND anything parked
184
+ // behind it, which would otherwise run and answer after the user asked
185
+ // to stop. `/cancel` stays out-of-band (it never enters the queue), and
186
+ // a prompt admitted after this point gets a fresh controller and runs.
187
+ this.responder.cancel?.(messengerConversationId(userId), reason);
188
+ this.cancelPending(userId, reason);
189
+ await this.sendTextSafely(userId, this.messages.cancelledText);
190
+ return withMessageId({ kind: "cancelled", userId }, messageId);
191
+ }
192
+ if (inbound.text.length === 0 && inbound.attachments.length === 0) {
193
+ await this.sendTextSafely(userId, this.messages.unsupportedText);
194
+ return withMessageId({ kind: "ignored", reason: "empty_text", userId }, messageId);
195
+ }
196
+ // One active turn plus `maxQueuedPerUser` waiting behind it.
197
+ if ((this.admitted.get(userId) ?? 0) > this.maxQueuedPerUser) {
198
+ await this.sendTextSafely(userId, this.messages.busyText);
199
+ return withMessageId({ kind: "busy", userId }, messageId);
200
+ }
201
+ void this.client.senderAction(userId, "mark_seen").catch(() => undefined);
202
+ const controller = this.registerController(userId);
203
+ return await this.admit(userId, () => this.respondToInbound(inbound, controller));
204
+ }
205
+ /** Stop accepting work and abort every active turn. */
206
+ stop(reason = new AgentResponseCancelledError("Messenger adapter stopped.")) {
207
+ if (this.stopping) {
208
+ return;
209
+ }
210
+ this.stopping = true;
211
+ for (const controllers of this.pendingControllers.values()) {
212
+ for (const controller of controllers) {
213
+ controller.abort(reason);
214
+ }
215
+ }
216
+ }
217
+ /**
218
+ * Proactive delivery. With `verbatim`, `text` is posted unchanged (no model
219
+ * call) and recorded to history; otherwise it runs as a turn for the user and
220
+ * the answer is delivered. Enforces the adapter allowlist.
221
+ */
222
+ async notify(userId, text, options) {
223
+ if (this.stopping) {
224
+ return { delivered: false, reason: "adapter stopped", retryable: true };
225
+ }
226
+ if (!this.isAuthorized(userId)) {
227
+ return { delivered: false, reason: "messenger user is not in the adapter allowlist", retryable: false };
228
+ }
229
+ if (options?.verbatim === true) {
230
+ return await this.admit(userId, () => this.deliverVerbatim(userId, text, options.deliveryKey));
231
+ }
232
+ const offerLiveInput = this.responder.offerLiveInput;
233
+ if (options?.steerActive === true && options.deliveryKey !== undefined && offerLiveInput !== undefined) {
234
+ return await this.steerOrRunReserved(userId, text, options.deliveryKey, offerLiveInput.bind(this.responder));
235
+ }
236
+ const controller = this.registerController(userId);
237
+ return await this.admit(userId, () => this.runProactiveTurn(userId, text, controller, options?.deliveryKey));
238
+ }
239
+ /**
240
+ * Offer a wake to the active turn, having RESERVED this user's queue slot
241
+ * first.
242
+ *
243
+ * The `AgentLiveInputOffer` contract requires an accepted offer to stay
244
+ * represented by its reserved normal-turn slot until `settled` says whether
245
+ * that reservation runs or becomes a no-op. Offering first and enqueueing
246
+ * only afterwards lets a prompt that arrives during the offer overtake the
247
+ * wake; reserving first keeps arrival order. Every settlement path —
248
+ * accepted/applied, requeue, discard, uncertain, unavailable, and a throwing
249
+ * offer — resolves the reservation exactly once.
250
+ */
251
+ async steerOrRunReserved(userId, text, deliveryKey, offerLiveInput) {
252
+ const controller = this.registerController(userId);
253
+ const decision = createDeferred();
254
+ const reserved = this.admit(userId, async () => {
255
+ const next = await decision.promise;
256
+ if (next === "run") {
257
+ return await this.runProactiveTurn(userId, text, controller, deliveryKey);
258
+ }
259
+ // The slot is not going to run a turn; release its controller now.
260
+ this.unregisterController(userId, controller);
261
+ if (next === "steered") {
262
+ return {
263
+ delivered: true,
264
+ code: "delivered",
265
+ channelId: MESSENGER_CHANNEL_ID,
266
+ historyRecorded: true,
267
+ disposition: "steered",
268
+ };
269
+ }
270
+ if (next === "discarded") {
271
+ return {
272
+ delivered: false,
273
+ code: "process_job_wake_discarded",
274
+ reason: "The active turn was cancelled before the wake was applied.",
275
+ retryable: false,
276
+ };
277
+ }
278
+ return {
279
+ delivered: false,
280
+ code: "delivery_uncertain",
281
+ reason: "Live-input delivery is uncertain and was not retried.",
282
+ retryable: false,
283
+ ambiguous: true,
284
+ channelId: MESSENGER_CHANNEL_ID,
285
+ };
286
+ });
287
+ let offer;
288
+ try {
289
+ offer = offerLiveInput({
290
+ conversationId: messengerConversationId(userId),
291
+ id: deliveryKey,
292
+ text,
293
+ receivedAt: new Date().toISOString(),
294
+ deliveryKey,
295
+ });
296
+ }
297
+ catch (error) {
298
+ this.logger?.debug?.("Messenger steering failed; running the reserved fallback turn.", {
299
+ error: errorMessage(error),
300
+ });
301
+ decision.resolve("run");
302
+ return await reserved;
303
+ }
304
+ if (offer.status === "accepted") {
305
+ void offer.settled.then((settlement) => decision.resolve(settlement.status === "applied"
306
+ ? "steered"
307
+ : settlement.status === "requeue"
308
+ ? "run"
309
+ : settlement.status === "discarded"
310
+ ? "discarded"
311
+ : "uncertain"), () => decision.resolve("uncertain"));
312
+ }
313
+ else {
314
+ decision.resolve("run");
315
+ }
316
+ return await reserved;
317
+ }
318
+ async updateProcessJob(userId, projection) {
319
+ if (this.stopping) {
320
+ return { delivered: false, reason: "adapter stopped", retryable: true };
321
+ }
322
+ if (!this.isAuthorized(userId)) {
323
+ return { delivered: false, reason: "messenger user is not in the adapter allowlist", retryable: false };
324
+ }
325
+ const status = projection.state.replaceAll("_", " ");
326
+ const sent = await this.client.sendText(userId, `Background job ${projection.jobId}: ${status}.`, this.proactiveSend());
327
+ const deliveryId = sent.messageIds.at(-1);
328
+ return { delivered: true, code: "delivered", channelId: MESSENGER_CHANNEL_ID, ...(deliveryId === undefined ? {} : { deliveryId }) };
329
+ }
330
+ /** Create and register a controller for a user BEFORE the work is admitted. */
331
+ registerController(userId) {
332
+ const controller = new AbortController();
333
+ const controllers = this.pendingControllers.get(userId);
334
+ if (controllers === undefined) {
335
+ this.pendingControllers.set(userId, new Set([controller]));
336
+ }
337
+ else {
338
+ controllers.add(controller);
339
+ }
340
+ return controller;
341
+ }
342
+ unregisterController(userId, controller) {
343
+ const controllers = this.pendingControllers.get(userId);
344
+ if (controllers === undefined) {
345
+ return;
346
+ }
347
+ controllers.delete(controller);
348
+ if (controllers.size === 0) {
349
+ this.pendingControllers.delete(userId);
350
+ }
351
+ }
352
+ /** Abort every controller currently admitted for a user (active and parked). */
353
+ cancelPending(userId, reason) {
354
+ for (const controller of this.pendingControllers.get(userId) ?? []) {
355
+ controller.abort(reason);
356
+ }
357
+ }
358
+ /** Append work to a user's serial queue, tracking admitted depth for the busy cap. */
359
+ async admit(userId, task) {
360
+ this.admitted.set(userId, (this.admitted.get(userId) ?? 0) + 1);
361
+ const previous = this.queueTails.get(userId) ?? Promise.resolve();
362
+ const current = previous.catch(() => undefined).then(task);
363
+ this.queueTails.set(userId, current);
364
+ try {
365
+ return await current;
366
+ }
367
+ finally {
368
+ const remaining = (this.admitted.get(userId) ?? 1) - 1;
369
+ if (remaining <= 0) {
370
+ this.admitted.delete(userId);
371
+ }
372
+ else {
373
+ this.admitted.set(userId, remaining);
374
+ }
375
+ if (this.queueTails.get(userId) === current) {
376
+ this.queueTails.delete(userId);
377
+ }
378
+ }
379
+ }
380
+ async respondToInbound(inbound, controller) {
381
+ const { userId, messageId } = inbound;
382
+ // The turn may have been parked behind an earlier run while `/cancel` (or
383
+ // `stop()`) aborted it. Retire it as a no-op instead of answering a prompt
384
+ // the user already withdrew.
385
+ if (this.stopping || controller.signal.aborted) {
386
+ this.unregisterController(userId, controller);
387
+ return withMessageId({ kind: "cancelled", userId }, messageId);
388
+ }
389
+ const stream = this.createStream(userId, undefined);
390
+ void this.client.senderAction(userId, "typing_on").catch(() => undefined);
391
+ try {
392
+ const metadata = {
393
+ user: { id: userId },
394
+ ...(inbound.pageId === undefined ? {} : { page: { id: inbound.pageId } }),
395
+ message: {
396
+ ...(messageId === undefined ? {} : { id: messageId }),
397
+ ...(inbound.timestamp === undefined ? {} : { timestamp: inbound.timestamp }),
398
+ },
399
+ attachmentTypes: inbound.attachmentTypes,
400
+ trigger: inbound.trigger,
401
+ };
402
+ const conversationId = messengerConversationId(userId);
403
+ const request = {
404
+ conversationId,
405
+ replyTo: { conversationId },
406
+ userId,
407
+ ...(messageId === undefined ? {} : { messageId }),
408
+ text: inbound.text,
409
+ abortSignal: controller.signal,
410
+ sender: { id: userId },
411
+ surface: { kind: "dm", id: userId, messageBudget: { maxChars: this.maxMessageChars, overflow: "follow_up" } },
412
+ ...(inbound.attachments.length === 0 ? {} : { attachments: inbound.attachments }),
413
+ metadata: { messenger: metadata },
414
+ };
415
+ const response = await this.responder.respond(request, stream);
416
+ if (controller.signal.aborted) {
417
+ await this.finishCancelledUnlessAcknowledged(stream, controller.signal);
418
+ return withMessageId({ kind: "cancelled", userId }, messageId);
419
+ }
420
+ await stream.finish(response.text, response.parts === undefined ? undefined : { parts: response.parts });
421
+ return withMessageId({ kind: "handled", userId, action: "responded" }, messageId);
422
+ }
423
+ catch (error) {
424
+ if (controller.signal.aborted || isAgentResponseCancelledError(error)) {
425
+ await this.finishCancelledUnlessAcknowledged(stream, controller.signal, error);
426
+ return withMessageId({ kind: "cancelled", userId }, messageId);
427
+ }
428
+ this.logger?.error?.("Messenger adapter responder failed.", { error: errorMessage(error) });
429
+ await this.finishSafely(stream, this.messages.errorText);
430
+ return withMessageId({ kind: "error", userId, error }, messageId);
431
+ }
432
+ finally {
433
+ void this.client.senderAction(userId, "typing_off").catch(() => undefined);
434
+ this.unregisterController(userId, controller);
435
+ }
436
+ }
437
+ async runProactiveTurn(userId, text, controller, deliveryKey) {
438
+ if (this.stopping || controller.signal.aborted) {
439
+ this.unregisterController(userId, controller);
440
+ return { delivered: false, reason: "adapter stopped", retryable: true };
441
+ }
442
+ const stream = this.createStream(userId, this.proactiveSend());
443
+ try {
444
+ const conversationId = messengerConversationId(userId);
445
+ const metadata = {
446
+ messenger: { user: { id: userId }, message: {}, attachmentTypes: [], trigger: "proactive" },
447
+ ...(deliveryKey === undefined ? {} : { [PROCESS_JOB_WAKE_DELIVERY_METADATA]: deliveryKey }),
448
+ };
449
+ const response = await this.responder.respond({
450
+ conversationId,
451
+ replyTo: { conversationId },
452
+ userId,
453
+ text,
454
+ abortSignal: controller.signal,
455
+ surface: { kind: "dm", id: userId, messageBudget: { maxChars: this.maxMessageChars, overflow: "follow_up" } },
456
+ metadata,
457
+ }, stream);
458
+ await stream.finish(response.text, response.parts === undefined ? undefined : { parts: response.parts });
459
+ return { delivered: true, code: "delivered", channelId: MESSENGER_CHANNEL_ID, historyRecorded: true, disposition: "follow_up" };
460
+ }
461
+ catch (error) {
462
+ if (!controller.signal.aborted && !isAgentResponseCancelledError(error)) {
463
+ await this.finishSafely(stream, this.messages.errorText);
464
+ }
465
+ return { delivered: false, code: "process_job_wake_failed", reason: errorMessage(error), retryable: false };
466
+ }
467
+ finally {
468
+ this.unregisterController(userId, controller);
469
+ }
470
+ }
471
+ async deliverVerbatim(userId, text, deliveryKey) {
472
+ if (this.stopping) {
473
+ return { delivered: false, reason: "adapter stopped", retryable: true };
474
+ }
475
+ const normalized = normalizeTrailing(text, "");
476
+ if (normalized.length === 0) {
477
+ return { delivered: false, reason: "empty notification", retryable: false };
478
+ }
479
+ let deliveryId;
480
+ try {
481
+ const sent = await this.client.sendText(userId, stripMarkdownForMessenger(normalized), this.proactiveSend());
482
+ deliveryId = sent.messageIds.at(-1);
483
+ }
484
+ catch (error) {
485
+ this.logger?.error?.("Messenger verbatim notify delivery failed.", { error: errorMessage(error) });
486
+ // Only an unknown-outcome send is ambiguous. A clean rejection (auth,
487
+ // policy, a 4xx) definitively did not deliver, and saying so lets the
488
+ // host distinguish "never sent" from "may already be posted".
489
+ if (isMessengerAmbiguousDeliveryError(error)) {
490
+ return {
491
+ delivered: false,
492
+ code: "delivery_uncertain",
493
+ reason: "The Messenger send outcome is unknown and was not retried.",
494
+ retryable: false,
495
+ ambiguous: true,
496
+ channelId: MESSENGER_CHANNEL_ID,
497
+ };
498
+ }
499
+ return { delivered: false, code: "delivery_failed", reason: "delivery failed", retryable: false };
500
+ }
501
+ let historyRecorded = false;
502
+ try {
503
+ await this.responder.deliverVerbatim?.(messengerConversationId(userId), normalized, deliveryKey === undefined ? undefined : { idempotencyKey: deliveryKey });
504
+ historyRecorded = this.responder.deliverVerbatim !== undefined;
505
+ }
506
+ catch (error) {
507
+ this.logger?.warn?.("Messenger verbatim notify history record failed.", { error: errorMessage(error) });
508
+ }
509
+ return {
510
+ delivered: true,
511
+ code: "delivered",
512
+ channelId: MESSENGER_CHANNEL_ID,
513
+ historyRecorded,
514
+ ...(deliveryId === undefined ? {} : { deliveryId }),
515
+ };
516
+ }
517
+ async normalizeInbound(userId, event, messageId) {
518
+ const parts = [];
519
+ const attachments = [];
520
+ const attachmentTypes = [];
521
+ const message = event.message;
522
+ const postback = event.postback;
523
+ if (typeof message?.text === "string" && message.text.trim().length > 0) {
524
+ parts.push(message.text.trim());
525
+ }
526
+ if (postback !== undefined) {
527
+ const payload = postback.payload?.trim() ?? "";
528
+ const title = postback.title?.trim() ?? "";
529
+ parts.push(payload.length > 0 ? payload : title.length > 0 ? title : "[postback]");
530
+ }
531
+ for (const attachment of message?.attachments ?? []) {
532
+ const kind = (attachment.type ?? "file").toLowerCase();
533
+ attachmentTypes.push(kind);
534
+ if (kind === "location") {
535
+ const coordinates = attachment.payload?.coordinates;
536
+ parts.push(`[location: ${attachment.title ?? "location"} ${coordinates?.lat ?? "?"},${coordinates?.long ?? "?"}]`);
537
+ continue;
538
+ }
539
+ const url = attachment.payload?.url?.trim();
540
+ if (url === undefined || url.length === 0) {
541
+ parts.push(`[${kind} attachment]`);
542
+ continue;
543
+ }
544
+ const ingested = kind === "image" || kind === "file" ? await this.downloadAttachment(url, kind) : undefined;
545
+ if (ingested !== undefined) {
546
+ attachments.push(ingested);
547
+ parts.push(`[${kind} attachment: ${ingested.name ?? kind}]`);
548
+ }
549
+ else {
550
+ parts.push(`[${kind} attachment: ${url}]`);
551
+ }
552
+ }
553
+ return {
554
+ userId,
555
+ pageId: normalizeId(event.recipient?.id),
556
+ messageId,
557
+ timestamp: typeof event.timestamp === "number" ? event.timestamp : undefined,
558
+ text: parts.join("\n").trim(),
559
+ attachments,
560
+ attachmentTypes,
561
+ trigger: postback !== undefined && message === undefined ? "postback" : "message",
562
+ };
563
+ }
564
+ /**
565
+ * Fetch one attachment under an explicit host policy.
566
+ *
567
+ * Redirects are followed MANUALLY so every hop is re-validated: the original
568
+ * URL being a signed Meta link says nothing about where a `Location` header
569
+ * points. Each hop must satisfy the host allowlist AND resolve entirely to
570
+ * public addresses, so neither an open redirect nor a hostname whose DNS
571
+ * answer is loopback/private/link-local can reach internal resources. The
572
+ * body is then read incrementally against the size cap, so a chunked
573
+ * response with an absent or lying `Content-Length` cannot exhaust memory.
574
+ */
575
+ async downloadAttachment(url, kind) {
576
+ const controller = new AbortController();
577
+ const timer = setTimeout(() => controller.abort(), this.ingest.timeoutMs);
578
+ try {
579
+ let current = url;
580
+ let response;
581
+ for (let hop = 0;; hop += 1) {
582
+ const rejection = await this.attachmentUrlRejection(current);
583
+ if (rejection !== undefined) {
584
+ this.logger?.warn?.("Blocked unsafe Messenger attachment URL.", {
585
+ url: safeUrlForLog(current),
586
+ reason: rejection,
587
+ ...(hop === 0 ? {} : { hop }),
588
+ });
589
+ return undefined;
590
+ }
591
+ const hopResponse = await this.ingest.fetch(current, { signal: controller.signal, redirect: "manual" });
592
+ if (!isRedirectResponse(hopResponse)) {
593
+ response = hopResponse;
594
+ break;
595
+ }
596
+ const location = hopResponse.headers.get("location");
597
+ await cancelBody(hopResponse);
598
+ if (location === null || location.length === 0) {
599
+ this.logger?.warn?.("Messenger attachment redirect had no location.", { url: safeUrlForLog(current) });
600
+ return undefined;
601
+ }
602
+ if (hop >= MAX_ATTACHMENT_REDIRECTS) {
603
+ this.logger?.warn?.("Messenger attachment exceeded the redirect cap.", { url: safeUrlForLog(current) });
604
+ return undefined;
605
+ }
606
+ let next;
607
+ try {
608
+ next = new URL(location, current).toString();
609
+ }
610
+ catch {
611
+ this.logger?.warn?.("Messenger attachment redirect location was unparseable.", { url: safeUrlForLog(current) });
612
+ return undefined;
613
+ }
614
+ current = next;
615
+ }
616
+ if (!response.ok) {
617
+ this.logger?.warn?.("Messenger attachment download failed.", { url: safeUrlForLog(current), status: response.status });
618
+ return undefined;
619
+ }
620
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
621
+ if (Number.isFinite(declared) && declared > this.ingest.maxBytes) {
622
+ await cancelBody(response);
623
+ this.logger?.warn?.("Messenger attachment exceeds the size cap.", { url: safeUrlForLog(current), bytes: declared });
624
+ return undefined;
625
+ }
626
+ const bytes = await readBodyWithinCap(response, this.ingest.maxBytes);
627
+ if (bytes === undefined) {
628
+ this.logger?.warn?.("Messenger attachment exceeds the size cap.", {
629
+ url: safeUrlForLog(current),
630
+ maxBytes: this.ingest.maxBytes,
631
+ });
632
+ return undefined;
633
+ }
634
+ const mimeType = (response.headers.get("content-type") ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
635
+ const name = fileNameFromUrl(current, kind, mimeType);
636
+ if (kind === "image" || mimeType.startsWith("image/")) {
637
+ return { kind: "image", mimeType: mimeType.length > 0 ? mimeType : "image/jpeg", data: Buffer.from(bytes).toString("base64"), name, sizeBytes: bytes.byteLength };
638
+ }
639
+ const isText = mimeType.startsWith("text/") || mimeType === "application/json";
640
+ if (mimeType === "application/pdf" || isText) {
641
+ return {
642
+ kind: "document",
643
+ mimeType,
644
+ data: Buffer.from(bytes).toString("base64"),
645
+ name,
646
+ sizeBytes: bytes.byteLength,
647
+ ...(isText ? { text: Buffer.from(bytes).toString("utf8") } : {}),
648
+ };
649
+ }
650
+ return undefined;
651
+ }
652
+ catch (error) {
653
+ this.logger?.warn?.("Messenger attachment download failed.", { url: safeUrlForLog(url), error: errorMessage(error) });
654
+ return undefined;
655
+ }
656
+ finally {
657
+ clearTimeout(timer);
658
+ }
659
+ }
660
+ /**
661
+ * Full per-hop admission check: the static URL policy, then DNS resolution
662
+ * with every returned address required to be public. Returns a short
663
+ * rejection reason, or `undefined` when the hop may be fetched.
664
+ */
665
+ async attachmentUrlRejection(url) {
666
+ const policyRejection = attachmentUrlPolicyRejection(url, this.ingest.allowedHostSuffixes);
667
+ if (policyRejection !== undefined) {
668
+ return policyRejection;
669
+ }
670
+ const hostname = normalizeHostname(new URL(url).hostname);
671
+ let addresses;
672
+ try {
673
+ addresses = await this.ingest.resolveAddresses(hostname);
674
+ }
675
+ catch {
676
+ return "dns_resolution_failed";
677
+ }
678
+ if (addresses.length === 0) {
679
+ return "dns_no_addresses";
680
+ }
681
+ // Every answer must be public: one private record is enough to make the
682
+ // fetch a rebinding vector, since we do not control which one is dialled.
683
+ return addresses.every((address) => isPublicUnicastAddress(address)) ? undefined : "non_public_address";
684
+ }
685
+ createStream(userId, send) {
686
+ return new MessengerMessageStream({
687
+ client: this.client,
688
+ recipientId: userId,
689
+ maxMessageChars: this.maxMessageChars,
690
+ ...(send === undefined ? {} : { send }),
691
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
692
+ });
693
+ }
694
+ proactiveSend() {
695
+ return {
696
+ messagingType: this.proactive.messagingType,
697
+ ...(this.proactive.tag === undefined ? {} : { tag: this.proactive.tag }),
698
+ };
699
+ }
700
+ isAuthorized(userId) {
701
+ return this.allowAllUsers || this.allowedUserIds.has(userId);
702
+ }
703
+ async finishCancelledUnlessAcknowledged(stream, signal, error) {
704
+ const acknowledged = isChannelUserCancelReason(signal.reason)
705
+ || (isAgentResponseCancelledError(error) && isChannelUserCancelReason(error.reason));
706
+ if (!acknowledged) {
707
+ await this.finishSafely(stream, this.messages.cancelledText);
708
+ }
709
+ }
710
+ async finishSafely(stream, text) {
711
+ try {
712
+ await stream.finish(text);
713
+ }
714
+ catch (error) {
715
+ this.logger?.error?.("Failed to send Messenger terminal message.", { error: errorMessage(error) });
716
+ }
717
+ }
718
+ async sendTextSafely(userId, text) {
719
+ try {
720
+ await this.client.sendText(userId, normalizeTrailing(text, DEFAULT_EMPTY_FINAL_TEXT));
721
+ }
722
+ catch (error) {
723
+ this.logger?.error?.("Messenger send failed.", { error: errorMessage(error) });
724
+ }
725
+ }
726
+ }
727
+ function parseCommand(text) {
728
+ const match = text.match(/^\/([A-Za-z0-9_]+)(?:\s|$)/u);
729
+ const name = match?.[1]?.toLowerCase();
730
+ return name === "start" || name === "help" || name === "cancel" ? name : undefined;
731
+ }
732
+ function normalizeId(value) {
733
+ if (typeof value === "number" && Number.isSafeInteger(value)) {
734
+ return String(value);
735
+ }
736
+ if (typeof value !== "string") {
737
+ return undefined;
738
+ }
739
+ const trimmed = value.trim();
740
+ return trimmed.length === 0 ? undefined : trimmed;
741
+ }
742
+ function isRecord(value) {
743
+ return typeof value === "object" && value !== null && !Array.isArray(value);
744
+ }
745
+ function withMessageId(result, messageId) {
746
+ if (messageId !== undefined) {
747
+ result.messageId = messageId;
748
+ }
749
+ return result;
750
+ }
751
+ function errorMessage(error) {
752
+ return error instanceof Error ? error.message : String(error);
753
+ }
754
+ /**
755
+ * Static URL policy for an attachment download: HTTPS, no embedded
756
+ * credentials, no literal IP host, and a hostname inside the configured
757
+ * allowlist. Returns a short rejection reason, or `undefined` when the URL
758
+ * passes. Address resolution is a separate, asynchronous check —
759
+ * see {@link MessengerAdapter.attachmentUrlRejection}.
760
+ */
761
+ export function attachmentUrlPolicyRejection(url, allowedHostSuffixes = DEFAULT_MESSENGER_ATTACHMENT_HOST_SUFFIXES) {
762
+ let parsed;
763
+ try {
764
+ parsed = new URL(url);
765
+ }
766
+ catch {
767
+ return "unparseable_url";
768
+ }
769
+ if (parsed.protocol !== "https:") {
770
+ return "not_https";
771
+ }
772
+ if (parsed.username.length > 0 || parsed.password.length > 0) {
773
+ return "embedded_credentials";
774
+ }
775
+ const host = normalizeHostname(parsed.hostname);
776
+ if (host.length === 0) {
777
+ return "empty_host";
778
+ }
779
+ // A literal address bypasses the name-based allowlist entirely, so it is
780
+ // never acceptable here regardless of which address it is.
781
+ if (isIP(host) !== 0) {
782
+ return "ip_literal_host";
783
+ }
784
+ if (!hostMatchesSuffixes(host, allowedHostSuffixes)) {
785
+ return "host_not_allowed";
786
+ }
787
+ return undefined;
788
+ }
789
+ /** Only fetch https URLs on allowlisted Meta CDN hostnames. */
790
+ export function isSafeAttachmentUrl(url, allowedHostSuffixes = DEFAULT_MESSENGER_ATTACHMENT_HOST_SUFFIXES) {
791
+ return attachmentUrlPolicyRejection(url, allowedHostSuffixes) === undefined;
792
+ }
793
+ function normalizeHostname(hostname) {
794
+ const lower = hostname.trim().toLowerCase();
795
+ const unbracketed = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
796
+ // A trailing dot names the same host but would defeat suffix matching.
797
+ return unbracketed.endsWith(".") ? unbracketed.slice(0, -1) : unbracketed;
798
+ }
799
+ function normalizeHostSuffixes(suffixes) {
800
+ return suffixes
801
+ .map((suffix) => normalizeHostname(suffix))
802
+ .filter((suffix) => suffix.length > 0);
803
+ }
804
+ function hostMatchesSuffixes(host, suffixes) {
805
+ return suffixes.some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
806
+ }
807
+ async function resolveHostAddresses(hostname) {
808
+ const records = await lookup(hostname, { all: true, verbatim: true });
809
+ return records.map((record) => record.address);
810
+ }
811
+ function isRedirectResponse(response) {
812
+ return response.status >= 300 && response.status < 400;
813
+ }
814
+ /** Release a response body we are not going to read, ignoring teardown races. */
815
+ async function cancelBody(response) {
816
+ try {
817
+ await response.body?.cancel();
818
+ }
819
+ catch {
820
+ // Already closed or unsupported by an injected fake; nothing to release.
821
+ }
822
+ }
823
+ /**
824
+ * Read a response body incrementally, stopping and cancelling the stream the
825
+ * moment it exceeds `maxBytes`. Returns `undefined` when over the cap, so an
826
+ * absent or dishonest `Content-Length` cannot buffer an unbounded body.
827
+ */
828
+ async function readBodyWithinCap(response, maxBytes) {
829
+ const body = response.body;
830
+ if (body === null || body === undefined) {
831
+ // No stream to meter (empty body, or an injected fake without one): fall
832
+ // back to the buffered read, which is still bounded by the check below.
833
+ const buffered = new Uint8Array(await response.arrayBuffer());
834
+ return buffered.byteLength > maxBytes ? undefined : buffered;
835
+ }
836
+ const reader = body.getReader();
837
+ const chunks = [];
838
+ let total = 0;
839
+ try {
840
+ for (;;) {
841
+ const { done, value } = await reader.read();
842
+ if (done) {
843
+ break;
844
+ }
845
+ if (value === undefined) {
846
+ continue;
847
+ }
848
+ total += value.byteLength;
849
+ if (total > maxBytes) {
850
+ await reader.cancel();
851
+ return undefined;
852
+ }
853
+ chunks.push(value);
854
+ }
855
+ }
856
+ finally {
857
+ try {
858
+ reader.releaseLock();
859
+ }
860
+ catch {
861
+ // The reader is already released when the stream errored or was cancelled.
862
+ }
863
+ }
864
+ const out = new Uint8Array(total);
865
+ let offset = 0;
866
+ for (const chunk of chunks) {
867
+ out.set(chunk, offset);
868
+ offset += chunk.byteLength;
869
+ }
870
+ return out;
871
+ }
872
+ /**
873
+ * True only for a globally routable unicast address. Everything else —
874
+ * loopback, private, link-local, CGNAT, multicast, and the reserved/documentation
875
+ * ranges — is rejected, so a hostname whose DNS answer points inside the
876
+ * deployment cannot be fetched.
877
+ */
878
+ export function isPublicUnicastAddress(address) {
879
+ const version = isIP(address);
880
+ if (version === 4) {
881
+ return isPublicIPv4(address);
882
+ }
883
+ if (version === 6) {
884
+ return isPublicIPv6(address);
885
+ }
886
+ return false;
887
+ }
888
+ function isPublicIPv4(address) {
889
+ const octets = address.split(".").map((part) => Number(part));
890
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
891
+ return false;
892
+ }
893
+ const [a, b] = octets;
894
+ if (a === 0 || a === 10 || a === 127)
895
+ return false; // this-network, private, loopback
896
+ if (a === 100 && b >= 64 && b <= 127)
897
+ return false; // CGNAT 100.64/10
898
+ if (a === 169 && b === 254)
899
+ return false; // link-local
900
+ if (a === 172 && b >= 16 && b <= 31)
901
+ return false; // private 172.16/12
902
+ if (a === 192 && b === 168)
903
+ return false; // private
904
+ if (a === 192 && b === 0)
905
+ return false; // 192.0.0/24 protocol assignments, 192.0.2/24 docs
906
+ if (a === 192 && b === 88)
907
+ return false; // 192.88.99/24 6to4 relay anycast
908
+ if (a === 198 && (b === 18 || b === 19))
909
+ return false; // benchmarking 198.18/15
910
+ if (a === 198 && b === 51)
911
+ return false; // 198.51.100/24 docs
912
+ if (a === 203 && b === 0)
913
+ return false; // 203.0.113/24 docs
914
+ if (a >= 224)
915
+ return false; // multicast and reserved, incl. broadcast
916
+ return true;
917
+ }
918
+ function isPublicIPv6(address) {
919
+ const bytes = parseIPv6(address);
920
+ if (bytes === undefined) {
921
+ return false;
922
+ }
923
+ // IPv4-mapped/compatible: classify by the embedded IPv4 address.
924
+ const isV4Mapped = bytes.slice(0, 10).every((byte) => byte === 0)
925
+ && ((bytes[10] === 0xff && bytes[11] === 0xff) || (bytes[10] === 0 && bytes[11] === 0));
926
+ if (isV4Mapped) {
927
+ const embedded = `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
928
+ // `::` and `::1` fall out of this as 0.0.0.0 / 0.0.0.1, both rejected.
929
+ return isPublicIPv4(embedded);
930
+ }
931
+ const first = bytes[0] ?? 0;
932
+ const second = bytes[1] ?? 0;
933
+ if (first === 0xff)
934
+ return false; // multicast
935
+ if ((first & 0xfe) === 0xfc)
936
+ return false; // unique local fc00::/7
937
+ if (first === 0xfe && (second & 0xc0) === 0x80)
938
+ return false; // link-local fe80::/10
939
+ if (first === 0x20 && second === 0x01 && (bytes[2] ?? 0) === 0x0d && (bytes[3] ?? 0) === 0xb8)
940
+ return false; // 2001:db8::/32
941
+ if (first === 0x00 && second === 0x64 && (bytes[2] ?? 0) === 0xff && (bytes[3] ?? 0) === 0x9b)
942
+ return false; // NAT64 64:ff9b::/96
943
+ return true;
944
+ }
945
+ /** Expand an IPv6 literal (including `::` and a trailing IPv4 tail) to 16 bytes. */
946
+ function parseIPv6(address) {
947
+ const withoutZone = address.split("%", 1)[0] ?? address;
948
+ const [head, tail, ...rest] = withoutZone.split("::");
949
+ if (rest.length > 0 || head === undefined) {
950
+ return undefined;
951
+ }
952
+ const expandGroups = (part) => {
953
+ if (part.length === 0) {
954
+ return [];
955
+ }
956
+ const bytes = [];
957
+ const groups = part.split(":");
958
+ for (const [index, group] of groups.entries()) {
959
+ if (group.includes(".")) {
960
+ // Only a trailing IPv4 tail is legal.
961
+ if (index !== groups.length - 1) {
962
+ return undefined;
963
+ }
964
+ const octets = group.split(".").map((octet) => Number(octet));
965
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
966
+ return undefined;
967
+ }
968
+ bytes.push(...octets);
969
+ continue;
970
+ }
971
+ if (!/^[0-9a-f]{1,4}$/u.test(group)) {
972
+ return undefined;
973
+ }
974
+ const value = Number.parseInt(group, 16);
975
+ bytes.push((value >> 8) & 0xff, value & 0xff);
976
+ }
977
+ return bytes;
978
+ };
979
+ const headBytes = expandGroups(head);
980
+ const tailBytes = tail === undefined ? [] : expandGroups(tail);
981
+ if (headBytes === undefined || tailBytes === undefined) {
982
+ return undefined;
983
+ }
984
+ if (tail === undefined) {
985
+ return headBytes.length === 16 ? headBytes : undefined;
986
+ }
987
+ const fill = 16 - headBytes.length - tailBytes.length;
988
+ if (fill < 0) {
989
+ return undefined;
990
+ }
991
+ return [...headBytes, ...new Array(fill).fill(0), ...tailBytes];
992
+ }
993
+ function safeUrlForLog(url) {
994
+ try {
995
+ const parsed = new URL(url);
996
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
997
+ }
998
+ catch {
999
+ return "<invalid url>";
1000
+ }
1001
+ }
1002
+ function fileNameFromUrl(url, kind, mimeType) {
1003
+ try {
1004
+ const last = new URL(url).pathname.split("/").filter((segment) => segment.length > 0).at(-1);
1005
+ if (last !== undefined && /^[\w.-]{1,120}$/u.test(last) && last.includes(".")) {
1006
+ return last;
1007
+ }
1008
+ }
1009
+ catch {
1010
+ // Fall through to a synthesized name.
1011
+ }
1012
+ const extension = mimeType === "image/png" ? ".png"
1013
+ : mimeType === "image/gif" ? ".gif"
1014
+ : mimeType === "image/webp" ? ".webp"
1015
+ : mimeType.startsWith("image/") ? ".jpg"
1016
+ : mimeType === "application/pdf" ? ".pdf"
1017
+ : mimeType.startsWith("text/") ? ".txt"
1018
+ : "";
1019
+ return `messenger-${kind}${extension}`;
1020
+ }
1021
+ //# sourceMappingURL=adapter.js.map