@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 +34 -5
- package/dist/graph-api/client.d.ts +6 -1
- package/dist/graph-api/client.js +8 -1
- package/dist/graph-api/errors.js +6 -2
- package/dist/instagram/client.d.ts +16 -16
- package/dist/instagram/client.js +50 -67
- package/dist/instagram/types.d.ts +9 -11
- package/dist/instagram/types.js +2 -2
- package/dist/messenger/client.d.ts +1 -1
- package/dist/messenger/client.js +7 -6
- package/dist/webhook/index.d.ts +1 -1
- package/dist/webhook/normalizer.d.ts +64 -5
- package/dist/webhook/normalizer.js +54 -4
- package/dist/whatsapp/client.d.ts +56 -9
- package/dist/whatsapp/client.js +209 -24
- package/dist/whatsapp/commerce.d.ts +63 -0
- package/dist/whatsapp/commerce.js +105 -0
- package/dist/whatsapp/index.d.ts +2 -1
- package/dist/whatsapp/index.js +2 -0
- package/dist/whatsapp/types.d.ts +198 -6
- package/package.json +6 -6
|
@@ -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) {
|
|
@@ -128,12 +130,32 @@ function normalizeWhatsAppWebhook(payload) {
|
|
|
128
130
|
normalized.interactive = msg.interactive;
|
|
129
131
|
if (msg.button)
|
|
130
132
|
normalized.button = msg.button;
|
|
131
|
-
if (msg.
|
|
132
|
-
normalized.
|
|
133
|
+
if (msg.order)
|
|
134
|
+
normalized.order = msg.order;
|
|
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
|
+
}
|
|
133
152
|
if (msg.referral)
|
|
134
153
|
normalized.referral = msg.referral;
|
|
135
154
|
result.messages.push(normalized);
|
|
136
155
|
}
|
|
156
|
+
for (const err of value.errors ?? []) {
|
|
157
|
+
result.errors.push(normalizeWebhookError(err, phoneNumberId));
|
|
158
|
+
}
|
|
137
159
|
// --- Statuses ---
|
|
138
160
|
for (const status of value.statuses ?? []) {
|
|
139
161
|
const normalizedStatus = {
|
|
@@ -143,6 +165,11 @@ function normalizeWhatsAppWebhook(payload) {
|
|
|
143
165
|
timestamp: status.timestamp ?? '',
|
|
144
166
|
phoneNumberId,
|
|
145
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
|
+
}
|
|
146
173
|
if (status.conversation) {
|
|
147
174
|
normalizedStatus.conversation = status.conversation;
|
|
148
175
|
}
|
|
@@ -172,6 +199,7 @@ function normalizePageWebhook(payload) {
|
|
|
172
199
|
messages: [],
|
|
173
200
|
statuses: [],
|
|
174
201
|
reactions: [],
|
|
202
|
+
errors: [],
|
|
175
203
|
};
|
|
176
204
|
const entries = payload.entry ?? [];
|
|
177
205
|
for (const entry of entries) {
|
|
@@ -214,6 +242,13 @@ function normalizePageWebhook(payload) {
|
|
|
214
242
|
if (msg.reply_to?.mid) {
|
|
215
243
|
normalized.context = { message_id: msg.reply_to.mid };
|
|
216
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
|
+
}
|
|
217
252
|
result.messages.push(normalized);
|
|
218
253
|
continue;
|
|
219
254
|
}
|
|
@@ -286,15 +321,30 @@ function resolvePageMessageType(msg) {
|
|
|
286
321
|
}
|
|
287
322
|
return 'unknown';
|
|
288
323
|
}
|
|
289
|
-
/** Normalise a status string
|
|
324
|
+
/** Normalise a status string; unknown values pass through unchanged. */
|
|
290
325
|
function normalizeStatusValue(raw) {
|
|
326
|
+
if (!raw)
|
|
327
|
+
return 'sent';
|
|
291
328
|
switch (raw) {
|
|
292
329
|
case 'sent':
|
|
293
330
|
case 'delivered':
|
|
294
331
|
case 'read':
|
|
295
332
|
case 'failed':
|
|
333
|
+
case 'played':
|
|
296
334
|
return raw;
|
|
297
335
|
default:
|
|
298
|
-
return
|
|
336
|
+
return raw;
|
|
299
337
|
}
|
|
300
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
|
+
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Extends {@link BaseMetaClient} for shared webhook verification, payload
|
|
7
7
|
* normalization, handler dispatch, and `/webhook` Hono sub-app. Templates,
|
|
8
|
-
* Flows, phone-number management, CTA buttons,
|
|
9
|
-
*
|
|
8
|
+
* Flows, phone-number management, CTA buttons, commerce (product, catalog,
|
|
9
|
+
* and address messages), reactions, location, and contact messages remain
|
|
10
|
+
* in this file as WhatsApp-specific surface.
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* ```ts
|
|
@@ -24,7 +25,7 @@ import { type FormatConverter, type InboundMessage, type InteractiveMessage, typ
|
|
|
24
25
|
import { BaseMetaClient } from '../base-client.js';
|
|
25
26
|
import type { BaseMetaClientConfig } from '../base-client.js';
|
|
26
27
|
import type { NormalizedMessage, NormalizedReaction, NormalizedStatus } from '../webhook/normalizer.js';
|
|
27
|
-
import type { WhatsAppClientConfig, TemplateMessage, TemplateInfo, TemplateDefinition, TextOrTemplateOptions, ListMessage, ButtonMessage, CTAButtonMessage, FlowInteractiveInput, 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';
|
|
28
29
|
type WhatsAppInbound = NormalizedMessage;
|
|
29
30
|
type WhatsAppOutbound = Record<string, unknown>;
|
|
30
31
|
/** Internal config — base-client common fields threaded through the public config. */
|
|
@@ -34,8 +35,8 @@ export declare function createWhatsAppClient(config: WhatsAppClientConfig): What
|
|
|
34
35
|
* Full-featured WhatsApp Cloud API client.
|
|
35
36
|
*
|
|
36
37
|
* Implements the `PlatformClient` interface via {@link BaseMetaClient}.
|
|
37
|
-
* Layers WhatsApp-specific capabilities (templates, Flows,
|
|
38
|
-
* buttons, locations, contacts) on top.
|
|
38
|
+
* Layers WhatsApp-specific capabilities (templates, Flows, commerce,
|
|
39
|
+
* reactions, CTA buttons, locations, contacts) on top.
|
|
39
40
|
*/
|
|
40
41
|
export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, WhatsAppOutbound, InternalWhatsAppConfig> {
|
|
41
42
|
readonly platform: "whatsapp";
|
|
@@ -60,8 +61,15 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
|
|
|
60
61
|
sendMedia(to: string, media: MediaPayload): Promise<SendResult>;
|
|
61
62
|
sendInteractive(to: string, msg: InteractiveMessage): Promise<SendResult>;
|
|
62
63
|
sendRaw(to: string, payload: WhatsAppOutbound): Promise<SendResult>;
|
|
63
|
-
markAsRead(messageId: string
|
|
64
|
-
|
|
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
|
+
*/
|
|
65
73
|
sendTypingIndicator(_to: string): Promise<void>;
|
|
66
74
|
sendTemplate(to: string, template: TemplateMessage): Promise<SendResult>;
|
|
67
75
|
/**
|
|
@@ -76,13 +84,50 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
|
|
|
76
84
|
sendInteractiveButtons(to: string, msg: ButtonMessage): Promise<SendResult>;
|
|
77
85
|
sendCTAButton(to: string, cta: CTAButtonMessage): Promise<SendResult>;
|
|
78
86
|
sendInteractiveFlow(to: string, flow: FlowInteractiveInput): Promise<SendResult>;
|
|
87
|
+
/**
|
|
88
|
+
* Send a single-product message (`interactive.type: "product"`).
|
|
89
|
+
*
|
|
90
|
+
* Displays one catalog product with a **View** button; the user can open
|
|
91
|
+
* the Product Detail Page, add to cart, and send an order. Orders arrive
|
|
92
|
+
* as inbound `type: "order"` messages — see {@link parseInboundOrder}.
|
|
93
|
+
*/
|
|
94
|
+
sendProduct(to: string, product: ProductMessage): Promise<SendResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Send a multi-product message (`interactive.type: "product_list"`).
|
|
97
|
+
*
|
|
98
|
+
* Displays up to {@link MAX_PRODUCT_LIST_PRODUCTS} catalog products across
|
|
99
|
+
* up to {@link MAX_PRODUCT_LIST_SECTIONS} sections. Header and body are
|
|
100
|
+
* required by the Cloud API.
|
|
101
|
+
*
|
|
102
|
+
* @throws {MessagingError} `INVALID_PRODUCT_LIST` when the section or
|
|
103
|
+
* product limits are violated, or a section has no products.
|
|
104
|
+
*/
|
|
105
|
+
sendProductList(to: string, list: ProductListMessage): Promise<SendResult>;
|
|
106
|
+
/**
|
|
107
|
+
* Send a catalog message (`interactive.type: "catalog_message"`).
|
|
108
|
+
*
|
|
109
|
+
* Displays a product thumbnail header, custom body text, and a
|
|
110
|
+
* **View catalog** button that opens the full catalog within WhatsApp.
|
|
111
|
+
*/
|
|
112
|
+
sendCatalog(to: string, catalog: CatalogMessage): Promise<SendResult>;
|
|
113
|
+
/**
|
|
114
|
+
* Send an address request message (`interactive.type: "address_message"`).
|
|
115
|
+
*
|
|
116
|
+
* Country-gated by Meta — currently India-based businesses and their
|
|
117
|
+
* India customers only. The user's submission arrives as an inbound
|
|
118
|
+
* `nfm_reply` interactive message — see {@link parseInboundAddress}.
|
|
119
|
+
*/
|
|
120
|
+
sendAddressRequest(to: string, address: AddressMessage): Promise<SendResult>;
|
|
79
121
|
sendReaction(to: string, messageId: string, emoji: string): Promise<SendResult>;
|
|
80
122
|
sendLocation(to: string, location: LocationPayload): Promise<SendResult>;
|
|
81
123
|
sendContacts(to: string, contacts: ContactPayload[]): Promise<SendResult>;
|
|
82
124
|
readonly templates: {
|
|
83
125
|
list: (wabaId: string) => Promise<TemplateInfo[]>;
|
|
84
|
-
create: (wabaId: string, template: TemplateDefinition) => Promise<
|
|
85
|
-
delete: (wabaId: string,
|
|
126
|
+
create: (wabaId: string, template: TemplateDefinition) => Promise<TemplateCreateResponse>;
|
|
127
|
+
delete: (wabaId: string, opts: {
|
|
128
|
+
name?: string;
|
|
129
|
+
hsm_id?: string;
|
|
130
|
+
}) => Promise<void>;
|
|
86
131
|
};
|
|
87
132
|
readonly phoneNumbers: {
|
|
88
133
|
requestCode: (phoneNumberId: string, method: "SMS" | "VOICE", language: string) => Promise<void>;
|
|
@@ -95,6 +140,7 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
|
|
|
95
140
|
create: (wabaId: string, flow: FlowDefinition) => Promise<FlowInfo>;
|
|
96
141
|
update: (flowId: string, flow: Partial<FlowDefinition>) => Promise<FlowInfo>;
|
|
97
142
|
publish: (flowId: string) => Promise<void>;
|
|
143
|
+
deprecate: (flowId: string) => Promise<void>;
|
|
98
144
|
delete: (flowId: string) => Promise<void>;
|
|
99
145
|
getAssets: (flowId: string) => Promise<FlowAssets>;
|
|
100
146
|
};
|
|
@@ -103,6 +149,7 @@ export declare class WhatsAppClient extends BaseMetaClient<WhatsAppInbound, What
|
|
|
103
149
|
protected toInboundMessage(msg: NormalizedMessage): InboundMessage;
|
|
104
150
|
protected toStatusUpdate(status: NormalizedStatus): StatusUpdate;
|
|
105
151
|
protected toReactionData(reaction: NormalizedReaction): ReactionData;
|
|
152
|
+
private validateProductList;
|
|
106
153
|
private toSendResult;
|
|
107
154
|
private mapMessageType;
|
|
108
155
|
private extractTextFallback;
|
package/dist/whatsapp/client.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Extends {@link BaseMetaClient} for shared webhook verification, payload
|
|
7
7
|
* normalization, handler dispatch, and `/webhook` Hono sub-app. Templates,
|
|
8
|
-
* Flows, phone-number management, CTA buttons,
|
|
9
|
-
*
|
|
8
|
+
* Flows, phone-number management, CTA buttons, commerce (product, catalog,
|
|
9
|
+
* and address messages), reactions, location, and contact messages remain
|
|
10
|
+
* in this file as WhatsApp-specific surface.
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* ```ts
|
|
@@ -24,6 +25,7 @@ import { MessagingError, WindowClosedError, FileHashDedupStrategy, UploadStrateg
|
|
|
24
25
|
import { BaseMetaClient } from '../base-client.js';
|
|
25
26
|
import { GraphAPIClient } from '../graph-api/client.js';
|
|
26
27
|
import { SmartSplitter } from '../message-splitter.js';
|
|
28
|
+
import { MAX_PRODUCT_LIST_SECTIONS, MAX_PRODUCT_LIST_PRODUCTS, } from './commerce.js';
|
|
27
29
|
import { WhatsAppFormatConverter } from './format.js';
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Factory
|
|
@@ -38,8 +40,8 @@ export function createWhatsAppClient(config) {
|
|
|
38
40
|
* Full-featured WhatsApp Cloud API client.
|
|
39
41
|
*
|
|
40
42
|
* Implements the `PlatformClient` interface via {@link BaseMetaClient}.
|
|
41
|
-
* Layers WhatsApp-specific capabilities (templates, Flows,
|
|
42
|
-
* buttons, locations, contacts) on top.
|
|
43
|
+
* Layers WhatsApp-specific capabilities (templates, Flows, commerce,
|
|
44
|
+
* reactions, CTA buttons, locations, contacts) on top.
|
|
43
45
|
*/
|
|
44
46
|
export class WhatsAppClient extends BaseMetaClient {
|
|
45
47
|
platform = 'whatsapp';
|
|
@@ -135,7 +137,7 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
135
137
|
footer: msg.footer ? { text: msg.footer } : undefined,
|
|
136
138
|
flowId: msg.action.flowId,
|
|
137
139
|
flowCta: 'Continue',
|
|
138
|
-
flowToken: msg.action.flowToken
|
|
140
|
+
flowToken: msg.action.flowToken,
|
|
139
141
|
flowAction: 'navigate',
|
|
140
142
|
flowActionPayload: msg.action.parameters,
|
|
141
143
|
});
|
|
@@ -151,14 +153,25 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
151
153
|
});
|
|
152
154
|
return this.toSendResult(to, response);
|
|
153
155
|
}
|
|
154
|
-
async markAsRead(messageId) {
|
|
155
|
-
|
|
156
|
+
async markAsRead(messageId, opts) {
|
|
157
|
+
const body = {
|
|
156
158
|
messaging_product: 'whatsapp',
|
|
157
159
|
status: 'read',
|
|
158
160
|
message_id: messageId,
|
|
159
|
-
}
|
|
161
|
+
};
|
|
162
|
+
if (opts?.typing) {
|
|
163
|
+
body.typing_indicator = { type: 'text' };
|
|
164
|
+
}
|
|
165
|
+
await this.graphApi.post(`${this.config.phoneNumberId}/messages`, body);
|
|
160
166
|
}
|
|
161
|
-
/**
|
|
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
|
+
*/
|
|
162
175
|
async sendTypingIndicator(_to) {
|
|
163
176
|
// intentionally empty
|
|
164
177
|
}
|
|
@@ -197,6 +210,10 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
197
210
|
// WhatsApp-specific — Interactive messages
|
|
198
211
|
// =========================================================================
|
|
199
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
|
+
}
|
|
200
217
|
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
201
218
|
messaging_product: 'whatsapp',
|
|
202
219
|
recipient_type: 'individual',
|
|
@@ -256,6 +273,16 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
256
273
|
return this.toSendResult(to, response);
|
|
257
274
|
}
|
|
258
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
|
+
}
|
|
259
286
|
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
260
287
|
messaging_product: 'whatsapp',
|
|
261
288
|
recipient_type: 'individual',
|
|
@@ -267,13 +294,124 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
267
294
|
footer: flow.footer,
|
|
268
295
|
action: {
|
|
269
296
|
name: 'flow',
|
|
297
|
+
parameters,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return this.toSendResult(to, response);
|
|
302
|
+
}
|
|
303
|
+
// =========================================================================
|
|
304
|
+
// WhatsApp-specific — Commerce (products, catalog, addresses)
|
|
305
|
+
// =========================================================================
|
|
306
|
+
/**
|
|
307
|
+
* Send a single-product message (`interactive.type: "product"`).
|
|
308
|
+
*
|
|
309
|
+
* Displays one catalog product with a **View** button; the user can open
|
|
310
|
+
* the Product Detail Page, add to cart, and send an order. Orders arrive
|
|
311
|
+
* as inbound `type: "order"` messages — see {@link parseInboundOrder}.
|
|
312
|
+
*/
|
|
313
|
+
async sendProduct(to, product) {
|
|
314
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
315
|
+
messaging_product: 'whatsapp',
|
|
316
|
+
recipient_type: 'individual',
|
|
317
|
+
to,
|
|
318
|
+
type: 'interactive',
|
|
319
|
+
interactive: {
|
|
320
|
+
type: 'product',
|
|
321
|
+
body: product.body,
|
|
322
|
+
footer: product.footer,
|
|
323
|
+
action: {
|
|
324
|
+
catalog_id: product.catalogId,
|
|
325
|
+
product_retailer_id: product.productRetailerId,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
return this.toSendResult(to, response);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Send a multi-product message (`interactive.type: "product_list"`).
|
|
333
|
+
*
|
|
334
|
+
* Displays up to {@link MAX_PRODUCT_LIST_PRODUCTS} catalog products across
|
|
335
|
+
* up to {@link MAX_PRODUCT_LIST_SECTIONS} sections. Header and body are
|
|
336
|
+
* required by the Cloud API.
|
|
337
|
+
*
|
|
338
|
+
* @throws {MessagingError} `INVALID_PRODUCT_LIST` when the section or
|
|
339
|
+
* product limits are violated, or a section has no products.
|
|
340
|
+
*/
|
|
341
|
+
async sendProductList(to, list) {
|
|
342
|
+
this.validateProductList(list);
|
|
343
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
344
|
+
messaging_product: 'whatsapp',
|
|
345
|
+
recipient_type: 'individual',
|
|
346
|
+
to,
|
|
347
|
+
type: 'interactive',
|
|
348
|
+
interactive: {
|
|
349
|
+
type: 'product_list',
|
|
350
|
+
header: list.header,
|
|
351
|
+
body: list.body,
|
|
352
|
+
footer: list.footer,
|
|
353
|
+
action: {
|
|
354
|
+
catalog_id: list.catalogId,
|
|
355
|
+
sections: list.sections.map((section) => ({
|
|
356
|
+
title: section.title,
|
|
357
|
+
product_items: section.productRetailerIds.map((id) => ({
|
|
358
|
+
product_retailer_id: id,
|
|
359
|
+
})),
|
|
360
|
+
})),
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
return this.toSendResult(to, response);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Send a catalog message (`interactive.type: "catalog_message"`).
|
|
368
|
+
*
|
|
369
|
+
* Displays a product thumbnail header, custom body text, and a
|
|
370
|
+
* **View catalog** button that opens the full catalog within WhatsApp.
|
|
371
|
+
*/
|
|
372
|
+
async sendCatalog(to, catalog) {
|
|
373
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
374
|
+
messaging_product: 'whatsapp',
|
|
375
|
+
recipient_type: 'individual',
|
|
376
|
+
to,
|
|
377
|
+
type: 'interactive',
|
|
378
|
+
interactive: {
|
|
379
|
+
type: 'catalog_message',
|
|
380
|
+
body: catalog.body,
|
|
381
|
+
footer: catalog.footer,
|
|
382
|
+
action: {
|
|
383
|
+
name: 'catalog_message',
|
|
384
|
+
parameters: catalog.thumbnailProductRetailerId
|
|
385
|
+
? { thumbnail_product_retailer_id: catalog.thumbnailProductRetailerId }
|
|
386
|
+
: undefined,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
return this.toSendResult(to, response);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Send an address request message (`interactive.type: "address_message"`).
|
|
394
|
+
*
|
|
395
|
+
* Country-gated by Meta — currently India-based businesses and their
|
|
396
|
+
* India customers only. The user's submission arrives as an inbound
|
|
397
|
+
* `nfm_reply` interactive message — see {@link parseInboundAddress}.
|
|
398
|
+
*/
|
|
399
|
+
async sendAddressRequest(to, address) {
|
|
400
|
+
const response = await this.graphApi.post(`${this.config.phoneNumberId}/messages`, {
|
|
401
|
+
messaging_product: 'whatsapp',
|
|
402
|
+
recipient_type: 'individual',
|
|
403
|
+
to,
|
|
404
|
+
type: 'interactive',
|
|
405
|
+
interactive: {
|
|
406
|
+
type: 'address_message',
|
|
407
|
+
body: address.body,
|
|
408
|
+
action: {
|
|
409
|
+
name: 'address_message',
|
|
270
410
|
parameters: {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
flow_action: flow.flowAction,
|
|
276
|
-
flow_action_payload: flow.flowActionPayload,
|
|
411
|
+
country: address.country,
|
|
412
|
+
values: address.values,
|
|
413
|
+
saved_addresses: address.savedAddresses,
|
|
414
|
+
validation_errors: address.validationErrors,
|
|
277
415
|
},
|
|
278
416
|
},
|
|
279
417
|
},
|
|
@@ -318,17 +456,31 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
318
456
|
// =========================================================================
|
|
319
457
|
templates = {
|
|
320
458
|
list: async (wabaId) => {
|
|
321
|
-
const
|
|
322
|
-
|
|
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;
|
|
323
473
|
},
|
|
324
474
|
create: async (wabaId, template) => {
|
|
325
475
|
return this.graphApi.post(`${wabaId}/message_templates`, template);
|
|
326
476
|
},
|
|
327
|
-
delete: async (wabaId,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 });
|
|
332
484
|
},
|
|
333
485
|
};
|
|
334
486
|
// =========================================================================
|
|
@@ -374,8 +526,11 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
374
526
|
publish: async (flowId) => {
|
|
375
527
|
await this.graphApi.post(`${flowId}/publish`, {});
|
|
376
528
|
},
|
|
529
|
+
deprecate: async (flowId) => {
|
|
530
|
+
await this.graphApi.post(`${flowId}/deprecate`, {});
|
|
531
|
+
},
|
|
377
532
|
delete: async (flowId) => {
|
|
378
|
-
await this.graphApi.
|
|
533
|
+
await this.graphApi.delete(flowId);
|
|
379
534
|
},
|
|
380
535
|
getAssets: async (flowId) => {
|
|
381
536
|
return this.graphApi.get(`${flowId}/assets`);
|
|
@@ -442,7 +597,10 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
442
597
|
}
|
|
443
598
|
: undefined,
|
|
444
599
|
context: msg.context
|
|
445
|
-
? {
|
|
600
|
+
? {
|
|
601
|
+
messageId: msg.context.message_id,
|
|
602
|
+
from: msg.context.from,
|
|
603
|
+
}
|
|
446
604
|
: undefined,
|
|
447
605
|
raw: msg,
|
|
448
606
|
};
|
|
@@ -474,6 +632,7 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
474
632
|
? {
|
|
475
633
|
model: status.pricing.pricing_model,
|
|
476
634
|
category: status.pricing.category,
|
|
635
|
+
...(status.pricing.type ? { type: status.pricing.type } : {}),
|
|
477
636
|
}
|
|
478
637
|
: undefined,
|
|
479
638
|
raw: status,
|
|
@@ -490,6 +649,19 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
490
649
|
// =========================================================================
|
|
491
650
|
// Private — conversion helpers
|
|
492
651
|
// =========================================================================
|
|
652
|
+
validateProductList(list) {
|
|
653
|
+
if (list.sections.length === 0 || list.sections.length > MAX_PRODUCT_LIST_SECTIONS) {
|
|
654
|
+
throw new MessagingError(`Product list must have between 1 and ${MAX_PRODUCT_LIST_SECTIONS} sections (has ${list.sections.length})`, 'INVALID_PRODUCT_LIST', 'whatsapp');
|
|
655
|
+
}
|
|
656
|
+
const emptySection = list.sections.find((s) => s.productRetailerIds.length === 0);
|
|
657
|
+
if (emptySection) {
|
|
658
|
+
throw new MessagingError(`Product list section "${emptySection.title}" has no products`, 'INVALID_PRODUCT_LIST', 'whatsapp');
|
|
659
|
+
}
|
|
660
|
+
const totalProducts = list.sections.reduce((sum, s) => sum + s.productRetailerIds.length, 0);
|
|
661
|
+
if (totalProducts > MAX_PRODUCT_LIST_PRODUCTS) {
|
|
662
|
+
throw new MessagingError(`Product list exceeds maximum of ${MAX_PRODUCT_LIST_PRODUCTS} products across all sections (has ${totalProducts})`, 'INVALID_PRODUCT_LIST', 'whatsapp');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
493
665
|
toSendResult(to, response) {
|
|
494
666
|
return {
|
|
495
667
|
messageId: response.messages[0]?.id ?? '',
|
|
@@ -527,6 +699,8 @@ export class WhatsAppClient extends BaseMetaClient {
|
|
|
527
699
|
return msg.interactive.button_reply.title;
|
|
528
700
|
if (msg.interactive?.list_reply)
|
|
529
701
|
return msg.interactive.list_reply.title;
|
|
702
|
+
if (msg.order?.text)
|
|
703
|
+
return msg.order.text;
|
|
530
704
|
if (msg.location) {
|
|
531
705
|
return msg.location.name ?? `${msg.location.latitude},${msg.location.longitude}`;
|
|
532
706
|
}
|
|
@@ -582,6 +756,17 @@ function mapListTemplateRow(raw) {
|
|
|
582
756
|
paused,
|
|
583
757
|
};
|
|
584
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
|
+
}
|
|
585
770
|
// ---------------------------------------------------------------------------
|
|
586
771
|
// Utility
|
|
587
772
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module whatsapp/commerce
|
|
3
|
+
*
|
|
4
|
+
* Helpers and limits for WhatsApp commerce messages.
|
|
5
|
+
*
|
|
6
|
+
* Commerce messages let you share products from a Meta catalog and receive
|
|
7
|
+
* orders directly in WhatsApp. The send methods live on the
|
|
8
|
+
* {@link WhatsAppClient} (`sendProduct`, `sendProductList`, `sendCatalog`,
|
|
9
|
+
* `sendAddressRequest`); this module provides the inbound typed accessors.
|
|
10
|
+
*
|
|
11
|
+
* @see https://developers.facebook.com/documentation/business-messaging/whatsapp/catalogs/share-products
|
|
12
|
+
* @see https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/order
|
|
13
|
+
*/
|
|
14
|
+
import type { InboundMessage } from '@kuralle-agents/messaging';
|
|
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
|
+
/** Maximum number of sections in a multi-product message. */
|
|
18
|
+
export declare const MAX_PRODUCT_LIST_SECTIONS = 10;
|
|
19
|
+
/** Maximum number of products across all sections of a multi-product message. */
|
|
20
|
+
export declare const MAX_PRODUCT_LIST_PRODUCTS = 30;
|
|
21
|
+
/**
|
|
22
|
+
* Extract the typed order from an inbound message, when the user placed an
|
|
23
|
+
* order from a catalog, single-, or multi-product message.
|
|
24
|
+
*
|
|
25
|
+
* The generic `InboundMessage` type has no `order` field, so the order
|
|
26
|
+
* travels on `message.raw` (the normalized WhatsApp webhook message). This
|
|
27
|
+
* accessor returns it typed.
|
|
28
|
+
*
|
|
29
|
+
* @param message - An inbound message from the WhatsApp client.
|
|
30
|
+
* @returns The order, or `undefined` if the message is not an order.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* client.onMessage(async (msg) => {
|
|
35
|
+
* const order = parseInboundOrder(msg);
|
|
36
|
+
* if (order) {
|
|
37
|
+
* console.log(`Order against catalog ${order.catalog_id}:`);
|
|
38
|
+
* for (const item of order.product_items) {
|
|
39
|
+
* console.log(` ${item.quantity}x ${item.product_retailer_id} @ ${item.item_price} ${item.currency}`);
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseInboundOrder(message: InboundMessage): WhatsAppInboundOrder | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Extract the typed address submission from an inbound message, when the
|
|
48
|
+
* user replied to an address message (`sendAddressRequest`).
|
|
49
|
+
*
|
|
50
|
+
* Address replies arrive as `interactive.type === "nfm_reply"` messages with
|
|
51
|
+
* `nfm_reply.name === "address_message"`; the address fields are JSON-encoded
|
|
52
|
+
* in `nfm_reply.response_json`.
|
|
53
|
+
*
|
|
54
|
+
* @param message - An inbound message from the WhatsApp client.
|
|
55
|
+
* @returns The parsed address, or `undefined` if the message is not an
|
|
56
|
+
* address submission (or the payload is malformed).
|
|
57
|
+
*/
|
|
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;
|