@kuralle-agents/messaging-meta 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -12,11 +12,11 @@ npm install @kuralle-agents/messaging-meta @kuralle-agents/messaging
12
12
 
13
13
  Provides production-ready clients for Meta's three messaging platforms, built on a shared Graph API foundation with retry logic, rate limiting, and unified error handling.
14
14
 
15
- - **WhatsApp** — full API coverage: text (auto-split at 4096 chars), media, templates, interactive buttons, list messages, CTA buttons, WhatsApp Flows, reactions, locations, contacts.
16
- - **Messenger** — button templates, generic templates (carousel), quick replies, sender actions, user profile lookups.
17
- - **Instagram** — text (auto-split at 1000 bytes), quick replies, carousels, private replies to comments, ice breakers, message tags.
15
+ - **WhatsApp** — full API coverage: text (auto-split at 4096 chars), media, templates, interactive buttons, list messages (max 10 rows total), CTA buttons, WhatsApp Flows, reactions, locations, contacts, typing indicators (via message id), commerce (single/multi-product, catalog, address messages, inbound order + product-inquiry parsing).
16
+ - **Messenger** — button templates, generic templates (carousel), quick replies, sender actions (`mark_seen` via PSID), user profile lookups.
17
+ - **Instagram** — text (auto-split at 1000 bytes), media by URL (audio/image/video/file), quick replies, carousels, private replies to comments, ice breakers, message tags, `mark_seen`.
18
18
  - **Main barrel** — `GraphAPIClient`, `BaseMetaClient`, `verifySignature`, `normalizeWebhook`, error classes.
19
- - **Shared Graph API** — `MetaErrorClassifier`, `SmartSplitter`, `TruncateSplitter`, `ByteLimitSplitter`, unicode utilities.
19
+ - **Shared Graph API** — default `v24.0`, `MetaErrorClassifier`, `SmartSplitter`, `TruncateSplitter`, `ByteLimitSplitter`, unicode utilities.
20
20
 
21
21
  WhatsApp is available only via the `/whatsapp` subpath (see below).
22
22
 
@@ -66,6 +66,35 @@ const instagram = createInstagramClient({
66
66
  });
67
67
  ```
68
68
 
69
+ ### WhatsApp commerce
70
+
71
+ Share catalog products and receive orders without leaving WhatsApp:
72
+
73
+ ```typescript
74
+ import { parseInboundOrder, parseProductInquiry } from '@kuralle-agents/messaging-meta/whatsapp';
75
+
76
+ // Single product, multi-product (max 10 sections / 30 products), catalog, address request
77
+ await whatsapp.sendProduct(to, { catalogId, productRetailerId: 'sku-1', body: { text: 'Check this out' } });
78
+ await whatsapp.sendProductList(to, {
79
+ header: { type: 'text', text: 'Bestsellers' },
80
+ body: { text: 'Pick your favorites' },
81
+ catalogId,
82
+ sections: [{ title: 'Cakes', productRetailerIds: ['sku-1', 'sku-2'] }],
83
+ });
84
+ await whatsapp.sendCatalog(to, { body: { text: 'Browse our catalog' } });
85
+ await whatsapp.sendAddressRequest(to, { body: { text: 'Where should we deliver?' }, country: 'IN' });
86
+
87
+ // Inbound orders (webhook message type "order") arrive typed:
88
+ whatsapp.onMessage(async (msg) => {
89
+ const order = parseInboundOrder(msg);
90
+ if (order) {
91
+ // order.catalog_id, order.product_items[].{product_retailer_id, quantity, item_price, currency}
92
+ }
93
+ });
94
+ ```
95
+
96
+ Address submissions arrive as `nfm_reply` interactive messages — use `parseInboundAddress(msg)` for a typed accessor. Address messages are country-gated by Meta (currently India only).
97
+
69
98
  ### Webhook verification without a client
70
99
 
71
100
  ```typescript
@@ -81,7 +110,7 @@ const events = normalizeWebhook(JSON.parse(rawBody));
81
110
  | Import path | Contents |
82
111
  |---|---|
83
112
  | `@kuralle-agents/messaging-meta` | `GraphAPIClient`, `BaseMetaClient`, errors, `verifySignature`, `normalizeWebhook`, `MessengerClient`, `InstagramClient` |
