@kuralle-agents/messaging-meta 0.8.5 → 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, commerce (single/multi-product, catalog, and address messages, plus inbound order parsing).
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
 
@@ -71,7 +71,7 @@ const instagram = createInstagramClient({
71
71
  Share catalog products and receive orders without leaving WhatsApp:
72
72
 
73
73
  ```typescript
74
- import { parseInboundOrder } from '@kuralle-agents/messaging-meta/whatsapp';
74
+ import { parseInboundOrder, parseProductInquiry } from '@kuralle-agents/messaging-meta/whatsapp';
75
75
 
76
76
  // Single product, multi-product (max 10 sections / 30 products), catalog, address request
77
77
  await whatsapp.sendProduct(to, { catalogId, productRetailerId: 'sku-1', body: { text: 'Check this out' } });
@@ -110,7 +110,7 @@ const events = normalizeWebhook(JSON.parse(rawBody));
110
110
  | Import path | Contents |
111
111
  |---|---|
112
112
  | `@kuralle-agents/messaging-meta` | `GraphAPIClient`, `BaseMetaClient`, errors, `verifySignature`, `normalizeWebhook`, `MessengerClient`, `InstagramClient` |
113
- | `@kuralle-agents/messaging-meta/whatsapp` | `WhatsAppClient`, `createWhatsAppClient`, templates, flows, commerce (`parseInboundOrder`, `parseInboundAddress`), format converter |
113
+ | `@kuralle-agents/messaging-meta/whatsapp` | `WhatsAppClient`, `createWhatsAppClient`, templates, flows, commerce (`parseInboundOrder`, `parseInboundAddress`, `parseProductInquiry`), format converter |
114
114
  | `@kuralle-agents/messaging-meta/messenger` | `MessengerClient`, `createMessengerClient`, format converter |
115
115
  | `@kuralle-agents/messaging-meta/instagram` | `InstagramClient`, `createInstagramClient`, ice breakers, format converter |
116
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 {
@@ -101,10 +114,19 @@ export interface NormalizedMessage {
101
114
  currency: string;
102
115
  }>;
103
116
  };
104
- /** Quoted/replied-to message context. */
117
+ /** Quoted/replied-to message context (normalized: WhatsApp's raw `context.id` maps to `message_id`). */
105
118
  context?: {
106
119
  message_id: string;
107
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
+ };
108
130
  };
109
131
  /** Reaction (only set for WhatsApp reaction messages before they're split out). */
110
132
  reaction?: {
@@ -112,7 +134,23 @@ export interface NormalizedMessage {
112
134
  emoji: string;
113
135
  };
114
136
  /** Click-to-WhatsApp ad referral data. */
115
- 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
+ };
116
154
  }
117
155
  /** A delivery status update. */
118
156
  export interface NormalizedStatus {
@@ -120,12 +158,16 @@ export interface NormalizedStatus {
120
158
  id: string;
121
159
  /** Recipient identifier. */
122
160
  recipientId: string;
123
- /** Status value. */
124
- status: 'sent' | 'delivered' | 'read' | 'failed';
161
+ /** Status value (`played` is WhatsApp voice-note playback). */
162
+ status: 'sent' | 'delivered' | 'read' | 'failed' | 'played' | (string & {});
125
163
  /** Unix timestamp (seconds) as a string. */
126
164
  timestamp: string;
127
165
  /** Phone number ID or page ID that sent the original message. */
128
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;
129
171
  /** Conversation metadata (WhatsApp-specific). */
130
172
  conversation?: {
131
173
  id: string;
@@ -138,13 +180,18 @@ export interface NormalizedStatus {
138
180
  pricing?: {
139
181
  billable: boolean;
140
182
  pricing_model: string;
141
- category: string;
183
+ category: string | (string & {});
184
+ type?: 'regular' | 'free_customer_service' | 'free_entry_point' | (string & {});
142
185
  };
143
186
  /** Error details when `status === "failed"`. */
144
187
  errors?: Array<{
145
188
  code: number;
146
189
  title?: string;
147
190
  message?: string;
191
+ error_data?: {
192
+ details?: string;
193
+ };
194
+ href?: string;
148
195
  }>;
149
196
  }
150
197
  /** An emoji reaction on an existing message. */
@@ -41,6 +41,7 @@ export function normalizeWebhook(payload) {
41
41
  messages: [],
42
42
  statuses: [],
43
43
  reactions: [],
44
+ errors: [],
44
45
  };
45
46
  if (!payload || typeof payload !== 'object') {
46
47
  return result;
@@ -68,6 +69,7 @@ function normalizeWhatsAppWebhook(payload) {
68
69
  messages: [],
69
70
  statuses: [],
70
71
  reactions: [],
72
+ errors: [],
71
73
  };
72
74
  const entries = payload.entry ?? [];
73
75
  for (const entry of entries) {
@@ -130,12 +132,30 @@ function normalizeWhatsAppWebhook(payload) {
130
132
  normalized.button = msg.button;
131
133
  if (msg.order)
132
134
  normalized.order = msg.order;
133
- if (msg.context)
134
- normalized.context = msg.context;
135
+ if (msg.context) {
136
+ // Real WhatsApp webhooks send `context.{id, from}` (plus forwarding
137
+ // flags / referred_product) — normalize raw `id` to `message_id`.
138
+ const referred = msg.context.referred_product;
139
+ normalized.context = {
140
+ message_id: msg.context.id ?? '',
141
+ from: msg.context.from,
142
+ forwarded: msg.context.forwarded,
143
+ frequently_forwarded: msg.context.frequently_forwarded,
144
+ referred_product: referred?.catalog_id && referred.product_retailer_id
145
+ ? {
146
+ catalog_id: referred.catalog_id,
147
+ product_retailer_id: referred.product_retailer_id,
148
+ }
149
+ : undefined,
150
+ };
151
+ }
135
152
  if (msg.referral)
136
153
  normalized.referral = msg.referral;
137
154
  result.messages.push(normalized);
138
155
  }
156
+ for (const err of value.errors ?? []) {
157
+ result.errors.push(normalizeWebhookError(err, phoneNumberId));
158
+ }
139
159
  // --- Statuses ---
140
160
  for (const status of value.statuses ?? []) {
141
161
  const normalizedStatus = {
@@ -145,6 +165,11 @@ function normalizeWhatsAppWebhook(payload) {
145
165
  timestamp: status.timestamp ?? '',
146
166
  phoneNumberId,
147
167
  };
168
+ if (status.recipient_type)
169
+ normalizedStatus.recipient_type = status.recipient_type;
170
+ if (status.biz_opaque_callback_data) {
171
+ normalizedStatus.biz_opaque_callback_data = status.biz_opaque_callback_data;
172
+ }
148
173
  if (status.conversation) {
149
174
  normalizedStatus.conversation = status.conversation;
150
175
  }
@@ -174,6 +199,7 @@ function normalizePageWebhook(payload) {
174
199
  messages: [],
175
200
  statuses: [],
176
201
  reactions: [],
202
+ errors: [],
177
203
  };
178
204
  const entries = payload.entry ?? [];
179
205
  for (const entry of entries) {
@@ -216,6 +242,13 @@ function normalizePageWebhook(payload) {
216
242
  if (msg.reply_to?.mid) {
217
243
  normalized.context = { message_id: msg.reply_to.mid };
218
244
  }
245
+ if (msg.quick_reply?.payload) {
246
+ normalized.type = 'postback';
247
+ normalized.button = {
248
+ text: msg.text ?? '',
249
+ payload: msg.quick_reply.payload,
250
+ };
251
+ }
219
252
  result.messages.push(normalized);
220
253
  continue;
221
254
  }
@@ -288,15 +321,30 @@ function resolvePageMessageType(msg) {
288
321
  }
289
322
  return 'unknown';
290
323
  }
291
- /** Normalise a status string to the union type, defaulting to `"sent"`. */
324
+ /** Normalise a status string; unknown values pass through unchanged. */
292
325
  function normalizeStatusValue(raw) {
326
+ if (!raw)
327
+ return 'sent';
293
328
  switch (raw) {
294
329
  case 'sent':
295
330
  case 'delivered':
296
331
  case 'read':
297
332
  case 'failed':
333
+ case 'played':
298
334
  return raw;
299
335
  default:
300
- return 'sent';
336
+ return raw;
301
337
  }
302
338
  }
339
+ function normalizeWebhookError(err, phoneNumberId) {
340
+ return {
341
+ code: typeof err.code === 'number' ? err.code : 0,
342
+ title: typeof err.title === 'string' ? err.title : undefined,
343
+ message: typeof err.message === 'string' ? err.message : undefined,
344
+ error_data: err.error_data && typeof err.error_data === 'object'
345
+ ? err.error_data
346
+ : undefined,
347
+ href: typeof err.href === 'string' ? err.href : undefined,
348
+ phoneNumberId,
349
+ };
350
+ }
@@ -25,7 +25,7 @@ import { type FormatConverter, type InboundMessage, type InteractiveMessage, typ
25
25
  import { BaseMetaClient } from '../base-client.js';
26
26
  import type { BaseMetaClientConfig } from '../base-client.js';
27
27
  import type { NormalizedMessage, NormalizedReaction, NormalizedStatus } from '../webhook/normalizer.js';
28
- import type { WhatsAppClientConfig, TemplateMessage, TemplateInfo, TemplateDefinition, TextOrTemplateOptions, ListMessage, ButtonMessage, CTAButtonMessage, FlowInteractiveInput, ProductMessage, ProductListMessage, CatalogMessage, AddressMessage, LocationPayload, ContactPayload, BusinessProfile, FlowDefinition, FlowInfo, FlowAssets } from './types.js';
28
+ import type { WhatsAppClientConfig, TemplateMessage, TemplateInfo, TemplateDefinition, TemplateCreateResponse, TextOrTemplateOptions, ListMessage, ButtonMessage, CTAButtonMessage, FlowInteractiveInput, ProductMessage, ProductListMessage, CatalogMessage, AddressMessage, LocationPayload, ContactPayload, BusinessProfile, FlowDefinition, FlowInfo, FlowAssets } from './types.js';
29
29
  type WhatsAppInbound = NormalizedMessage;
30
30
  type WhatsAppOutbound = Record<string, unknown>;
31
31
  /** Internal config — base-client common fields threaded through the public config. */
@@ -61,8 +61,15 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
61
61
  sendMedia(to: string, media: MediaPayload): Promise<SendResult>;
62
62
  sendInteractive(to: string, msg: InteractiveMessage): Promise<SendResult>;
63
63
  sendRaw(to: string, payload: WhatsAppOutbound): Promise<SendResult>;
64
- markAsRead(messageId: string): Promise<void>;
65
- /** WhatsApp Cloud API does not support typing indicators — no-op. */
64
+ markAsRead(messageId: string, opts?: {
65
+ typing?: boolean;
66
+ }): Promise<void>;
67
+ /** Show a typing indicator for 25s (requires a recent inbound message id). */
68
+ sendTypingIndicatorFor(messageId: string): Promise<void>;
69
+ /**
70
+ * WhatsApp typing indicators are keyed by message ID, not recipient.
71
+ * Use {@link sendTypingIndicatorFor} with the inbound message id instead.
72
+ */
66
73
  sendTypingIndicator(_to: string): Promise<void>;
67
74
  sendTemplate(to: string, template: TemplateMessage): Promise<SendResult>;
68
75
  /**
@@ -116,8 +123,11 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
116
123
  sendContacts(to: string, contacts: ContactPayload[]): Promise<SendResult>;
117
124
  readonly templates: {
118
125
  list: (wabaId: string) => Promise<TemplateInfo[]>;
119
- create: (wabaId: string, template: TemplateDefinition) => Promise<TemplateInfo>;
120
- delete: (wabaId: string, name: string) => Promise<void>;
126
+ create: (wabaId: string, template: TemplateDefinition) => Promise<TemplateCreateResponse>;
127
+ delete: (wabaId: string, opts: {
128
+ name?: string;
129
+ hsm_id?: string;
130
+ }) => Promise<void>;
121
131
  };
122
132
  readonly phoneNumbers: {
123
133
  requestCode: (phoneNumberId: string, method: "SMS" | "VOICE", language: string) => Promise<void>;
@@ -130,6 +140,7 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
130
140
  create: (wabaId: string, flow: FlowDefinition) => Promise<FlowInfo>;
131
141
  update: (flowId: string, flow: Partial<FlowDefinition>) => Promise<FlowInfo>;
132
142
  publish: (flowId: string) => Promise<void>;
143
+ deprecate: (flowId: string) => Promise<void>;
133
144
  delete: (flowId: string) => Promise<void>;
134
145
  getAssets: (flowId: string) => Promise<FlowAssets>;
135
146
  };
@@ -137,7 +137,7 @@ export class WhatsAppClient extends BaseMetaClient {
137
137
  footer: msg.footer ? { text: msg.footer } : undefined,
138
138
  flowId: msg.action.flowId,
139
139
  flowCta: 'Continue',
140
- flowToken: msg.action.flowToken ?? '',
140
+ flowToken: msg.action.flowToken,
141
141
  flowAction: 'navigate',
142
142
  flowActionPayload: msg.action.parameters,
143
143
  });
@@ -153,14 +153,25 @@ export class WhatsAppClient extends BaseMetaClient {
153
153
  });
154
154
  return this.toSendResult(to, response);
155
155
  }
156
- async markAsRead(messageId) {
157
- await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
156
+ async markAsRead(messageId, opts) {
157
+ const body = {
158
158
  messaging_product: 'whatsapp',
159
159
  status: 'read',
160
160
  message_id: messageId,
161
- });
161
+ };
162
+ if (opts?.typing) {
163
+ body.typing_indicator = { type: 'text' };
164
+ }
165
+ await this.graphApi.post(`${this.config.phoneNumberId}/messages`, body);
162
166
  }
163
- /** WhatsApp Cloud API does not support typing indicators no-op. */
167
+ /** Show a typing indicator for 25s (requires a recent inbound message id). */
168
+ async sendTypingIndicatorFor(messageId) {
169
+ await this.markAsRead(messageId, { typing: true });
170
+ }
171
+ /**
172
+ * WhatsApp typing indicators are keyed by message ID, not recipient.
173
+ * Use {@link sendTypingIndicatorFor} with the inbound message id instead.
174
+ */
164
175
  async sendTypingIndicator(_to) {
165
176
  // intentionally empty
166
177
  }
@@ -199,6 +210,10 @@ export class WhatsAppClient extends BaseMetaClient {
199
210
  // WhatsApp-specific — Interactive messages
200
211
  // =========================================================================
201
212
  async sendListMessage(to, list) {
213
+ const totalRows = list.sections.reduce((sum, section) => sum + section.rows.length, 0);
214
+ if (totalRows > 10) {
215
+ throw new MessagingError(`List message exceeds maximum of 10 rows across all sections (has ${totalRows})`, 'INVALID_LIST_MESSAGE', 'whatsapp');
216
+ }
202
217
  const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
203
218
  messaging_product: 'whatsapp',
204
219
  recipient_type: 'individual',
@@ -258,6 +273,16 @@ export class WhatsAppClient extends BaseMetaClient {
258
273
  return this.toSendResult(to, response);
259
274
  }
260
275
  async sendInteractiveFlow(to, flow) {
276
+ const parameters = {
277
+ flow_message_version: '3',
278
+ flow_id: flow.flowId,
279
+ flow_cta: flow.flowCta,
280
+ flow_action: flow.flowAction,
281
+ flow_action_payload: flow.flowActionPayload,
282
+ };
283
+ if (flow.flowToken) {
284
+ parameters.flow_token = flow.flowToken;
285
+ }
261
286
  const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
262
287
  messaging_product: 'whatsapp',
263
288
  recipient_type: 'individual',
@@ -269,14 +294,7 @@ export class WhatsAppClient extends BaseMetaClient {
269
294
  footer: flow.footer,
270
295
  action: {
271
296
  name: 'flow',
272
- parameters: {
273
- flow_message_version: '3',
274
- flow_id: flow.flowId,
275
- flow_cta: flow.flowCta,
276
- flow_token: flow.flowToken,
277
- flow_action: flow.flowAction,
278
- flow_action_payload: flow.flowActionPayload,
279
- },
297
+ parameters,
280
298
  },
281
299
  },
282
300
  });
@@ -438,17 +456,31 @@ export class WhatsAppClient extends BaseMetaClient {
438
456
  // =========================================================================
439
457
  templates = {
440
458
  list: async (wabaId) => {
441
- const result = await this.graphApi.get(`${wabaId}/message_templates`);
442
- return result.data.map(mapListTemplateRow);
459
+ const all = [];
460
+ let endpoint = `${wabaId}/message_templates`;
461
+ let extraParams;
462
+ for (let page = 0; page < 50; page++) {
463
+ const result = await this.graphApi.get(endpoint, extraParams);
464
+ all.push(...result.data.map(mapListTemplateRow));
465
+ const next = result.paging?.next;
466
+ if (!next)
467
+ break;
468
+ const parsed = parseGraphPagingNext(next);
469
+ endpoint = parsed.endpoint;
470
+ extraParams = parsed.params;
471
+ }
472
+ return all;
443
473
  },
444
474
  create: async (wabaId, template) => {
445
475
  return this.graphApi.post(`${wabaId}/message_templates`, template);
446
476
  },
447
- delete: async (wabaId, name) => {
448
- await this.graphApi.post(`${wabaId}/message_templates`, {
449
- name,
450
- _method: 'DELETE',
451
- });
477
+ delete: async (wabaId, opts) => {
478
+ const params = {};
479
+ if (opts.name)
480
+ params.name = opts.name;
481
+ if (opts.hsm_id)
482
+ params.hsm_id = opts.hsm_id;
483
+ await this.graphApi.delete(`${wabaId}/message_templates`, { params });
452
484
  },
453
485
  };
454
486
  // =========================================================================
@@ -494,8 +526,11 @@ export class WhatsAppClient extends BaseMetaClient {
494
526
  publish: async (flowId) => {
495
527
  await this.graphApi.post(`${flowId}/publish`, {});
496
528
  },
529
+ deprecate: async (flowId) => {
530
+ await this.graphApi.post(`${flowId}/deprecate`, {});
531
+ },
497
532
  delete: async (flowId) => {
498
- await this.graphApi.post(flowId, { status: 'DEPRECATED' });
533
+ await this.graphApi.delete(flowId);
499
534
  },
500
535
  getAssets: async (flowId) => {
501
536
  return this.graphApi.get(`${flowId}/assets`);
@@ -562,7 +597,10 @@ export class WhatsAppClient extends BaseMetaClient {
562
597
  }
563
598
  : undefined,
564
599
  context: msg.context
565
- ? { messageId: msg.context.message_id, from: msg.context.from }
600
+ ? {
601
+ messageId: msg.context.message_id,
602
+ from: msg.context.from,
603
+ }
566
604
  : undefined,
567
605
  raw: msg,
568
606
  };
@@ -594,6 +632,7 @@ export class WhatsAppClient extends BaseMetaClient {
594
632
  ? {
595
633
  model: status.pricing.pricing_model,
596
634
  category: status.pricing.category,
635
+ ...(status.pricing.type ? { type: status.pricing.type } : {}),
597
636
  }
598
637
  : undefined,
599
638
  raw: status,
@@ -717,6 +756,17 @@ function mapListTemplateRow(raw) {
717
756
  paused,
718
757
  };
719
758
  }
759
+ function parseGraphPagingNext(nextUrl) {
760
+ const url = new URL(nextUrl);
761
+ const parts = url.pathname.split('/').filter(Boolean);
762
+ const endpoint = parts.slice(1).join('/');
763
+ const params = {};
764
+ for (const [key, value] of url.searchParams.entries()) {
765
+ if (key !== 'access_token')
766
+ params[key] = value;
767
+ }
768
+ return { endpoint, params: Object.keys(params).length > 0 ? params : undefined };
769
+ }
720
770
  // ---------------------------------------------------------------------------
721
771
  // Utility
722
772
  // ---------------------------------------------------------------------------
@@ -12,8 +12,8 @@
12
12
  * @see https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/order
13
13
  */
14
14
  import type { InboundMessage } from '@kuralle-agents/messaging';
15
- import type { WhatsAppInboundOrder, WhatsAppInboundAddress } from './types.js';
16
- export type { ProductMessage, ProductListMessage, ProductSection, CatalogMessage, AddressMessage, WhatsAppAddressValues, WhatsAppSavedAddress, WhatsAppOrderItem, WhatsAppInboundOrder, WhatsAppInboundAddress, } from './types.js';
15
+ import type { WhatsAppInboundOrder, WhatsAppInboundAddress, WhatsAppInboundProductInquiry } from './types.js';
16
+ export type { ProductMessage, ProductListMessage, ProductSection, CatalogMessage, AddressMessage, WhatsAppAddressValues, WhatsAppSavedAddress, WhatsAppOrderItem, WhatsAppInboundOrder, WhatsAppInboundAddress, WhatsAppInboundProductInquiry, } from './types.js';
17
17
  /** Maximum number of sections in a multi-product message. */
18
18
  export declare const MAX_PRODUCT_LIST_SECTIONS = 10;
19
19
  /** Maximum number of products across all sections of a multi-product message. */
@@ -56,3 +56,8 @@ export declare function parseInboundOrder(message: InboundMessage): WhatsAppInbo
56
56
  * address submission (or the payload is malformed).
57
57
  */
58
58
  export declare function parseInboundAddress(message: InboundMessage): WhatsAppInboundAddress | undefined;
59
+ /**
60
+ * Extract a product inquiry from an inbound message when the user replied
61
+ * in the context of a catalog product message (`context.referred_product`).
62
+ */
63
+ export declare function parseProductInquiry(message: InboundMessage): WhatsAppInboundProductInquiry | undefined;
@@ -86,3 +86,20 @@ export function parseInboundAddress(message) {
86
86
  return undefined;
87
87
  }
88
88
  }
89
+ /**
90
+ * Extract a product inquiry from an inbound message when the user replied
91
+ * in the context of a catalog product message (`context.referred_product`).
92
+ */
93
+ export function parseProductInquiry(message) {
94
+ const raw = message.raw;
95
+ const referred = raw?.context?.referred_product;
96
+ if (!referred?.catalog_id || !referred.product_retailer_id) {
97
+ return undefined;
98
+ }
99
+ return {
100
+ catalog_id: referred.catalog_id,
101
+ product_retailer_id: referred.product_retailer_id,
102
+ context_message_id: raw?.context?.message_id,
103
+ context_from: raw?.context?.from,
104
+ };
105
+ }
@@ -30,10 +30,10 @@
30
30
  * @packageDocumentation
31
31
  */
32
32
  export { WhatsAppClient, createWhatsAppClient } from './client.js';
33
- export type { WhatsAppClientConfig, WhatsAppThreadId, WhatsAppSendResponse, TemplateMessage, TemplateLanguage, TemplateComponent, TemplateParameter, MediaObject, ListMessage, ListSection, ListRow, ButtonMessage, ReplyButton, CTAButtonMessage, FlowInteractiveInput, ProductMessage, ProductListMessage, ProductSection, CatalogMessage, AddressMessage, WhatsAppAddressValues, WhatsAppSavedAddress, WhatsAppOrderItem, WhatsAppInboundOrder, WhatsAppInboundAddress, LocationPayload, ContactPayload, BusinessProfile, TemplateDefinition, TemplateDefinitionComponent, TemplateInfo, FlowDefinition, FlowInfo, FlowAssets, WhatsAppMediaResponse, TextOrTemplateOptions, } from './types.js';
33
+ export type { WhatsAppClientConfig, WhatsAppThreadId, WhatsAppSendResponse, TemplateMessage, TemplateLanguage, TemplateComponent, TemplateParameter, MediaObject, ListMessage, ListSection, ListRow, ButtonMessage, ReplyButton, CTAButtonMessage, FlowInteractiveInput, ProductMessage, ProductListMessage, ProductSection, CatalogMessage, AddressMessage, WhatsAppAddressValues, WhatsAppSavedAddress, WhatsAppOrderItem, WhatsAppInboundOrder, WhatsAppInboundAddress, WhatsAppInboundProductInquiry, LocationPayload, ContactPayload, BusinessProfile, TemplateDefinition, TemplateDefinitionComponent, TemplateCreateResponse, TemplateInfo, FlowDefinition, FlowCategory, FlowInfo, FlowAssets, WhatsAppMediaResponse, TextOrTemplateOptions, } from './types.js';
34
34
  export { buildTemplateSendPayload, buildTemplatePayload, mapOutboundTemplateComponents, } from './templates.js';
35
35
  export type { TypedTemplateConfig, RawTemplateComponents, } from './templates.js';
36
36
  export { WhatsAppFormatConverter } from './format.js';
37
37
  export { splitMessage } from './split.js';
38
38
  export { generateFlowToken, buildFlowInput } from './flows.js';
39
- export { parseInboundOrder, parseInboundAddress, MAX_PRODUCT_LIST_SECTIONS, MAX_PRODUCT_LIST_PRODUCTS, } from './commerce.js';
39
+ export { parseInboundOrder, parseInboundAddress, parseProductInquiry, MAX_PRODUCT_LIST_SECTIONS, MAX_PRODUCT_LIST_PRODUCTS, } from './commerce.js';
@@ -40,4 +40,4 @@ export { splitMessage } from './split.js';
40
40
  // ─── Flows ──────────────────────────────────────────────────────────────
41
41
  export { generateFlowToken, buildFlowInput } from './flows.js';
42
42
  // ─── Commerce ───────────────────────────────────────────────────────────
43
- export { parseInboundOrder, parseInboundAddress, MAX_PRODUCT_LIST_SECTIONS, MAX_PRODUCT_LIST_PRODUCTS, } from './commerce.js';
43
+ export { parseInboundOrder, parseInboundAddress, parseProductInquiry, MAX_PRODUCT_LIST_SECTIONS, MAX_PRODUCT_LIST_PRODUCTS, } from './commerce.js';
@@ -27,7 +27,7 @@ export interface WhatsAppClientConfig {
27
27
  phoneNumberId: string;
28
28
  /** Custom verify token for webhook subscription validation. */
29
29
  verifyToken: string;
30
- /** Graph API version (e.g. `"v21.0"`). Default `"v21.0"`. */
30
+ /** Graph API version (e.g. `"v24.0"`). Default `"v24.0"`. */
31
31
  apiVersion?: string;
32
32
  /** Base URL for the Graph API. Default `"https://graph.facebook.com"`. */
33
33
  baseUrl?: string;
@@ -127,6 +127,8 @@ export interface TemplateParameter {
127
127
  payload?: string;
128
128
  /** Action payload (when `type` is `"action"`). */
129
129
  action?: Record<string, unknown>;
130
+ /** Parameter name for named-parameter templates. */
131
+ parameter_name?: string;
130
132
  }
131
133
  /**
132
134
  * A media object used within WhatsApp messages.
@@ -149,7 +151,7 @@ export interface MediaObject {
149
151
  /**
150
152
  * A list-style interactive message with expandable sections.
151
153
  *
152
- * Supports up to 10 sections with up to 10 rows each. The `button` text
154
+ * Supports up to 10 rows total across all sections. The `button` text
153
155
  * appears on the collapsed list control.
154
156
  */
155
157
  export interface ListMessage {
@@ -272,8 +274,8 @@ export interface FlowInteractiveInput {
272
274
  flowId: string;
273
275
  /** Call-to-action button text that opens the flow. */
274
276
  flowCta: string;
275
- /** Unique token for this flow session. */
276
- flowToken: string;
277
+ /** Unique token for this flow session (omit when not required). */
278
+ flowToken?: string;
277
279
  /** Flow action type. */
278
280
  flowAction: 'navigate' | 'data_exchange';
279
281
  /** Optional initial data for the flow. */
@@ -446,6 +448,13 @@ export interface WhatsAppInboundAddress {
446
448
  /** Address fields entered or confirmed by the user. */
447
449
  values: WhatsAppAddressValues;
448
450
  }
451
+ /** A product inquiry from an inbound message context.referred_product. */
452
+ export interface WhatsAppInboundProductInquiry {
453
+ catalog_id: string;
454
+ product_retailer_id: string;
455
+ context_message_id?: string;
456
+ context_from?: string;
457
+ }
449
458
  /** A location payload for sending a map pin. */
450
459
  export interface LocationPayload {
451
460
  /** Latitude in decimal degrees. */
@@ -510,6 +519,14 @@ export interface TemplateDefinition {
510
519
  components: TemplateDefinitionComponent[];
511
520
  /** Whether Meta can auto-recategorize the template. */
512
521
  allow_category_change?: boolean;
522
+ /** Named vs positional parameter placeholders. */
523
+ parameter_format?: 'named' | 'positional';
524
+ }
525
+ /** Response from creating a message template. */
526
+ export interface TemplateCreateResponse {
527
+ id: string;
528
+ status: string;
529
+ category: string;
513
530
  }
514
531
  /** A component within a template definition. */
515
532
  export interface TemplateDefinitionComponent {
@@ -554,11 +571,19 @@ export interface TemplateInfo {
554
571
  export interface FlowDefinition {
555
572
  /** Flow name. */
556
573
  name: string;
557
- /** Flow categories. */
558
- categories?: string[];
574
+ /** Flow categories (at least one required by the API). */
575
+ categories: FlowCategory[];
559
576
  /** Clone from an existing flow. */
560
577
  clone_flow_id?: string;
561
- }
578
+ /** Initial flow JSON definition. */
579
+ flow_json?: string;
580
+ /** Publish immediately after creation. */
581
+ publish?: boolean;
582
+ /** Endpoint URI for data_exchange flows. */
583
+ endpoint_uri?: string;
584
+ }
585
+ /** Valid WhatsApp Flow categories. */
586
+ export type FlowCategory = 'SIGN_UP' | 'SIGN_IN' | 'APPOINTMENT_BOOKING' | 'LEAD_GENERATION' | 'CONTACT_US' | 'CUSTOMER_SUPPORT' | 'SURVEY' | 'OTHER';
562
587
  /** Information about an existing WhatsApp Flow. */
563
588
  export interface FlowInfo {
564
589
  /** Flow ID. */
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-messaging-meta"
8
8
  },
9
- "version": "0.8.5",
9
+ "version": "0.9.0",
10
10
  "description": "Meta platform clients (WhatsApp, Messenger, Instagram) for Kuralle messaging",
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
@@ -41,8 +41,8 @@
41
41
  "README.md"
42
42
  ],
43
43
  "dependencies": {
44
- "@kuralle-agents/http-client": "0.8.5",
45
- "@kuralle-agents/messaging": "0.8.5"
44
+ "@kuralle-agents/http-client": "0.9.0",
45
+ "@kuralle-agents/messaging": "0.9.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "hono": "^4.12.0"
@@ -58,9 +58,9 @@
58
58
  "redis": "^4.7.0",
59
59
  "typescript": "^5.8.2",
60
60
  "zod": "^4.0.0",
61
- "@kuralle-agents/core": "0.8.5",
62
- "@kuralle-agents/hono-server": "0.8.5",
63
- "@kuralle-agents/engagement": "0.8.5"
61
+ "@kuralle-agents/engagement": "0.9.0",
62
+ "@kuralle-agents/core": "0.9.0",
63
+ "@kuralle-agents/hono-server": "0.9.0"
64
64
  },
65
65
  "scripts": {
66
66
  "prebuild": "rm -rf dist",