84
- | `@kuralle-agents/messaging-meta/whatsapp` | `WhatsAppClient`, `createWhatsAppClient`, templates, flows, format converter |
113
+ | `@kuralle-agents/messaging-meta/whatsapp` | `WhatsAppClient`, `createWhatsAppClient`, templates, flows, commerce (`parseInboundOrder`, `parseInboundAddress`, `parseProductInquiry`), format converter |
85
114
  | `@kuralle-agents/messaging-meta/messenger` | `MessengerClient`, `createMessengerClient`, format converter |
86
115
  | `@kuralle-agents/messaging-meta/instagram` | `InstagramClient`, `createInstagramClient`, ice breakers, format converter |
87
116
  | `@kuralle-agents/messaging-meta/webhooks` | `verifySignature`, `normalizeWebhook` only |
@@ -16,7 +16,7 @@ export interface GraphAPIClientConfig {
16
16
  accessToken: string;
17
17
  /** App secret used for webhook signature verification and appsecret_proof. */
18
18
  appSecret: string;
19
- /** Graph API version (e.g. `"v21.0"`). Default `"v21.0"`. */
19
+ /** Graph API version (e.g. `"v24.0"`). Default `"v24.0"`. */
20
20
  apiVersion?: string;
21
21
  /** Base URL for the Graph API. Default `"https://graph.facebook.com"`. */
22
22
  baseUrl?: string;
@@ -52,6 +52,11 @@ export declare class GraphAPIClient {
52
52
  post<T>(endpoint: string, body: unknown): Promise<T>;
53
53
  /** POST with a multipart body (e.g. media uploads). */
54
54
  postFormData<T>(endpoint: string, formData: FormData): Promise<T>;
55
+ /** DELETE with `access_token` query param applied. */
56
+ delete<T>(endpoint: string, opts?: {
57
+ params?: Record<string, string>;
58
+ body?: unknown;
59
+ }): Promise<T>;
55
60
  /** Fetch a binary resource from an absolute CDN URL. */
56
61
  fetchBinary(url: string): Promise<Buffer>;
57
62
  /** App secret proof generation is left to callers that need it (not part of the HTTP layer). */
@@ -29,7 +29,7 @@ export class GraphAPIClient {
29
29
  constructor(config) {
30
30
  this.accessToken = config.accessToken;
31
31
  this.appSecret = config.appSecret;
32
- const apiVersion = config.apiVersion ?? 'v21.0';
32
+ const apiVersion = config.apiVersion ?? 'v24.0';
33
33
  const baseUrl = (config.baseUrl ?? 'https://graph.facebook.com').replace(/\/+$/, '');
34
34
  this.http = new HttpClient({
35
35
  baseUrl: `${baseUrl}/${apiVersion}`,
@@ -56,6 +56,13 @@ export class GraphAPIClient {
56
56
  async postFormData(endpoint, formData) {
57
57
  return this.http.postFormData(endpoint, formData);
58
58
  }
59
+ /** DELETE with `access_token` query param applied. */
60
+ async delete(endpoint, opts) {
61
+ return this.http.delete(endpoint, {
62
+ params: { access_token: this.accessToken, ...(opts?.params ?? {}) },
63
+ body: opts?.body,
64
+ });
65
+ }
59
66
  /** Fetch a binary resource from an absolute CDN URL. */
60
67
  async fetchBinary(url) {
61
68
  return this.http.fetchBinary(url);
@@ -50,10 +50,14 @@ export function classifyMetaError(status, body, platform) {
50
50
  if (status === 403 || metaCode === 10 || metaCode === 200) {
51
51
  return new PermissionError(metaMessage, platform);
52
52
  }
53
- // --- 24-hour window closed (Meta code 131047) ---
54
- if (metaCode === 131047) {
53
+ // --- 24-hour window closed (WhatsApp 131047, Messenger 1545041) ---
54
+ if (metaCode === 131047 || metaCode === 1545041) {
55
55
  return new WindowClosedError(metaMessage, platform, new Date());
56
56
  }
57
+ // --- Person unavailable (Messenger 551) ---
58
+ if (metaCode === 551 || metaSubcode === 551) {
59
+ return new MessagingError(metaMessage, 'person_unavailable', platform);
60
+ }
57
61
  // --- Recipient not reachable (HTTP 404 or Meta code 131026) ---
58
62
  if (status === 404 || metaCode === 131026) {
59
63
  return new RecipientError(metaMessage, platform);
@@ -8,13 +8,13 @@
8
8
  * inherited. Only Instagram-specific outbound paths and inbound
9
9
  * normalization live here.
10
10
  *
11
- * Key quirks vs Messenger:
11
+ * Key differences from WhatsApp / Messenger:
12
12
  * - Base URL is `graph.instagram.com` by default.
13
- * - Only IMAGE attachments are supported.
14
- * - Send API caps at 1000 UTF-8 bytes per message — long text is split
15
- * via the shared {@link ByteLimitSplitter} strategy.
16
- * - Send response only carries `message_id` (no `recipient_id`).
17
- * - Mark-as-read is a no-op (platform doesn't expose it).
13
+ * - Media is sent by public URL (audio, image, video, file attachments).
14
+ * - Message limit is 1000 bytes (UTF-8), not characters.
15
+ * - Send response contains `message_id` (recipient id is implicit).
16
+ * - Only `HUMAN_AGENT` message tag is supported (7-day window).
17
+ * - Ice breakers replace persistent menus.
18
18
  */
19
19
  import { type FormatConverter, type InboundMessage, type InteractiveMessage, type MediaDownload, type MediaHandle, type MediaPayload, type MediaUploadOptions, type ReactionData, type SendResult, type StatusUpdate } from '@kuralle-agents/messaging';
20
20
  import { BaseMetaClient } from '../base-client.js';
@@ -31,8 +31,6 @@ export declare class InstagramClient extends BaseMetaClient<InstagramInbound, In
31
31
  readonly platform: "instagram";
32
32
  private readonly config;
33
33
  private readonly formatConverterInstance;
34
- private readonly mediaCache?;
35
- private readonly mediaStrategy;
36
34
  private readonly splitter;
37
35
  constructor(config: InstagramClientConfig);
38
36
  /** Instagram-specific format converter (plain text). */
@@ -44,19 +42,20 @@ export declare class InstagramClient extends BaseMetaClient<InstagramInbound, In
44
42
  */
45
43
  sendText(to: string, text: string): Promise<SendResult>;
46
44
  /**
47
- * Send an image. Instagram only supports image attachments.
48
- *
49
- * Uses {@link MediaStrategy} so identical Buffer uploads dedup by
50
- * SHA-256 content hash (C-13.7).
45
+ * Send a media message by URL. Instagram messaging supports audio, image,
46
+ * video, and file attachments via `attachment.payload.url`.
51
47
  */
52
48
  sendMedia(to: string, media: MediaPayload): Promise<SendResult>;
53
49
  sendInteractive(to: string, msg: InteractiveMessage): Promise<SendResult>;
54
50
  sendRaw(to: string, payload: InstagramOutbound): Promise<SendResult>;
55
- /** Instagram has no mark-as-read endpoint; this is a no-op. */
56
- markAsRead(_messageId: string): Promise<void>;
51
+ /**
52
+ * Messenger/Instagram `mark_seen` targets the conversation partner (PSID),
53
+ * not a message id. Pass the user's IGSID despite the PlatformClient param name.
54
+ */
55
+ markAsRead(recipientId: string): Promise<void>;
57
56
  sendTypingIndicator(to: string): Promise<void>;
58
- uploadMedia(file: Buffer | ReadableStream, options: MediaUploadOptions): Promise<MediaHandle>;
59
- downloadMedia(mediaId: string): Promise<MediaDownload>;
57
+ uploadMedia(_file: Buffer | ReadableStream, _options: MediaUploadOptions): Promise<MediaHandle>;
58
+ downloadMedia(_mediaId: string): Promise<MediaDownload>;
60
59
  sendQuickReplies(to: string, text: string, replies: InstagramQuickReply[]): Promise<SendResult>;
61
60
  sendGenericTemplate(to: string, template: InstagramGenericTemplate): Promise<SendResult>;
62
61
  sendButtonTemplate(to: string, template: InstagramButtonTemplate): Promise<SendResult>;
@@ -74,6 +73,7 @@ export declare class InstagramClient extends BaseMetaClient<InstagramInbound, In
74
73
  private mapMessageType;
75
74
  private extractTextFallback;
76
75
  private extractMedia;
76
+ private resolveAttachmentType;
77
77
  private sendSingleText;
78
78
  }
79
79
  export {};
@@ -8,15 +8,15 @@
8
8
  * inherited. Only Instagram-specific outbound paths and inbound
9
9
  * normalization live here.
10
10
  *
11
- * Key quirks vs Messenger:
11
+ * Key differences from WhatsApp / Messenger:
12
12
  * - Base URL is `graph.instagram.com` by default.
13
- * - Only IMAGE attachments are supported.
14
- * - Send API caps at 1000 UTF-8 bytes per message — long text is split
15
- * via the shared {@link ByteLimitSplitter} strategy.
16
- * - Send response only carries `message_id` (no `recipient_id`).
17
- * - Mark-as-read is a no-op (platform doesn't expose it).
13
+ * - Media is sent by public URL (audio, image, video, file attachments).
14
+ * - Message limit is 1000 bytes (UTF-8), not characters.
15
+ * - Send response contains `message_id` (recipient id is implicit).
16
+ * - Only `HUMAN_AGENT` message tag is supported (7-day window).
17
+ * - Ice breakers replace persistent menus.
18
18
  */
19
- import { MediaError, MessagingError, FileHashDedupStrategy, UploadStrategy, } from '@kuralle-agents/messaging';
19
+ import { MessagingError, } from '@kuralle-agents/messaging';
20
20
  import { BaseMetaClient } from '../base-client.js';
21
21
  import { GraphAPIClient } from '../graph-api/client.js';
22
22
  import { ByteLimitSplitter } from '../message-splitter.js';
@@ -40,8 +40,6 @@ export class InstagramClient extends BaseMetaClient {
40
40
  platform = 'instagram';
41
41
  config;
42
42
  formatConverterInstance;
43
- mediaCache;
44
- mediaStrategy;
45
43
  splitter = new ByteLimitSplitter(MAX_MESSAGE_BYTES);
46
44
  constructor(config) {
47
45
  const full = config;
@@ -58,8 +56,6 @@ export class InstagramClient extends BaseMetaClient {
58
56
  super(full, graphApi);
59
57
  this.config = full;
60
58
  this.formatConverterInstance = new InstagramFormatConverter();
61
- this.mediaCache = config.mediaCache;
62
- this.mediaStrategy = new FileHashDedupStrategy(new UploadStrategy((file, opts) => this.uploadMedia(file, opts)));
63
59
  }
64
60
  /** Instagram-specific format converter (plain text). */
65
61
  get formatConverter() {
@@ -82,25 +78,18 @@ export class InstagramClient extends BaseMetaClient {
82
78
  return result;
83
79
  }
84
80
  /**
85
- * Send an image. Instagram only supports image attachments.
86
- *
87
- * Uses {@link MediaStrategy} so identical Buffer uploads dedup by
88
- * SHA-256 content hash (C-13.7).
81
+ * Send a media message by URL. Instagram messaging supports audio, image,
82
+ * video, and file attachments via `attachment.payload.url`.
89
83
  */
90
84
  async sendMedia(to, media) {
91
- if (media.type !== 'image') {
92
- throw new MediaError(`Instagram only supports image attachments. Received: ${media.type}`, 'instagram');
85
+ if (typeof media.data !== 'string') {
86
+ throw new MessagingError('Instagram messaging sends media by public URL — pass media.data as a URL string.', 'unsupported', 'instagram');
93
87
  }
94
- // Normalize stream Buffer so dedup can hash.
95
- const normalized = typeof media.data === 'string' || Buffer.isBuffer(media.data)
96
- ? media
97
- : { ...media, data: await streamToBuffer(media.data) };
98
- const resolved = await this.mediaStrategy.resolve(normalized);
99
- const url = resolved.kind === 'url' ? resolved.url : (resolved.handle.url ?? '');
88
+ const attachmentType = this.resolveAttachmentType(media.mimeType, media.type);
100
89
  const response = await this.graphApi.post(`${this.config.igId}/messages`, {
101
90
  recipient: { id: to },
102
91
  message: {
103
- attachments: [{ type: 'image', payload: { url } }],
92
+ attachment: { type: attachmentType, payload: { url: media.data } },
104
93
  },
105
94
  });
106
95
  return this.toSendResult(to, response);
@@ -144,9 +133,15 @@ export class InstagramClient extends BaseMetaClient {
144
133
  });
145
134
  return this.toSendResult(to, response);
146
135
  }
147
- /** Instagram has no mark-as-read endpoint; this is a no-op. */
148
- async markAsRead(_messageId) {
149
- // intentionally empty
136
+ /**
137
+ * Messenger/Instagram `mark_seen` targets the conversation partner (PSID),
138
+ * not a message id. Pass the user's IGSID despite the PlatformClient param name.
139
+ */
140
+ async markAsRead(recipientId) {
141
+ await this.graphApi.post(`${this.config.igId}/messages`, {
142
+ recipient: { id: recipientId },
143
+ sender_action: 'mark_seen',
144
+ });
150
145
  }
151
146
  async sendTypingIndicator(to) {
152
147
  await this.graphApi.post(`${this.config.igId}/messages`, {
@@ -157,26 +152,11 @@ export class InstagramClient extends BaseMetaClient {
157
152
  // =========================================================================
158
153
  // Media
159
154
  // =========================================================================
160
- async uploadMedia(file, options) {
161
- const buffer = Buffer.isBuffer(file) ? file : await streamToBuffer(file);
162
- const blob = new Blob([buffer], { type: options.mimeType });
163
- const formData = new FormData();
164
- formData.append('file', blob, options.filename ?? 'file');
165
- formData.append('type', options.mimeType);
166
- const response = await this.graphApi.postFormData(`${this.config.igId}/media`, formData);
167
- return { mediaId: response.id, url: response.url };
155
+ async uploadMedia(_file, _options) {
156
+ throw new MessagingError('Instagram messaging sends media by public URL — uploadMedia is not supported. Pass a URL via sendMedia instead.', 'unsupported', 'instagram');
168
157
  }
169
- async downloadMedia(mediaId) {
170
- if (this.mediaCache) {
171
- return this.mediaCache.getOrDownload(mediaId, async () => {
172
- const mediaInfo = await this.graphApi.get(mediaId);
173
- const data = await this.graphApi.fetchBinary(mediaInfo.url);
174
- return { data, mimeType: mediaInfo.mime_type };
175
- });
176
- }
177
- const mediaInfo = await this.graphApi.get(mediaId);
178
- const data = await this.graphApi.fetchBinary(mediaInfo.url);
179
- return { data, mimeType: mediaInfo.mime_type };
158
+ async downloadMedia(_mediaId) {
159
+ throw new MessagingError('Instagram inbound media arrives as CDN URLs in webhooks — downloadMedia is not supported.', 'unsupported', 'instagram');
180
160
  }
181
161
  // =========================================================================
182
162
  // Instagram-specific — Quick Replies
@@ -253,12 +233,14 @@ export class InstagramClient extends BaseMetaClient {
253
233
  },
254
234
  get: async () => {
255
235
  const result = await this.graphApi.get(`${this.config.igId}/messenger_profile`, { fields: 'ice_breakers' });
256
- return result.data?.[0]?.ice_breakers ?? [];
236
+ const row = result.data?.[0];
237
+ if (!row?.call_to_actions)
238
+ return [];
239
+ return [{ call_to_actions: row.call_to_actions, locale: row.locale }];
257
240
  },
258
241
  delete: async () => {
259
- await this.graphApi.post(`${this.config.igId}/messenger_profile`, {
260
- fields: ['ice_breakers'],
261
- _method: 'DELETE',
242
+ await this.graphApi.delete(`${this.config.igId}/messenger_profile`, {
243
+ body: { fields: ['ice_breakers'] },
262
244
  });
263
245
  },
264
246
  };
@@ -343,6 +325,8 @@ export class InstagramClient extends BaseMetaClient {
343
325
  image: 'image',
344
326
  video: 'video',
345
327
  audio: 'audio',
328
+ file: 'document',
329
+ document: 'document',
346
330
  sticker: 'sticker',
347
331
  interactive: 'interactive',
348
332
  postback: 'interactive',
@@ -362,19 +346,32 @@ export class InstagramClient extends BaseMetaClient {
362
346
  return undefined;
363
347
  }
364
348
  extractMedia(msg) {
365
- const mediaTypes = ['image', 'video', 'audio', 'sticker'];
349
+ const mediaTypes = ['image', 'video', 'audio', 'document', 'sticker'];
366
350
  for (const type of mediaTypes) {
367
351
  const media = msg[type];
368
- if (media && 'id' in media) {
352
+ if (media && ('id' in media || 'url' in media)) {
369
353
  return {
370
- id: media.id,
354
+ id: 'id' in media ? media.id : '',
371
355
  mimeType: 'mime_type' in media ? media.mime_type : undefined,
356
+ url: 'url' in media ? media.url : undefined,
372
357
  caption: 'caption' in media ? media.caption : undefined,
358
+ filename: 'filename' in media ? media.filename : undefined,
373
359
  };
374
360
  }
375
361
  }
376
362
  return undefined;
377
363
  }
364
+ resolveAttachmentType(mimeType, mediaType) {
365
+ if (mediaType === 'document')
366
+ return 'file';
367
+ if (mimeType.startsWith('image/'))
368
+ return 'image';
369
+ if (mimeType.startsWith('video/'))
370
+ return 'video';
371
+ if (mimeType.startsWith('audio/'))
372
+ return 'audio';
373
+ return 'file';
374
+ }
378
375
  async sendSingleText(to, text) {
379
376
  const response = await this.graphApi.post(`${this.config.igId}/messages`, {
380
377
  recipient: { id: to },
@@ -383,17 +380,3 @@ export class InstagramClient extends BaseMetaClient {
383
380
  return this.toSendResult(to, response);
384
381
  }
385
382
  }
386
- // ---------------------------------------------------------------------------
387
- // Utility
388
- // ---------------------------------------------------------------------------
389
- async function streamToBuffer(stream) {
390
- const chunks = [];
391
- const reader = stream.getReader();
392
- for (;;) {
393
- const { done, value } = await reader.read();
394
- if (done)
395
- break;
396
- chunks.push(value);
397
- }
398
- return Buffer.concat(chunks);
399
- }
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * Key differences from WhatsApp / Messenger:
11
11
  * - Base URL is `graph.instagram.com` (not `graph.facebook.com`).
12
- * - Only IMAGE attachments are supported (no video, audio, or file).
12
+ * - Media is sent by public URL (audio, image, video, file attachments).
13
13
  * - Message limit is 1000 bytes (UTF-8), not characters.
14
- * - Send response contains `message_id` only (no `recipient_id`).
14
+ * - Quick replies support text, user_phone_number, and user_email content types.
15
15
  * - Only `HUMAN_AGENT` message tag is supported (7-day window).
16
16
  * - Ice breakers replace persistent menus.
17
17
  */
@@ -61,27 +61,25 @@ export interface InstagramThreadId {
61
61
  }
62
62
  /**
63
63
  * Raw response from the Instagram Messaging API messages endpoint.
64
- *
65
- * NOTE: Unlike Messenger, Instagram does NOT return `recipient_id`
66
- * in the send response.
67
64
  */
68
65
  export interface InstagramSendResponse {
69
66
  /** Platform-assigned message identifier. */
70
67
  message_id: string;
68
+ /** Recipient IGSID (returned by some API versions). */
69
+ recipient_id?: string;
71
70
  }
72
71
  /**
73
72
  * A quick reply option for Instagram messages.
74
73
  *
75
- * Instagram only supports `content_type: "text"` quick replies.
76
74
  * Maximum 13 quick replies per message.
77
75
  */
78
76
  export interface InstagramQuickReply {
79
- /** Must be `"text"`. Instagram only supports text quick replies. */
80
- content_type: 'text';
81
- /** Display text for the quick reply button (max 20 chars). */
82
- title: string;
77
+ /** Quick reply content type. */
78
+ content_type: 'text' | 'user_phone_number' | 'user_email';
79
+ /** Display text for text quick replies (max 20 chars). */
80
+ title?: string;
83
81
  /** Payload string returned when the user taps this quick reply. */
84
- payload: string;
82
+ payload?: string;
85
83
  }
86
84
  /**
87
85
  * A button within an Instagram template message.
@@ -9,9 +9,9 @@
9
9
  *
10
10
  * Key differences from WhatsApp / Messenger:
11
11
  * - Base URL is `graph.instagram.com` (not `graph.facebook.com`).
12
- * - Only IMAGE attachments are supported (no video, audio, or file).
12
+ * - Media is sent by public URL (audio, image, video, file attachments).
13
13
  * - Message limit is 1000 bytes (UTF-8), not characters.
14
- * - Send response contains `message_id` only (no `recipient_id`).
14
+ * - Quick replies support text, user_phone_number, and user_email content types.
15
15
  * - Only `HUMAN_AGENT` message tag is supported (7-day window).
16
16
  * - Ice breakers replace persistent menus.
17
17
  */
@@ -63,7 +63,7 @@ export declare class MessengerClient extends BaseMetaClient<MessengerInbound, Me
63
63
  sendMedia(to: string, media: MediaPayload): Promise<SendResult>;
64
64
  sendInteractive(to: string, msg: InteractiveMessage): Promise<SendResult>;
65
65
  sendRaw(to: string, payload: MessengerOutbound): Promise<SendResult>;
66
- markAsRead(messageId: string): Promise<void>;
66
+ markAsRead(recipientId: string): Promise<void>;
67
67
  sendTypingIndicator(to: string): Promise<void>;
68
68
  sendButtonTemplate(to: string, template: ButtonTemplate): Promise<SendResult>;
69
69
  sendGenericTemplate(to: string, template: GenericTemplate): Promise<SendResult>;
@@ -150,11 +150,12 @@ export class MessengerClient extends BaseMetaClient {
150
150
  });
151
151
  return this.toSendResult(to, response);
152
152
  }
153
- async markAsRead(messageId) {
154
- // Messenger's mark_seen is a sender action that requires the recipient ID,
155
- // not a message ID. We accept the recipient PSID here since the platform
156
- // does not support marking individual messages.
157
- await this.sendSenderAction(messageId, 'mark_seen');
153
+ async markAsRead(recipientId) {
154
+ /**
155
+ * Messenger/Instagram `mark_seen` targets the conversation partner (PSID),
156
+ * not a message id. Pass the user's PSID despite the PlatformClient param name.
157
+ */
158
+ await this.sendSenderAction(recipientId, 'mark_seen');
158
159
  }
159
160
  async sendTypingIndicator(to) {
160
161
  await this.sendSenderAction(to, 'typing_on');
@@ -231,7 +232,7 @@ export class MessengerClient extends BaseMetaClient {
231
232
  return this.graphApi.post(`${this.config.pageId}/personas`, config);
232
233
  },
233
234
  delete: async (personaId) => {
234
- await this.graphApi.post(personaId, { _method: 'DELETE' });
235
+ await this.graphApi.delete(personaId);
235
236
  },
236
237
  };
237
238
  // =========================================================================
@@ -6,4 +6,4 @@
6
6
  export { verifySignature } from './verifier.js';
7
7
  export type { VerifySignatureOptions } from './verifier.js';
8
8
  export { normalizeWebhook } from './normalizer.js';
9
- export type { NormalizedWebhookEvents, NormalizedMessage, NormalizedStatus, NormalizedReaction, } from './normalizer.js';
9
+ export type { NormalizedWebhookEvents, NormalizedMessage, NormalizedStatus, NormalizedReaction, NormalizedWebhookError, WhatsAppReferral, } from './normalizer.js';
@@ -20,6 +20,19 @@ export interface NormalizedWebhookEvents {
20
20
  statuses: NormalizedStatus[];
21
21
  /** Emoji reactions on existing messages. */
22
22
  reactions: NormalizedReaction[];
23
+ /** Account-level webhook errors (WhatsApp `value.errors`). */
24
+ errors: NormalizedWebhookError[];
25
+ }
26
+ /** Account-level error from a Meta webhook payload. */
27
+ export interface NormalizedWebhookError {
28
+ code: number;
29
+ title?: string;
30
+ message?: string;
31
+ error_data?: {
32
+ details?: string;
33
+ };
34
+ href?: string;
35
+ phoneNumberId?: string;
23
36
  }
24
37
  /** A single inbound message extracted from the webhook payload. */
25
38
  export interface NormalizedMessage {
@@ -83,16 +96,37 @@ export interface NormalizedMessage {
83
96
  nfm_reply?: {
84
97
  name?: string;
85
98
  response_json: string;
99
+ body?: string;
86
100
  };
87
101
  };
88
102
  button?: {
89
103
  text: string;
90
104
  payload: string;
91
105
  };
92
- /** Quoted/replied-to message context. */
106
+ /** Order placed from a catalog, single-, or multi-product message. */
107
+ order?: {
108
+ catalog_id: string;
109
+ text?: string;
110
+ product_items: Array<{
111
+ product_retailer_id: string;
112
+ quantity: number;
113
+ item_price: number;
114
+ currency: string;
115
+ }>;
116
+ };
117
+ /** Quoted/replied-to message context (normalized: WhatsApp's raw `context.id` maps to `message_id`). */
93
118
  context?: {
94
119
  message_id: string;
95
120
  from?: string;
121
+ /** Set when the message was forwarded (WhatsApp). */
122
+ forwarded?: boolean;
123
+ /** Set when forwarded many times (WhatsApp). */
124
+ frequently_forwarded?: boolean;
125
+ /** Catalog product the user is asking about (WhatsApp product inquiry). */
126
+ referred_product?: {
127
+ catalog_id: string;
128
+ product_retailer_id: string;
129
+ };
96
130
  };
97
131
  /** Reaction (only set for WhatsApp reaction messages before they're split out). */
98
132
  reaction?: {
@@ -100,7 +134,23 @@ export interface NormalizedMessage {
100
134
  emoji: string;
101
135
  };
102
136
  /** Click-to-WhatsApp ad referral data. */
103
- referral?: unknown;
137
+ referral?: WhatsAppReferral;
138
+ }
139
+ /** Click-to-WhatsApp ad referral object from WhatsApp webhooks. */
140
+ export interface WhatsAppReferral {
141
+ source_url?: string;
142
+ source_id?: string;
143
+ source_type?: string;
144
+ body?: string;
145
+ headline?: string;
146
+ media_type?: string;
147
+ image_url?: string;
148
+ video_url?: string;
149
+ thumbnail_url?: string;
150
+ ctwa_clid?: string;
151
+ welcome_message?: {
152
+ text?: string;
153
+ };
104
154
  }
105
155
  /** A delivery status update. */
106
156
  export interface NormalizedStatus {
@@ -108,12 +158,16 @@ export interface NormalizedStatus {
108
158
  id: string;
109
159
  /** Recipient identifier. */
110
160
  recipientId: string;
111
- /** Status value. */
112
- status: 'sent' | 'delivered' | 'read' | 'failed';
161
+ /** Status value (`played` is WhatsApp voice-note playback). */
162
+ status: 'sent' | 'delivered' | 'read' | 'failed' | 'played' | (string & {});
113
163
  /** Unix timestamp (seconds) as a string. */
114
164
  timestamp: string;
115
165
  /** Phone number ID or page ID that sent the original message. */
116
166
  phoneNumberId: string;
167
+ /** Recipient type (WhatsApp). */
168
+ recipient_type?: string;
169
+ /** Opaque callback data echoed from the send request (WhatsApp). */
170
+ biz_opaque_callback_data?: string;
117
171
  /** Conversation metadata (WhatsApp-specific). */
118
172
  conversation?: {
119
173
  id: string;
@@ -126,13 +180,18 @@ export interface NormalizedStatus {
126
180
  pricing?: {
127
181
  billable: boolean;
128
182
  pricing_model: string;
129
- category: string;
183
+ category: string | (string & {});
184
+ type?: 'regular' | 'free_customer_service' | 'free_entry_point' | (string & {});
130
185
  };
131
186
  /** Error details when `status === "failed"`. */
132
187
  errors?: Array<{
133
188
  code: number;
134
189
  title?: string;
135
190
  message?: string;
191
+ error_data?: {
192
+ details?: string;
193
+ };
194
+ href?: string;
136
195
  }>;
137
196
  }
138
197
  /** An emoji reaction on an existing message. */