@dojocoding/whatsapp-sdk 0.8.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/dist/index.js ADDED
@@ -0,0 +1,2183 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { trace, SpanStatusCode } from '@opentelemetry/api';
3
+
4
+ // src/types/errors.ts
5
+ var WhatsAppError = class extends Error {
6
+ code;
7
+ constructor(code, message, options) {
8
+ super(message);
9
+ this.name = "WhatsAppError";
10
+ this.code = code;
11
+ if (options?.cause !== void 0) {
12
+ this.cause = options.cause;
13
+ }
14
+ Object.setPrototypeOf(this, new.target.prototype);
15
+ }
16
+ toJSON() {
17
+ return { name: this.name, code: this.code, message: this.message };
18
+ }
19
+ };
20
+ var MissingCredentialsError = class extends WhatsAppError {
21
+ code = "MISSING_CREDENTIALS";
22
+ missingFields;
23
+ constructor(missingFields, options) {
24
+ super(
25
+ "MISSING_CREDENTIALS",
26
+ `WhatsAppClient is missing required credential field(s): ${missingFields.join(", ")}`,
27
+ options
28
+ );
29
+ this.name = "MissingCredentialsError";
30
+ this.missingFields = missingFields;
31
+ Object.setPrototypeOf(this, new.target.prototype);
32
+ }
33
+ toJSON() {
34
+ return {
35
+ name: this.name,
36
+ code: this.code,
37
+ message: this.message,
38
+ missingFields: this.missingFields
39
+ };
40
+ }
41
+ };
42
+ var RateLimitError = class extends WhatsAppError {
43
+ code = "RATE_LIMIT";
44
+ metaCode;
45
+ retryAfterMs;
46
+ constructor(message, meta = {}, options) {
47
+ super("RATE_LIMIT", message, options);
48
+ this.name = "RateLimitError";
49
+ this.metaCode = meta.metaCode;
50
+ this.retryAfterMs = meta.retryAfterMs;
51
+ Object.setPrototypeOf(this, new.target.prototype);
52
+ }
53
+ };
54
+ var WindowClosedError = class extends WhatsAppError {
55
+ code = "WINDOW_CLOSED";
56
+ customerWaId;
57
+ constructor(customerWaId, options) {
58
+ super(
59
+ "WINDOW_CLOSED",
60
+ `24-hour customer-service window is closed for ${customerWaId}; only approved templates may be sent.`,
61
+ options
62
+ );
63
+ this.name = "WindowClosedError";
64
+ this.customerWaId = customerWaId;
65
+ Object.setPrototypeOf(this, new.target.prototype);
66
+ }
67
+ };
68
+ var WebhookSignatureError = class extends WhatsAppError {
69
+ code = "WEBHOOK_SIGNATURE";
70
+ constructor(message = "Webhook signature verification failed", options) {
71
+ super("WEBHOOK_SIGNATURE", message, options);
72
+ this.name = "WebhookSignatureError";
73
+ Object.setPrototypeOf(this, new.target.prototype);
74
+ }
75
+ };
76
+ var TemplateError = class extends WhatsAppError {
77
+ code = "TEMPLATE";
78
+ templateName;
79
+ constructor(message, templateName, options) {
80
+ super("TEMPLATE", message, options);
81
+ this.name = "TemplateError";
82
+ this.templateName = templateName;
83
+ Object.setPrototypeOf(this, new.target.prototype);
84
+ }
85
+ };
86
+ var MockModeError = class extends WhatsAppError {
87
+ code = "MOCK_MODE";
88
+ constructor(message, options) {
89
+ super("MOCK_MODE", message, options);
90
+ this.name = "MockModeError";
91
+ Object.setPrototypeOf(this, new.target.prototype);
92
+ }
93
+ };
94
+ var AuthenticationError = class extends WhatsAppError {
95
+ code = "AUTHENTICATION";
96
+ metaCode;
97
+ subcode;
98
+ constructor(message, meta = {}, options) {
99
+ super("AUTHENTICATION", message, options);
100
+ this.name = "AuthenticationError";
101
+ this.metaCode = meta.metaCode;
102
+ this.subcode = meta.subcode;
103
+ Object.setPrototypeOf(this, new.target.prototype);
104
+ }
105
+ };
106
+ var PermissionError = class extends WhatsAppError {
107
+ code = "PERMISSION";
108
+ metaCode;
109
+ constructor(message, meta = {}, options) {
110
+ super("PERMISSION", message, options);
111
+ this.name = "PermissionError";
112
+ this.metaCode = meta.metaCode;
113
+ Object.setPrototypeOf(this, new.target.prototype);
114
+ }
115
+ };
116
+ var CapabilityError = class extends WhatsAppError {
117
+ code = "CAPABILITY";
118
+ metaCode;
119
+ constructor(message, meta = {}, options) {
120
+ super("CAPABILITY", message, options);
121
+ this.name = "CapabilityError";
122
+ this.metaCode = meta.metaCode;
123
+ Object.setPrototypeOf(this, new.target.prototype);
124
+ }
125
+ };
126
+
127
+ // src/templates/placeholders.ts
128
+ var PLACEHOLDER_RE = /\{\{\s*(\d+)\s*\}\}/g;
129
+ function countTemplatePlaceholders(text) {
130
+ if (typeof text !== "string" || text.length === 0) return 0;
131
+ const indices = /* @__PURE__ */ new Set();
132
+ let match;
133
+ PLACEHOLDER_RE.lastIndex = 0;
134
+ while ((match = PLACEHOLDER_RE.exec(text)) !== null) {
135
+ const raw = match[1] ?? "";
136
+ const idx = Number.parseInt(raw, 10);
137
+ if (!Number.isFinite(idx) || idx < 0) {
138
+ throw new TemplateError(
139
+ `Invalid template placeholder "{{${raw}}}": must be a non-negative integer.`
140
+ );
141
+ }
142
+ if (idx === 0) {
143
+ throw new TemplateError("Template placeholders are 1-indexed; `{{0}}` is invalid.");
144
+ }
145
+ indices.add(idx);
146
+ }
147
+ if (indices.size === 0) return 0;
148
+ const max = Math.max(...indices);
149
+ for (let i = 1; i <= max; i += 1) {
150
+ if (!indices.has(i)) {
151
+ throw new TemplateError(
152
+ `Template placeholders must be contiguous; missing \`{{${i}}}\` between {{1}} and {{${max}}}.`
153
+ );
154
+ }
155
+ }
156
+ return max;
157
+ }
158
+
159
+ // src/templates/validate.ts
160
+ function validateTemplateSend(payload, definition) {
161
+ if (payload.template.name !== definition.name) {
162
+ throw new TemplateError(
163
+ `Template name mismatch: payload="${payload.template.name}" vs definition="${definition.name}".`,
164
+ definition.name
165
+ );
166
+ }
167
+ if (payload.template.language.code !== definition.language) {
168
+ throw new TemplateError(
169
+ `Template language mismatch: payload="${payload.template.language.code}" vs definition="${definition.language}".`,
170
+ definition.name
171
+ );
172
+ }
173
+ for (const payloadComp of payload.template.components ?? []) {
174
+ if (payloadComp.type === "button") {
175
+ validateButtonComponent(payloadComp, definition);
176
+ continue;
177
+ }
178
+ if (payloadComp.type === "carousel" || payloadComp.type === "limited_time_offer") {
179
+ continue;
180
+ }
181
+ const defComp = findDefinitionComponent(definition, payloadComp.type);
182
+ if (defComp === void 0) {
183
+ throw new TemplateError(
184
+ `Template component "${payloadComp.type}" not present in definition "${definition.name}".`,
185
+ definition.name
186
+ );
187
+ }
188
+ const expected = countTemplatePlaceholders(defComp.text);
189
+ const actual = payloadComp.parameters?.length ?? 0;
190
+ if (expected !== actual) {
191
+ throw new TemplateError(
192
+ `Template component "${payloadComp.type}" expects ${expected} parameter(s) but payload provided ${actual}.`,
193
+ definition.name
194
+ );
195
+ }
196
+ }
197
+ }
198
+ function findDefinitionComponent(definition, payloadType) {
199
+ const wantUpper = payloadType.toUpperCase();
200
+ return definition.components.find((c) => c.type === wantUpper);
201
+ }
202
+ function validateButtonComponent(payloadComp, definition) {
203
+ const buttons = definition.components.find((c) => c.type === "BUTTONS");
204
+ if (buttons === void 0) {
205
+ throw new TemplateError(
206
+ `Template payload includes a button component but definition "${definition.name}" has no BUTTONS component.`,
207
+ definition.name
208
+ );
209
+ }
210
+ const idxRaw = payloadComp.index;
211
+ const idx = typeof idxRaw === "string" ? Number.parseInt(idxRaw, 10) : Number.NaN;
212
+ if (!Number.isFinite(idx) || idx < 0) {
213
+ throw new TemplateError(
214
+ `Button component requires a numeric \`index\` string; received "${String(idxRaw)}".`,
215
+ definition.name
216
+ );
217
+ }
218
+ const button = buttons.buttons?.[idx];
219
+ if (button === void 0) {
220
+ throw new TemplateError(
221
+ `Button component index ${idx} is out of range for definition "${definition.name}".`,
222
+ definition.name
223
+ );
224
+ }
225
+ const subType = (payloadComp.sub_type ?? "").toUpperCase();
226
+ if (subType !== "" && button.type.toUpperCase() !== subTypeToButtonType(subType)) {
227
+ throw new TemplateError(
228
+ `Button component sub_type "${payloadComp.sub_type ?? ""}" does not match definition's "${button.type}".`,
229
+ definition.name
230
+ );
231
+ }
232
+ }
233
+ function subTypeToButtonType(subType) {
234
+ switch (subType) {
235
+ case "QUICK_REPLY":
236
+ return "QUICK_REPLY";
237
+ case "URL":
238
+ return "URL";
239
+ case "COPY_CODE":
240
+ return "COPY_CODE";
241
+ default:
242
+ return subType;
243
+ }
244
+ }
245
+
246
+ // src/messages/builders.ts
247
+ var BASE_PAYLOAD = {
248
+ messaging_product: "whatsapp",
249
+ recipient_type: "individual"
250
+ };
251
+ function fail(message, cause) {
252
+ throw new WhatsAppError("UNKNOWN", message, void 0 );
253
+ }
254
+ function failTemplate(message, templateName, cause) {
255
+ throw new TemplateError(message, templateName, void 0 );
256
+ }
257
+ function ensureRecipient(to) {
258
+ if (typeof to !== "string" || to.trim().length === 0) {
259
+ fail("Invalid recipient: `to` must be a non-empty string.");
260
+ }
261
+ return to;
262
+ }
263
+ function ensureReplyTo(replyTo) {
264
+ if (replyTo === void 0) return void 0;
265
+ if (typeof replyTo !== "string" || replyTo.length === 0) {
266
+ fail("Invalid `replyTo`: must be a non-empty wamid string.");
267
+ }
268
+ return replyTo;
269
+ }
270
+ function withReplyTo(payload, replyTo) {
271
+ if (replyTo === void 0) return payload;
272
+ return { ...payload, context: { message_id: replyTo } };
273
+ }
274
+ function exactlyOne(a, b, label) {
275
+ const haveA = typeof a === "string" && a.length > 0;
276
+ const haveB = typeof b === "string" && b.length > 0;
277
+ if (haveA === haveB) {
278
+ fail(`${label}: provide exactly one of \`id\` or \`link\` (not both, not neither).`);
279
+ }
280
+ }
281
+ function ensureNumberInRange(value, min, max, label) {
282
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
283
+ fail(`${label}: must be a finite number in [${min}, ${max}].`);
284
+ }
285
+ return value;
286
+ }
287
+ function buildText(input) {
288
+ const to = ensureRecipient(input.to);
289
+ const replyTo = ensureReplyTo(input.replyTo);
290
+ if (typeof input.body !== "string" || input.body.length === 0) {
291
+ fail("buildText: `body` must be a non-empty string.");
292
+ }
293
+ const text = input.previewUrl === void 0 ? { body: input.body } : { body: input.body, preview_url: input.previewUrl };
294
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "text", text }, replyTo);
295
+ }
296
+ function mediaSource(input, label) {
297
+ exactlyOne(input.id, input.link, label);
298
+ const out = {};
299
+ if (typeof input.id === "string" && input.id.length > 0) out.id = input.id;
300
+ if (typeof input.link === "string" && input.link.length > 0) out.link = input.link;
301
+ if (input.caption !== void 0) out.caption = input.caption;
302
+ if (input.filename !== void 0) out.filename = input.filename;
303
+ return out;
304
+ }
305
+ function buildImage(input) {
306
+ const to = ensureRecipient(input.to);
307
+ const replyTo = ensureReplyTo(input.replyTo);
308
+ const src = mediaSource(input, "buildImage");
309
+ delete src.filename;
310
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "image", image: src }, replyTo);
311
+ }
312
+ function buildVideo(input) {
313
+ const to = ensureRecipient(input.to);
314
+ const replyTo = ensureReplyTo(input.replyTo);
315
+ const src = mediaSource(input, "buildVideo");
316
+ delete src.filename;
317
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "video", video: src }, replyTo);
318
+ }
319
+ function buildAudio(input) {
320
+ const to = ensureRecipient(input.to);
321
+ const replyTo = ensureReplyTo(input.replyTo);
322
+ const src = mediaSource(input, "buildAudio");
323
+ return withReplyTo(
324
+ {
325
+ ...BASE_PAYLOAD,
326
+ to,
327
+ type: "audio",
328
+ audio: src.id !== void 0 ? { id: src.id } : { link: src.link }
329
+ },
330
+ replyTo
331
+ );
332
+ }
333
+ function buildDocument(input) {
334
+ const to = ensureRecipient(input.to);
335
+ const replyTo = ensureReplyTo(input.replyTo);
336
+ const src = mediaSource(input, "buildDocument");
337
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "document", document: src }, replyTo);
338
+ }
339
+ function buildSticker(input) {
340
+ const to = ensureRecipient(input.to);
341
+ const replyTo = ensureReplyTo(input.replyTo);
342
+ const src = mediaSource(input, "buildSticker");
343
+ return withReplyTo(
344
+ {
345
+ ...BASE_PAYLOAD,
346
+ to,
347
+ type: "sticker",
348
+ sticker: src.id !== void 0 ? { id: src.id } : { link: src.link }
349
+ },
350
+ replyTo
351
+ );
352
+ }
353
+ function buildLocation(input) {
354
+ const to = ensureRecipient(input.to);
355
+ const replyTo = ensureReplyTo(input.replyTo);
356
+ const latitude = ensureNumberInRange(input.latitude, -90, 90, "buildLocation.latitude");
357
+ const longitude = ensureNumberInRange(input.longitude, -180, 180, "buildLocation.longitude");
358
+ const location = { latitude, longitude };
359
+ if (input.name !== void 0) location.name = input.name;
360
+ if (input.address !== void 0) location.address = input.address;
361
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "location", location }, replyTo);
362
+ }
363
+ function buildContacts(input) {
364
+ const to = ensureRecipient(input.to);
365
+ const replyTo = ensureReplyTo(input.replyTo);
366
+ const list = Array.isArray(input.contacts) ? input.contacts : [input.contacts];
367
+ if (list.length === 0) fail("buildContacts: at least one contact is required.");
368
+ for (const c of list) {
369
+ const formatted = c.name.formatted_name;
370
+ if (typeof formatted !== "string" || formatted.length === 0) {
371
+ fail("buildContacts: every contact must include `name.formatted_name`.");
372
+ }
373
+ }
374
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "contacts", contacts: list }, replyTo);
375
+ }
376
+ function buildInteractiveButton(input) {
377
+ const to = ensureRecipient(input.to);
378
+ const replyTo = ensureReplyTo(input.replyTo);
379
+ if (typeof input.body !== "string" || input.body.length === 0) {
380
+ fail("buildInteractiveButton: `body` must be a non-empty string.");
381
+ }
382
+ if (input.buttons.length < 1 || input.buttons.length > 3) {
383
+ fail("buildInteractiveButton: `buttons` must contain 1 to 3 entries.");
384
+ }
385
+ for (const b of input.buttons) {
386
+ if (typeof b.id !== "string" || b.id.length === 0 || typeof b.title !== "string" || b.title.length === 0) {
387
+ fail("buildInteractiveButton: every button needs a non-empty `id` and `title`.");
388
+ }
389
+ }
390
+ const interactive = {
391
+ type: "button",
392
+ body: { text: input.body },
393
+ action: {
394
+ buttons: input.buttons.map((b) => ({
395
+ type: "reply",
396
+ reply: { id: b.id, title: b.title }
397
+ }))
398
+ }
399
+ };
400
+ if (input.header !== void 0) interactive.header = input.header;
401
+ if (input.footer !== void 0) interactive.footer = { text: input.footer };
402
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "interactive", interactive }, replyTo);
403
+ }
404
+ function buildInteractiveList(input) {
405
+ const to = ensureRecipient(input.to);
406
+ const replyTo = ensureReplyTo(input.replyTo);
407
+ if (typeof input.body !== "string" || input.body.length === 0) {
408
+ fail("buildInteractiveList: `body` must be a non-empty string.");
409
+ }
410
+ if (typeof input.button !== "string" || input.button.length === 0) {
411
+ fail("buildInteractiveList: `button` must be a non-empty string.");
412
+ }
413
+ if (input.sections.length < 1 || input.sections.length > 10) {
414
+ fail("buildInteractiveList: `sections` must contain 1 to 10 entries.");
415
+ }
416
+ for (const s of input.sections) {
417
+ if (typeof s.title !== "string" || s.title.length === 0) {
418
+ fail("buildInteractiveList: every section needs a non-empty `title`.");
419
+ }
420
+ if (s.rows.length < 1 || s.rows.length > 10) {
421
+ fail("buildInteractiveList: every section must have 1 to 10 rows.");
422
+ }
423
+ for (const r of s.rows) {
424
+ if (typeof r.id !== "string" || r.id.length === 0) {
425
+ fail("buildInteractiveList: every row needs a non-empty `id`.");
426
+ }
427
+ if (typeof r.title !== "string" || r.title.length === 0) {
428
+ fail("buildInteractiveList: every row needs a non-empty `title`.");
429
+ }
430
+ }
431
+ }
432
+ const interactive = {
433
+ type: "list",
434
+ body: { text: input.body },
435
+ action: { button: input.button, sections: input.sections }
436
+ };
437
+ if (input.header !== void 0) interactive.header = input.header;
438
+ if (input.footer !== void 0) interactive.footer = { text: input.footer };
439
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "interactive", interactive }, replyTo);
440
+ }
441
+ function buildInteractiveCtaUrl(input) {
442
+ const to = ensureRecipient(input.to);
443
+ const replyTo = ensureReplyTo(input.replyTo);
444
+ if (typeof input.body !== "string" || input.body.length === 0) {
445
+ fail("buildInteractiveCtaUrl: `body` must be a non-empty string.");
446
+ }
447
+ if (typeof input.cta?.displayText !== "string" || input.cta.displayText.length === 0) {
448
+ fail("buildInteractiveCtaUrl: `cta.displayText` must be non-empty.");
449
+ }
450
+ try {
451
+ new URL(input.cta.url);
452
+ } catch {
453
+ fail("buildInteractiveCtaUrl: `cta.url` must be a valid URL.");
454
+ }
455
+ const interactive = {
456
+ type: "cta_url",
457
+ body: { text: input.body },
458
+ action: {
459
+ name: "cta_url",
460
+ parameters: { display_text: input.cta.displayText, url: input.cta.url }
461
+ }
462
+ };
463
+ if (input.header !== void 0) interactive.header = input.header;
464
+ if (input.footer !== void 0) interactive.footer = { text: input.footer };
465
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "interactive", interactive }, replyTo);
466
+ }
467
+ function buildInteractive(input) {
468
+ switch (input.kind) {
469
+ case "button":
470
+ return buildInteractiveButton(input);
471
+ case "list":
472
+ return buildInteractiveList(input);
473
+ case "cta_url":
474
+ return buildInteractiveCtaUrl(input);
475
+ default: {
476
+ const exhaustive = input;
477
+ fail(
478
+ `buildInteractive: unknown kind "${exhaustive.kind}". v1 supports button | list | cta_url.`
479
+ );
480
+ }
481
+ }
482
+ }
483
+ function buildTemplate(input) {
484
+ const to = ensureRecipient(input.to);
485
+ const replyTo = ensureReplyTo(input.replyTo);
486
+ if (typeof input.name !== "string" || input.name.length === 0) {
487
+ failTemplate("buildTemplate: `name` must be a non-empty string.", input.name);
488
+ }
489
+ if (typeof input.language !== "string" || input.language.length === 0) {
490
+ failTemplate("buildTemplate: `language` must be a non-empty BCP-47 code.", input.name);
491
+ }
492
+ if (input.components) {
493
+ for (const c of input.components) {
494
+ if (!["header", "body", "button", "footer", "carousel", "limited_time_offer"].includes(c.type)) {
495
+ failTemplate(`buildTemplate: invalid component.type "${c.type}".`, input.name);
496
+ }
497
+ if (c.type === "button" && c.sub_type === void 0) {
498
+ failTemplate(
499
+ "buildTemplate: button components require a `sub_type` (quick_reply | url | copy_code).",
500
+ input.name
501
+ );
502
+ }
503
+ }
504
+ }
505
+ const template = {
506
+ name: input.name,
507
+ language: { code: input.language }
508
+ };
509
+ if (input.components !== void 0) template.components = input.components;
510
+ const payload = withReplyTo(
511
+ { ...BASE_PAYLOAD, to, type: "template", template },
512
+ replyTo
513
+ );
514
+ if (input.validateAgainst !== void 0) {
515
+ validateTemplateSend(payload, input.validateAgainst);
516
+ }
517
+ return payload;
518
+ }
519
+ function buildReaction(input) {
520
+ const to = ensureRecipient(input.to);
521
+ const replyTo = ensureReplyTo(input.replyTo);
522
+ if (typeof input.messageId !== "string" || input.messageId.length === 0) {
523
+ fail("buildReaction: `messageId` (wamid) must be a non-empty string.");
524
+ }
525
+ if (typeof input.emoji !== "string") {
526
+ fail('buildReaction: `emoji` must be a string (use "" to clear).');
527
+ }
528
+ return withReplyTo(
529
+ {
530
+ ...BASE_PAYLOAD,
531
+ to,
532
+ type: "reaction",
533
+ reaction: { message_id: input.messageId, emoji: input.emoji }
534
+ },
535
+ replyTo
536
+ );
537
+ }
538
+ function buildAuthTemplate(input) {
539
+ const to = ensureRecipient(input.to);
540
+ const replyTo = ensureReplyTo(input.replyTo);
541
+ if (typeof input.name !== "string" || input.name.length === 0) {
542
+ failTemplate("buildAuthTemplate: `name` must be a non-empty string.", input.name);
543
+ }
544
+ if (typeof input.language !== "string" || input.language.length === 0) {
545
+ failTemplate("buildAuthTemplate: `language` must be a non-empty BCP-47 code.", input.name);
546
+ }
547
+ if (typeof input.otp !== "string" || input.otp.length === 0) {
548
+ failTemplate("buildAuthTemplate: `otp` must be a non-empty string.", input.name);
549
+ }
550
+ if (input.otp.length > 15) {
551
+ failTemplate(
552
+ `buildAuthTemplate: \`otp\` exceeds Meta's 15-character maximum (got ${input.otp.length}).`,
553
+ input.name
554
+ );
555
+ }
556
+ const buttonIndex = input.otpButtonIndex ?? "0";
557
+ const components = [
558
+ { type: "body", parameters: [{ type: "text", text: input.otp }] },
559
+ {
560
+ type: "button",
561
+ sub_type: "url",
562
+ index: buttonIndex,
563
+ parameters: [{ type: "text", text: input.otp }]
564
+ }
565
+ ];
566
+ return withReplyTo(
567
+ {
568
+ ...BASE_PAYLOAD,
569
+ to,
570
+ type: "template",
571
+ template: { name: input.name, language: { code: input.language }, components }
572
+ },
573
+ replyTo
574
+ );
575
+ }
576
+ function buildVoice(input) {
577
+ const to = ensureRecipient(input.to);
578
+ const replyTo = ensureReplyTo(input.replyTo);
579
+ exactlyOne(input.id, input.link, "buildVoice");
580
+ const audio = typeof input.id === "string" && input.id.length > 0 ? { id: input.id, voice: true } : { link: input.link, voice: true };
581
+ return withReplyTo({ ...BASE_PAYLOAD, to, type: "audio", audio }, replyTo);
582
+ }
583
+ var CAROUSEL_MAX_CARDS = 10;
584
+ function buildCarouselTemplate(input) {
585
+ const to = ensureRecipient(input.to);
586
+ const replyTo = ensureReplyTo(input.replyTo);
587
+ if (typeof input.name !== "string" || input.name.length === 0) {
588
+ failTemplate("buildCarouselTemplate: `name` must be a non-empty string.", input.name);
589
+ }
590
+ if (typeof input.language !== "string" || input.language.length === 0) {
591
+ failTemplate("buildCarouselTemplate: `language` must be a non-empty BCP-47 code.", input.name);
592
+ }
593
+ const cards = input.cards;
594
+ if (cards === null || typeof cards !== "object" || typeof cards.length !== "number") {
595
+ failTemplate("buildCarouselTemplate: `cards` must be a non-empty array.", input.name);
596
+ }
597
+ if (cards.length === 0) {
598
+ failTemplate("buildCarouselTemplate: `cards` must be a non-empty array.", input.name);
599
+ }
600
+ if (cards.length > CAROUSEL_MAX_CARDS) {
601
+ failTemplate(
602
+ `buildCarouselTemplate: cards.length (${cards.length}) exceeds Meta's ${CAROUSEL_MAX_CARDS}-card maximum.`,
603
+ input.name
604
+ );
605
+ }
606
+ const topLevelComponents = [];
607
+ if (input.bodyParameters !== void 0 && input.bodyParameters.length > 0) {
608
+ topLevelComponents.push({
609
+ type: "body",
610
+ parameters: input.bodyParameters.map((text) => ({ type: "text", text }))
611
+ });
612
+ }
613
+ topLevelComponents.push({
614
+ type: "carousel",
615
+ cards: cards.map((card, cardIndex) => buildCarouselCard(card, cardIndex, input.name))
616
+ });
617
+ return withReplyTo(
618
+ {
619
+ ...BASE_PAYLOAD,
620
+ to,
621
+ type: "template",
622
+ template: {
623
+ name: input.name,
624
+ language: { code: input.language },
625
+ components: topLevelComponents
626
+ }
627
+ },
628
+ replyTo
629
+ );
630
+ }
631
+ function buildCarouselCard(card, cardIndex, templateName) {
632
+ const headerSource = card.header;
633
+ if (typeof headerSource?.type !== "string" || !["image", "video"].includes(headerSource.type)) {
634
+ failTemplate(
635
+ `buildCarouselTemplate: card[${cardIndex}].header.type must be "image" or "video".`,
636
+ templateName
637
+ );
638
+ }
639
+ exactlyOne(
640
+ headerSource.mediaId,
641
+ headerSource.link,
642
+ `buildCarouselTemplate: card[${cardIndex}].header`
643
+ );
644
+ const mediaParam = typeof headerSource.mediaId === "string" && headerSource.mediaId.length > 0 ? { id: headerSource.mediaId } : { link: headerSource.link };
645
+ const headerParam = headerSource.type === "image" ? { type: "image", image: mediaParam } : { type: "video", video: mediaParam };
646
+ const components = [{ type: "header", parameters: [headerParam] }];
647
+ if (card.bodyParameters !== void 0 && card.bodyParameters.length > 0) {
648
+ components.push({
649
+ type: "body",
650
+ parameters: card.bodyParameters.map((text) => ({ type: "text", text }))
651
+ });
652
+ }
653
+ if (card.buttons !== void 0) {
654
+ card.buttons.forEach((btn, btnIndex) => {
655
+ if (btn.subType === "quick_reply") {
656
+ components.push({
657
+ type: "button",
658
+ sub_type: "quick_reply",
659
+ index: btnIndex,
660
+ parameters: [{ type: "payload", payload: btn.payload }]
661
+ });
662
+ } else {
663
+ components.push({
664
+ type: "button",
665
+ sub_type: "url",
666
+ index: btnIndex,
667
+ parameters: [{ type: "text", text: btn.text }]
668
+ });
669
+ }
670
+ });
671
+ }
672
+ return { card_index: cardIndex, components };
673
+ }
674
+
675
+ // src/messages/send.ts
676
+ function sendMessage(client, payload, options) {
677
+ const path = `/${client.phoneNumberId}/messages`;
678
+ return client.request("POST", path, payload, options);
679
+ }
680
+
681
+ // src/templates/api.ts
682
+ function listTemplates(client, query = {}, options) {
683
+ const path = `/${client.wabaId}/message_templates${buildQuery(query)}`;
684
+ return client.request("GET", path, void 0, options);
685
+ }
686
+ function getTemplate(client, templateId, options) {
687
+ if (typeof templateId !== "string" || templateId.length === 0) {
688
+ throw new TypeError("getTemplate: templateId must be a non-empty string.");
689
+ }
690
+ const path = `/${templateId}`;
691
+ return client.request("GET", path, void 0, options);
692
+ }
693
+ function buildQuery(query) {
694
+ const entries = [];
695
+ if (query.name !== void 0) entries.push(["name", query.name]);
696
+ if (query.language !== void 0) entries.push(["language", query.language]);
697
+ if (query.status !== void 0) entries.push(["status", query.status]);
698
+ if (query.category !== void 0) entries.push(["category", query.category]);
699
+ if (query.limit !== void 0) entries.push(["limit", String(query.limit)]);
700
+ if (query.after !== void 0) entries.push(["after", query.after]);
701
+ if (query.before !== void 0) entries.push(["before", query.before]);
702
+ if (entries.length === 0) return "";
703
+ return "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
704
+ }
705
+
706
+ // src/types/constants.ts
707
+ var GRAPH_API_VERSION = "v25.0";
708
+ var META_GRAPH_BASE_URL = "https://graph.facebook.com";
709
+ var WEBHOOK_ACK_DEADLINE_MS = 3e4;
710
+ var WINDOW_TTL_MS = 24 * 60 * 60 * 1e3;
711
+ var WEBHOOK_DEDUPE_TTL_MS = 24 * 60 * 60 * 1e3;
712
+
713
+ // src/observability/redact.ts
714
+ var redactSalt = "@dojocoding/whatsapp:dev-default-salt";
715
+ var encoder = new TextEncoder();
716
+ function setRedactSalt(salt) {
717
+ if (typeof salt !== "string" || salt.length === 0) {
718
+ throw new TypeError("setRedactSalt: salt must be a non-empty string.");
719
+ }
720
+ redactSalt = salt;
721
+ }
722
+ async function hashPhoneNumberId(value) {
723
+ const input = encoder.encode(`${redactSalt}:${value}`);
724
+ const digest = await crypto.subtle.digest("SHA-256", input);
725
+ const bytes = new Uint8Array(digest);
726
+ let out = "";
727
+ for (let i = 0; i < 8; i++) {
728
+ out += bytes[i].toString(16).padStart(2, "0");
729
+ }
730
+ return out;
731
+ }
732
+ var TRACER_NAME = "@dojocoding/whatsapp";
733
+ var TRACER_VERSION = "0.0.0";
734
+ function getTracer() {
735
+ return trace.getTracer(TRACER_NAME, TRACER_VERSION);
736
+ }
737
+ async function withSpan(name, fn, attributes) {
738
+ const tracer = getTracer();
739
+ return tracer.startActiveSpan(name, async (span) => {
740
+ if (attributes !== void 0) {
741
+ span.setAttributes(attributes);
742
+ }
743
+ try {
744
+ const result = await fn();
745
+ span.end();
746
+ return result;
747
+ } catch (err) {
748
+ if (err instanceof Error) {
749
+ span.recordException(err);
750
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
751
+ } else {
752
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
753
+ }
754
+ span.end();
755
+ throw err;
756
+ }
757
+ });
758
+ }
759
+
760
+ // src/client/errors.ts
761
+ var RETRYABLE_RATE_LIMIT_CODES = /* @__PURE__ */ new Set([
762
+ 130429,
763
+ // Generic rate limit
764
+ 131048,
765
+ // Spam-detection rate limit
766
+ 131056,
767
+ // Pair rate limit
768
+ 131053
769
+ // Media-upload throttle
770
+ ]);
771
+ var WINDOW_CLOSED_CODE = 131026;
772
+ var AUTH_CODES = /* @__PURE__ */ new Set([
773
+ 190
774
+ // Invalid OAuth access token (subcodes: 463 expired, 467 invalid, 492 changed)
775
+ ]);
776
+ var PERMISSION_CODES = /* @__PURE__ */ new Set([
777
+ 200,
778
+ // Permissions error (general)
779
+ 210,
780
+ // User not visible / phone-level permission
781
+ 230,
782
+ // Permission disabled
783
+ 294,
784
+ // Permission for this action is required
785
+ 299
786
+ // Permission denied for this action
787
+ ]);
788
+ var CAPABILITY_CODES = /* @__PURE__ */ new Set([
789
+ 100
790
+ // Invalid parameter / API Unknown — request shape problem
791
+ ]);
792
+ function isMetaErrorEnvelope(value) {
793
+ if (typeof value !== "object" || value === null) return false;
794
+ const v = value;
795
+ if (typeof v.error !== "object" || v.error === null) return false;
796
+ const e = v.error;
797
+ return typeof e.code === "number" && typeof e.message === "string";
798
+ }
799
+ function looksLikeTemplateCode(code) {
800
+ return code >= 132e3 && code < 133e3;
801
+ }
802
+ function mapMetaError(httpStatus, body) {
803
+ if (!isMetaErrorEnvelope(body)) {
804
+ const fallbackMessage = typeof body === "string" && body.length > 0 ? `Graph API ${httpStatus}: ${body.slice(0, 200)}` : `Graph API ${httpStatus} with non-Meta-shaped error body`;
805
+ return new WhatsAppError("UNKNOWN", fallbackMessage);
806
+ }
807
+ const { code, message } = body.error;
808
+ if (RETRYABLE_RATE_LIMIT_CODES.has(code)) {
809
+ return new RateLimitError(message, { metaCode: code });
810
+ }
811
+ if (code === WINDOW_CLOSED_CODE) {
812
+ const recipient = extractRecipientFromMetaError(body);
813
+ return new WindowClosedError(recipient ?? "<unknown>");
814
+ }
815
+ if (AUTH_CODES.has(code)) {
816
+ const subcode = body.error.error_subcode;
817
+ return new AuthenticationError(message, {
818
+ metaCode: code,
819
+ ...typeof subcode === "number" ? { subcode } : {}
820
+ });
821
+ }
822
+ if (PERMISSION_CODES.has(code)) {
823
+ return new PermissionError(message, { metaCode: code });
824
+ }
825
+ if (CAPABILITY_CODES.has(code)) {
826
+ return new CapabilityError(message, { metaCode: code });
827
+ }
828
+ if (looksLikeTemplateCode(code)) {
829
+ return new TemplateError(message);
830
+ }
831
+ return new WhatsAppError("UNKNOWN", `Graph API ${httpStatus} (#${code}): ${message}`);
832
+ }
833
+ function extractRecipientFromMetaError(body) {
834
+ const data = body.error.error_data;
835
+ if (!data) return void 0;
836
+ const candidate = data["recipient_phone_number"] ?? data["customer_wa_id"];
837
+ if (typeof candidate === "string" && candidate.length > 0) {
838
+ return candidate;
839
+ }
840
+ return void 0;
841
+ }
842
+ function isRetryableError(err) {
843
+ if (err instanceof RateLimitError && typeof err.metaCode === "number") {
844
+ return RETRYABLE_RATE_LIMIT_CODES.has(err.metaCode);
845
+ }
846
+ return false;
847
+ }
848
+ function isRetryableHttpStatus(status) {
849
+ return status === 408 || status === 429 || status >= 500 && status < 600;
850
+ }
851
+
852
+ // src/client/retry.ts
853
+ var DEFAULT_RETRY_POLICY = {
854
+ maxAttempts: 4,
855
+ baseDelayMs: 250,
856
+ maxDelayMs: 8e3,
857
+ jitter: "full",
858
+ floorMs: 50
859
+ };
860
+ var TransientHttpError = class extends Error {
861
+ retryAfterMs;
862
+ constructor(message, retryAfterMs) {
863
+ super(message);
864
+ this.name = "TransientHttpError";
865
+ this.retryAfterMs = retryAfterMs;
866
+ Object.setPrototypeOf(this, new.target.prototype);
867
+ }
868
+ };
869
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
870
+ function fullJitterDelay(attempt, policy, random = Math.random) {
871
+ const exp = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1);
872
+ const cap = Math.min(policy.maxDelayMs, exp);
873
+ const sample = random() * cap;
874
+ return Math.max(policy.floorMs, sample);
875
+ }
876
+ function parseRetryAfter(headerValue, now = Date.now()) {
877
+ if (typeof headerValue !== "string") return void 0;
878
+ const trimmed = headerValue.trim();
879
+ if (trimmed.length === 0) return void 0;
880
+ const asNumber = Number(trimmed);
881
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
882
+ return Math.floor(asNumber * 1e3);
883
+ }
884
+ const parsedDate = Date.parse(trimmed);
885
+ if (Number.isFinite(parsedDate)) {
886
+ return Math.max(0, parsedDate - now);
887
+ }
888
+ return void 0;
889
+ }
890
+ async function retry(fn, policy = DEFAULT_RETRY_POLICY, hooks = {}) {
891
+ const sleep2 = hooks.sleep ?? defaultSleep;
892
+ const random = hooks.random ?? Math.random;
893
+ let lastError;
894
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
895
+ try {
896
+ return await fn(attempt);
897
+ } catch (err) {
898
+ lastError = err;
899
+ if (attempt === policy.maxAttempts || !shouldRetry(err)) {
900
+ throw err;
901
+ }
902
+ const hint = err instanceof TransientHttpError ? err.retryAfterMs : void 0;
903
+ const baseDelay = fullJitterDelay(attempt, policy, random);
904
+ const delay = typeof hint === "number" ? Math.min(policy.maxDelayMs, Math.max(policy.floorMs, hint)) : baseDelay;
905
+ await sleep2(delay);
906
+ }
907
+ }
908
+ throw lastError instanceof Error ? lastError : new Error("retry: exhausted");
909
+ }
910
+ function shouldRetry(err) {
911
+ if (err instanceof TransientHttpError) return true;
912
+ if (isRetryableError(err)) return true;
913
+ if (err instanceof TypeError && /fetch failed/i.test(err.message)) return true;
914
+ if (err instanceof Error && err.name === "AbortError") return true;
915
+ return false;
916
+ }
917
+
918
+ // src/client/transport.ts
919
+ var IDEMPOTENCY_HEADER = "X-Dojo-Idempotency-Key";
920
+ function buildGraphUrl(version, path) {
921
+ const cleanPath = path.startsWith("/") ? path.slice(1) : path;
922
+ return `${META_GRAPH_BASE_URL}/${version}/${cleanPath}`;
923
+ }
924
+ async function request(client, method, path, body, options = {}) {
925
+ const idempotencyKey = options.idempotencyKey ?? randomUUID();
926
+ const version = options.graphApiVersion ?? client.graphApiVersion;
927
+ const url = buildGraphUrl(version, path);
928
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
929
+ const hashedPhoneNumberId = await hashPhoneNumberId(client.phoneNumberId);
930
+ const bearerToken = await client._resolveBearerToken();
931
+ return withSpan(
932
+ "whatsapp.request",
933
+ async () => {
934
+ try {
935
+ return await retry(
936
+ async () => doFetch(fetchImpl, bearerToken, method, url, body, idempotencyKey, options.signal),
937
+ options.retryPolicy ?? DEFAULT_RETRY_POLICY,
938
+ options.retryHooks ?? {}
939
+ );
940
+ } catch (err) {
941
+ attachErrorAttributesToActiveSpan(err);
942
+ throw err;
943
+ }
944
+ },
945
+ {
946
+ "whatsapp.method": method,
947
+ "whatsapp.path": path.startsWith("/") ? path : `/${path}`,
948
+ "whatsapp.phone_number_id": hashedPhoneNumberId,
949
+ "whatsapp.idempotency_key": idempotencyKey
950
+ }
951
+ );
952
+ }
953
+ function attachErrorAttributesToActiveSpan(err) {
954
+ const span = trace.getActiveSpan();
955
+ if (span === void 0) return;
956
+ if (typeof err === "object" && err !== null && "code" in err) {
957
+ const code = err.code;
958
+ if (typeof code === "string") {
959
+ span.setAttribute("whatsapp.error.code", code);
960
+ }
961
+ }
962
+ const metaCode = extractMetaCode(err);
963
+ if (metaCode !== void 0) {
964
+ span.setAttribute("whatsapp.error.meta_code", metaCode);
965
+ }
966
+ }
967
+ function extractMetaCode(err) {
968
+ if (err instanceof RateLimitError && typeof err.metaCode === "number") return err.metaCode;
969
+ if (err instanceof AuthenticationError && typeof err.metaCode === "number") return err.metaCode;
970
+ if (err instanceof PermissionError && typeof err.metaCode === "number") return err.metaCode;
971
+ if (err instanceof CapabilityError && typeof err.metaCode === "number") return err.metaCode;
972
+ return void 0;
973
+ }
974
+ async function doFetch(fetchImpl, bearerToken, method, url, body, idempotencyKey, signal) {
975
+ const headers = {
976
+ Authorization: `Bearer ${bearerToken}`,
977
+ Accept: "application/json",
978
+ [IDEMPOTENCY_HEADER]: idempotencyKey
979
+ };
980
+ let serializedBody;
981
+ if (body !== void 0) {
982
+ headers["Content-Type"] = "application/json";
983
+ serializedBody = JSON.stringify(body);
984
+ }
985
+ const init = { method, headers };
986
+ if (serializedBody !== void 0) {
987
+ init.body = serializedBody;
988
+ }
989
+ if (signal !== void 0) {
990
+ init.signal = signal;
991
+ }
992
+ const response = await fetchImpl(url, init);
993
+ if (response.status >= 200 && response.status < 300) {
994
+ if (response.status === 204) {
995
+ return void 0;
996
+ }
997
+ return await response.json();
998
+ }
999
+ const parsedBody = await safeReadBody(response);
1000
+ if (isRetryableHttpStatus(response.status)) {
1001
+ const hint = parseRetryAfter(response.headers.get("retry-after"));
1002
+ throw new TransientHttpError(`Graph API ${response.status} (transient)`, hint);
1003
+ }
1004
+ throw mapMetaError(response.status, parsedBody);
1005
+ }
1006
+ async function safeReadBody(response) {
1007
+ const contentType = response.headers.get("content-type") ?? "";
1008
+ try {
1009
+ if (contentType.includes("application/json")) {
1010
+ return await response.json();
1011
+ }
1012
+ return await response.text();
1013
+ } catch {
1014
+ return void 0;
1015
+ }
1016
+ }
1017
+
1018
+ // src/client/health.ts
1019
+ async function healthCheck(client, options = {}) {
1020
+ const token = await client._resolveBearerToken();
1021
+ const path = `/debug_token?input_token=${encodeURIComponent(token)}`;
1022
+ const response = await request(client, "GET", path, void 0, options);
1023
+ const data = response.data;
1024
+ if (!data || data.is_valid !== true) {
1025
+ const message = data?.error?.message ?? "Token validation failed: Meta /debug_token returned is_valid=false";
1026
+ throw new WhatsAppError("UNKNOWN", message);
1027
+ }
1028
+ return {
1029
+ valid: true,
1030
+ expiresAt: typeof data.expires_at === "number" && data.expires_at > 0 ? data.expires_at * 1e3 : null,
1031
+ appId: data.app_id ?? null,
1032
+ userId: data.user_id ?? null,
1033
+ scopes: data.scopes ?? []
1034
+ };
1035
+ }
1036
+
1037
+ // src/client/whatsapp-client.ts
1038
+ var STRING_CREDENTIAL_FIELDS = [
1039
+ "phoneNumberId",
1040
+ "wabaId",
1041
+ "appSecret"
1042
+ ];
1043
+ function isValidTokenOption(value) {
1044
+ if (typeof value === "function") return true;
1045
+ return typeof value === "string" && value.length > 0;
1046
+ }
1047
+ var WhatsAppClient = class {
1048
+ phoneNumberId;
1049
+ wabaId;
1050
+ graphApiVersion;
1051
+ #tokenProvider;
1052
+ #appSecret;
1053
+ #windowTracker;
1054
+ constructor(options) {
1055
+ const missing = STRING_CREDENTIAL_FIELDS.filter((field) => {
1056
+ const value = options[field];
1057
+ return typeof value !== "string" || value.length === 0;
1058
+ });
1059
+ if (!isValidTokenOption(options.token)) {
1060
+ missing.push("token");
1061
+ }
1062
+ if (missing.length > 0) {
1063
+ throw new MissingCredentialsError(missing);
1064
+ }
1065
+ this.phoneNumberId = options.phoneNumberId;
1066
+ this.wabaId = options.wabaId;
1067
+ this.#tokenProvider = typeof options.token === "function" ? options.token : () => options.token;
1068
+ this.#appSecret = options.appSecret;
1069
+ this.graphApiVersion = options.graphApiVersion ?? GRAPH_API_VERSION;
1070
+ this.#windowTracker = options.windowTracker;
1071
+ }
1072
+ /**
1073
+ * Whether the 24h customer-service window is currently open for `to`.
1074
+ * Returns `true` when no window tracker is configured (preserving the
1075
+ * pre-Phase-4 "ungated" behaviour); otherwise delegates to the tracker.
1076
+ */
1077
+ isWindowOpen(to) {
1078
+ if (this.#windowTracker === void 0) return Promise.resolve(true);
1079
+ return this.#windowTracker.isWindowOpen(to);
1080
+ }
1081
+ async #assertWindowOpen(to) {
1082
+ if (this.#windowTracker === void 0) return;
1083
+ const open = await this.#windowTracker.isWindowOpen(to);
1084
+ if (!open) {
1085
+ throw new WindowClosedError(to);
1086
+ }
1087
+ }
1088
+ /**
1089
+ * @internal — exposed for capability slices that need the bearer
1090
+ * token. Resolves the configured `TokenProvider` exactly once per
1091
+ * call; surfaces provider failures as `AuthenticationError` before
1092
+ * the HTTP request is made.
1093
+ */
1094
+ async _resolveBearerToken() {
1095
+ let resolved;
1096
+ try {
1097
+ resolved = await this.#tokenProvider();
1098
+ } catch (err) {
1099
+ throw new AuthenticationError(
1100
+ "WhatsApp token provider threw an error before the request could be made.",
1101
+ {},
1102
+ { cause: err }
1103
+ );
1104
+ }
1105
+ if (typeof resolved !== "string") {
1106
+ throw new AuthenticationError(
1107
+ `WhatsApp token provider returned a non-string value (typeof ${typeof resolved}).`
1108
+ );
1109
+ }
1110
+ if (resolved.length === 0) {
1111
+ throw new AuthenticationError("WhatsApp token provider returned an empty string.");
1112
+ }
1113
+ return resolved;
1114
+ }
1115
+ /** @internal — exposed for the webhook receiver capability (Phase 3). */
1116
+ _getAppSecret() {
1117
+ return this.#appSecret;
1118
+ }
1119
+ /**
1120
+ * Issue an authenticated Graph API request.
1121
+ *
1122
+ * @internal — public-API surface for sends, templates, etc. lands in
1123
+ * later phases (Phase 2 message-builders, Phase 5 template-management).
1124
+ */
1125
+ request(method, path, body, options) {
1126
+ return request(this, method, path, body, options);
1127
+ }
1128
+ /**
1129
+ * Verify the bearer token via Meta's `/debug_token` endpoint.
1130
+ *
1131
+ * Resolves with the parsed token info; throws `WhatsAppError` if the
1132
+ * token is invalid or the call fails.
1133
+ */
1134
+ healthCheck(options) {
1135
+ return healthCheck(this, options ?? {});
1136
+ }
1137
+ // ───────────── Convenience send methods ─────────────
1138
+ async sendText(input, options) {
1139
+ await this.#assertWindowOpen(input.to);
1140
+ return sendMessage(this, buildText(input), options);
1141
+ }
1142
+ async sendImage(input, options) {
1143
+ await this.#assertWindowOpen(input.to);
1144
+ return sendMessage(this, buildImage(input), options);
1145
+ }
1146
+ async sendVideo(input, options) {
1147
+ await this.#assertWindowOpen(input.to);
1148
+ return sendMessage(this, buildVideo(input), options);
1149
+ }
1150
+ async sendAudio(input, options) {
1151
+ await this.#assertWindowOpen(input.to);
1152
+ return sendMessage(this, buildAudio(input), options);
1153
+ }
1154
+ async sendDocument(input, options) {
1155
+ await this.#assertWindowOpen(input.to);
1156
+ return sendMessage(this, buildDocument(input), options);
1157
+ }
1158
+ async sendSticker(input, options) {
1159
+ await this.#assertWindowOpen(input.to);
1160
+ return sendMessage(this, buildSticker(input), options);
1161
+ }
1162
+ async sendLocation(input, options) {
1163
+ await this.#assertWindowOpen(input.to);
1164
+ return sendMessage(this, buildLocation(input), options);
1165
+ }
1166
+ async sendContacts(input, options) {
1167
+ await this.#assertWindowOpen(input.to);
1168
+ return sendMessage(this, buildContacts(input), options);
1169
+ }
1170
+ async sendInteractive(input, options) {
1171
+ await this.#assertWindowOpen(input.to);
1172
+ return sendMessage(this, buildInteractive(input), options);
1173
+ }
1174
+ /**
1175
+ * Window-exempt: templates are the escape hatch when the window is closed.
1176
+ * Async so any synchronous error from `buildTemplate` (e.g.,
1177
+ * `validateAgainst` mismatch) surfaces as a rejected promise rather
1178
+ * than a synchronous throw.
1179
+ */
1180
+ async sendTemplate(input, options) {
1181
+ return sendMessage(this, buildTemplate(input), options);
1182
+ }
1183
+ /** Window-exempt: authentication templates are the canonical out-of-window send. */
1184
+ async sendAuthTemplate(input, options) {
1185
+ return sendMessage(this, buildAuthTemplate(input), options);
1186
+ }
1187
+ /**
1188
+ * Send a voice note (audio with `voice: true`). Window-gated like
1189
+ * any other free-form media send. Voice notes trigger transcription
1190
+ * support, auto-download, and a "played" status when the recipient
1191
+ * listens.
1192
+ */
1193
+ async sendVoice(input, options) {
1194
+ await this.#assertWindowOpen(input.to);
1195
+ return sendMessage(this, buildVoice(input), options);
1196
+ }
1197
+ /** Window-exempt: carousel sends are template sends. */
1198
+ async sendCarouselTemplate(input, options) {
1199
+ return sendMessage(this, buildCarouselTemplate(input), options);
1200
+ }
1201
+ // ───────────── Template management (Phase 5) ─────────────
1202
+ listTemplates(query, options) {
1203
+ return listTemplates(this, query ?? {}, options);
1204
+ }
1205
+ getTemplate(templateId, options) {
1206
+ return getTemplate(this, templateId, options);
1207
+ }
1208
+ /** Window-exempt: reactions are part of an existing thread. */
1209
+ sendReaction(input, options) {
1210
+ return sendMessage(this, buildReaction(input), options);
1211
+ }
1212
+ /**
1213
+ * Send any pre-built `WhatsAppMessage` payload as a reply to a previous
1214
+ * message identified by its wamid. Sets `context.message_id` and posts.
1215
+ * Window-gated for non-template, non-reaction payloads.
1216
+ */
1217
+ async sendReply(replyTo, payload, options) {
1218
+ if (typeof replyTo !== "string" || replyTo.length === 0) {
1219
+ throw new Error("sendReply: `replyTo` must be a non-empty wamid string.");
1220
+ }
1221
+ if (payload.type !== "template" && payload.type !== "reaction") {
1222
+ await this.#assertWindowOpen(payload.to);
1223
+ }
1224
+ const withContext = { ...payload, context: { message_id: replyTo } };
1225
+ return sendMessage(this, withContext, options);
1226
+ }
1227
+ };
1228
+
1229
+ // src/mock/client.ts
1230
+ var MockWhatsAppClient = class {
1231
+ phoneNumberId;
1232
+ wabaId;
1233
+ graphApiVersion;
1234
+ #windowTracker;
1235
+ #now;
1236
+ #templates;
1237
+ #sentMessages = [];
1238
+ #counter = 0;
1239
+ constructor(options) {
1240
+ this.phoneNumberId = options.phoneNumberId;
1241
+ this.wabaId = options.wabaId;
1242
+ this.graphApiVersion = options.graphApiVersion ?? GRAPH_API_VERSION;
1243
+ this.#windowTracker = options.windowTracker;
1244
+ this.#now = options.now ?? Date.now;
1245
+ this.#templates = options.templates ?? [];
1246
+ }
1247
+ get sentMessages() {
1248
+ return this.#sentMessages;
1249
+ }
1250
+ reset() {
1251
+ this.#sentMessages = [];
1252
+ this.#counter = 0;
1253
+ }
1254
+ isWindowOpen(to) {
1255
+ if (this.#windowTracker === void 0) return Promise.resolve(true);
1256
+ return this.#windowTracker.isWindowOpen(to);
1257
+ }
1258
+ /**
1259
+ * Synthesise a webhook event into a `WebhookReceiver`. Bypasses
1260
+ * signature verification — the receiver dispatches handlers directly.
1261
+ */
1262
+ simulateInbound(receiver, event) {
1263
+ return receiver._dispatchEvents([event]);
1264
+ }
1265
+ // ───────────── send* (mirror WhatsAppClient) ─────────────
1266
+ async sendText(input) {
1267
+ await this.#assertWindowOpen(input.to);
1268
+ return this.#record(buildText(input));
1269
+ }
1270
+ async sendImage(input) {
1271
+ await this.#assertWindowOpen(input.to);
1272
+ return this.#record(buildImage(input));
1273
+ }
1274
+ async sendVideo(input) {
1275
+ await this.#assertWindowOpen(input.to);
1276
+ return this.#record(buildVideo(input));
1277
+ }
1278
+ async sendAudio(input) {
1279
+ await this.#assertWindowOpen(input.to);
1280
+ return this.#record(buildAudio(input));
1281
+ }
1282
+ async sendDocument(input) {
1283
+ await this.#assertWindowOpen(input.to);
1284
+ return this.#record(buildDocument(input));
1285
+ }
1286
+ async sendSticker(input) {
1287
+ await this.#assertWindowOpen(input.to);
1288
+ return this.#record(buildSticker(input));
1289
+ }
1290
+ async sendLocation(input) {
1291
+ await this.#assertWindowOpen(input.to);
1292
+ return this.#record(buildLocation(input));
1293
+ }
1294
+ async sendContacts(input) {
1295
+ await this.#assertWindowOpen(input.to);
1296
+ return this.#record(buildContacts(input));
1297
+ }
1298
+ async sendInteractive(input) {
1299
+ await this.#assertWindowOpen(input.to);
1300
+ return this.#record(buildInteractive(input));
1301
+ }
1302
+ sendTemplate(input) {
1303
+ return Promise.resolve(this.#record(buildTemplate(input)));
1304
+ }
1305
+ sendAuthTemplate(input) {
1306
+ return Promise.resolve(this.#record(buildAuthTemplate(input)));
1307
+ }
1308
+ async sendVoice(input) {
1309
+ await this.#assertWindowOpen(input.to);
1310
+ return this.#record(buildVoice(input));
1311
+ }
1312
+ sendCarouselTemplate(input) {
1313
+ return Promise.resolve(this.#record(buildCarouselTemplate(input)));
1314
+ }
1315
+ sendReaction(input) {
1316
+ return Promise.resolve(this.#record(buildReaction(input)));
1317
+ }
1318
+ async sendReply(replyTo, payload) {
1319
+ if (typeof replyTo !== "string" || replyTo.length === 0) {
1320
+ throw new Error("sendReply: `replyTo` must be a non-empty wamid string.");
1321
+ }
1322
+ if (payload.type !== "template" && payload.type !== "reaction") {
1323
+ await this.#assertWindowOpen(payload.to);
1324
+ }
1325
+ const withContext = { ...payload, context: { message_id: replyTo } };
1326
+ return this.#record(withContext);
1327
+ }
1328
+ // ───────────── template management (Phase 5 mirror) ─────────────
1329
+ listTemplates(query, _options) {
1330
+ let data = this.#templates;
1331
+ if (query?.name !== void 0) data = data.filter((t) => t.name === query.name);
1332
+ if (query?.language !== void 0) data = data.filter((t) => t.language === query.language);
1333
+ if (query?.status !== void 0) data = data.filter((t) => t.status === query.status);
1334
+ if (query?.category !== void 0) data = data.filter((t) => t.category === query.category);
1335
+ if (typeof query?.limit === "number" && query.limit >= 0) data = data.slice(0, query.limit);
1336
+ return Promise.resolve({ data });
1337
+ }
1338
+ getTemplate(templateId, _options) {
1339
+ if (typeof templateId !== "string" || templateId.length === 0) {
1340
+ return Promise.reject(new TypeError("getTemplate: templateId must be a non-empty string."));
1341
+ }
1342
+ const found = this.#templates.find((t) => t.id === templateId);
1343
+ if (found !== void 0) return Promise.resolve(found);
1344
+ return Promise.reject(
1345
+ new TemplateError(
1346
+ this.#templates.length === 0 ? `MockWhatsAppClient has no template registry; pass options.templates or stub via your test harness.` : `Template "${templateId}" not in MockWhatsAppClient registry.`,
1347
+ templateId
1348
+ )
1349
+ );
1350
+ }
1351
+ // ───────────── internals ─────────────
1352
+ async #assertWindowOpen(to) {
1353
+ if (this.#windowTracker === void 0) return;
1354
+ const open = await this.#windowTracker.isWindowOpen(to);
1355
+ if (!open) throw new WindowClosedError(to);
1356
+ }
1357
+ #record(payload) {
1358
+ this.#counter += 1;
1359
+ const wamid = `wamid.mock-${this.#counter}`;
1360
+ this.#sentMessages.push({ wamid, payload, sentAt: this.#now() });
1361
+ return {
1362
+ messaging_product: "whatsapp",
1363
+ contacts: [{ input: payload.to, wa_id: payload.to }],
1364
+ messages: [{ id: wamid }]
1365
+ };
1366
+ }
1367
+ };
1368
+
1369
+ // src/mock/factory.ts
1370
+ function pickWhatsAppClient(options) {
1371
+ if (options.forceMock === true) {
1372
+ return makeMock(options);
1373
+ }
1374
+ if (options.forceReal === true) {
1375
+ return new WhatsAppClient(options);
1376
+ }
1377
+ if (typeof process !== "undefined" && process.env?.["WHATSAPP_MODE"] === "mock") {
1378
+ return makeMock(options);
1379
+ }
1380
+ return new WhatsAppClient(options);
1381
+ }
1382
+ function makeMock(options) {
1383
+ const mockOpts = {
1384
+ phoneNumberId: options.phoneNumberId,
1385
+ wabaId: options.wabaId
1386
+ };
1387
+ if (options.graphApiVersion !== void 0) mockOpts.graphApiVersion = options.graphApiVersion;
1388
+ if (options.windowTracker !== void 0) mockOpts.windowTracker = options.windowTracker;
1389
+ if (options.templates !== void 0) mockOpts.templates = options.templates;
1390
+ return new MockWhatsAppClient(mockOpts);
1391
+ }
1392
+
1393
+ // src/queue/token-bucket.ts
1394
+ var TokenBucket = class {
1395
+ #capacity;
1396
+ #refillPerMs;
1397
+ #now;
1398
+ #tokens;
1399
+ #lastRefillAt;
1400
+ #lastAccessAt;
1401
+ /** Tail of the single-flight wait chain. `undefined` when no waiter is queued. */
1402
+ #waitTail;
1403
+ constructor(options) {
1404
+ if (!Number.isFinite(options.capacity) || options.capacity <= 0) {
1405
+ throw new RangeError("TokenBucket: capacity must be a positive finite number.");
1406
+ }
1407
+ if (!Number.isFinite(options.refillPerMs) || options.refillPerMs <= 0) {
1408
+ throw new RangeError("TokenBucket: refillPerMs must be a positive finite number.");
1409
+ }
1410
+ this.#capacity = options.capacity;
1411
+ this.#refillPerMs = options.refillPerMs;
1412
+ this.#now = options.now ?? Date.now;
1413
+ this.#tokens = options.capacity;
1414
+ const t = this.#now();
1415
+ this.#lastRefillAt = t;
1416
+ this.#lastAccessAt = t;
1417
+ }
1418
+ /** Current token count after applying any pending refill. Pure read. */
1419
+ peek() {
1420
+ this.#refill();
1421
+ return this.#tokens;
1422
+ }
1423
+ /** Last access timestamp (epoch ms). Used by `BucketMap` eviction. */
1424
+ lastAccessAt() {
1425
+ return this.#lastAccessAt;
1426
+ }
1427
+ /** Whether the bucket currently has its full capacity worth of tokens. */
1428
+ isFull() {
1429
+ return this.peek() >= this.#capacity;
1430
+ }
1431
+ /**
1432
+ * Acquire `count` tokens. Resolves once the tokens are reserved
1433
+ * for this caller. Concurrent acquires queue behind the previous
1434
+ * waiter so the refill is consumed in arrival order.
1435
+ */
1436
+ acquire(count = 1) {
1437
+ if (!Number.isFinite(count) || count <= 0) {
1438
+ return Promise.reject(new RangeError("TokenBucket.acquire: count must be > 0."));
1439
+ }
1440
+ if (count > this.#capacity) {
1441
+ return Promise.reject(
1442
+ new RangeError(
1443
+ `TokenBucket.acquire: count (${count}) exceeds bucket capacity (${this.#capacity}).`
1444
+ )
1445
+ );
1446
+ }
1447
+ this.#lastAccessAt = this.#now();
1448
+ if (this.#waitTail === void 0) {
1449
+ this.#refill();
1450
+ if (this.#tokens >= count) {
1451
+ this.#tokens -= count;
1452
+ return Promise.resolve();
1453
+ }
1454
+ }
1455
+ const tail = this.#waitTail ?? Promise.resolve();
1456
+ const ours = tail.then(() => this.#consumeAfterWait(count));
1457
+ this.#waitTail = ours.catch(() => void 0);
1458
+ return ours;
1459
+ }
1460
+ async #consumeAfterWait(count) {
1461
+ for (; ; ) {
1462
+ this.#refill();
1463
+ if (this.#tokens >= count) {
1464
+ this.#tokens -= count;
1465
+ if (this.#waitTail !== void 0) {
1466
+ await Promise.resolve();
1467
+ this.#maybeClearTail();
1468
+ }
1469
+ return;
1470
+ }
1471
+ const deficit = count - this.#tokens;
1472
+ const waitMs = Math.max(1, Math.ceil(deficit / this.#refillPerMs));
1473
+ await sleep(waitMs);
1474
+ }
1475
+ }
1476
+ #maybeClearTail() {
1477
+ this.#waitTail = void 0;
1478
+ }
1479
+ #refill() {
1480
+ const now = this.#now();
1481
+ const elapsed = Math.max(0, now - this.#lastRefillAt);
1482
+ if (elapsed === 0) return;
1483
+ this.#tokens = Math.min(this.#capacity, this.#tokens + elapsed * this.#refillPerMs);
1484
+ this.#lastRefillAt = now;
1485
+ }
1486
+ };
1487
+ function sleep(ms) {
1488
+ return new Promise((resolve) => {
1489
+ setTimeout(resolve, ms);
1490
+ });
1491
+ }
1492
+
1493
+ // src/queue/bucket-map.ts
1494
+ var BucketMap = class {
1495
+ #factory;
1496
+ #now;
1497
+ #evictAfterMs;
1498
+ #buckets = /* @__PURE__ */ new Map();
1499
+ constructor(options) {
1500
+ const evictAfterMs = options.evictAfterMs ?? 6e4;
1501
+ if (!Number.isFinite(evictAfterMs) || evictAfterMs <= 0) {
1502
+ throw new RangeError("BucketMap: evictAfterMs must be a positive finite number.");
1503
+ }
1504
+ this.#evictAfterMs = evictAfterMs;
1505
+ this.#now = options.now ?? Date.now;
1506
+ this.#factory = () => new TokenBucket({
1507
+ capacity: options.capacity,
1508
+ refillPerMs: options.refillPerMs,
1509
+ ...options.now !== void 0 ? { now: options.now } : {}
1510
+ });
1511
+ }
1512
+ acquire(key, count = 1) {
1513
+ this.#evictStale();
1514
+ let bucket = this.#buckets.get(key);
1515
+ if (bucket === void 0) {
1516
+ bucket = this.#factory();
1517
+ this.#buckets.set(key, bucket);
1518
+ }
1519
+ return bucket.acquire(count);
1520
+ }
1521
+ size() {
1522
+ return this.#buckets.size;
1523
+ }
1524
+ #evictStale() {
1525
+ const cutoff = this.#now() - this.#evictAfterMs;
1526
+ for (const [key, bucket] of this.#buckets) {
1527
+ if (bucket.isFull() && bucket.lastAccessAt() < cutoff) {
1528
+ this.#buckets.delete(key);
1529
+ }
1530
+ }
1531
+ }
1532
+ };
1533
+
1534
+ // src/queue/with-rate-limit.ts
1535
+ var DEFAULT_PER_PAIR = { messages: 1, per: 6e3 };
1536
+ var DEFAULT_PER_WABA = { mps: 80 };
1537
+ function withRateLimit(client, options = {}) {
1538
+ const perPair = options.perPair ?? DEFAULT_PER_PAIR;
1539
+ const perWaba = options.perWaba ?? DEFAULT_PER_WABA;
1540
+ const perPairBuckets = new BucketMap({
1541
+ capacity: perPair.messages,
1542
+ refillPerMs: perPair.messages / perPair.per,
1543
+ ...options.now !== void 0 ? { now: options.now } : {},
1544
+ ...options.evictAfterMs !== void 0 ? { evictAfterMs: options.evictAfterMs } : {}
1545
+ });
1546
+ const perWabaBuckets = new BucketMap({
1547
+ capacity: perWaba.mps,
1548
+ refillPerMs: perWaba.mps / 1e3,
1549
+ ...options.now !== void 0 ? { now: options.now } : {},
1550
+ ...options.evictAfterMs !== void 0 ? { evictAfterMs: options.evictAfterMs } : {}
1551
+ });
1552
+ async function gate(to) {
1553
+ const pairKey = `${client.phoneNumberId}:${to}`;
1554
+ const wabaKey = client.wabaId;
1555
+ const start = (options.now ?? Date.now)();
1556
+ const hashedRecipient = await hashPhoneNumberId(to);
1557
+ await withSpan(
1558
+ "whatsapp.queue.acquire",
1559
+ async () => {
1560
+ await perPairBuckets.acquire(pairKey);
1561
+ await perWabaBuckets.acquire(wabaKey);
1562
+ const end = (options.now ?? Date.now)();
1563
+ void end;
1564
+ void start;
1565
+ },
1566
+ {
1567
+ "whatsapp.queue.pair_recipient": hashedRecipient,
1568
+ "whatsapp.queue.waba_id": await hashPhoneNumberId(client.wabaId)
1569
+ }
1570
+ );
1571
+ }
1572
+ const wrapped = {
1573
+ get phoneNumberId() {
1574
+ return client.phoneNumberId;
1575
+ },
1576
+ get wabaId() {
1577
+ return client.wabaId;
1578
+ },
1579
+ get graphApiVersion() {
1580
+ return client.graphApiVersion;
1581
+ },
1582
+ isWindowOpen: (to) => client.isWindowOpen(to),
1583
+ sendText: async (input, opts) => {
1584
+ await gate(input.to);
1585
+ return client.sendText(input, opts);
1586
+ },
1587
+ sendImage: async (input, opts) => {
1588
+ await gate(input.to);
1589
+ return client.sendImage(input, opts);
1590
+ },
1591
+ sendVideo: async (input, opts) => {
1592
+ await gate(input.to);
1593
+ return client.sendVideo(input, opts);
1594
+ },
1595
+ sendAudio: async (input, opts) => {
1596
+ await gate(input.to);
1597
+ return client.sendAudio(input, opts);
1598
+ },
1599
+ sendDocument: async (input, opts) => {
1600
+ await gate(input.to);
1601
+ return client.sendDocument(input, opts);
1602
+ },
1603
+ sendSticker: async (input, opts) => {
1604
+ await gate(input.to);
1605
+ return client.sendSticker(input, opts);
1606
+ },
1607
+ sendLocation: async (input, opts) => {
1608
+ await gate(input.to);
1609
+ return client.sendLocation(input, opts);
1610
+ },
1611
+ sendContacts: async (input, opts) => {
1612
+ await gate(input.to);
1613
+ return client.sendContacts(input, opts);
1614
+ },
1615
+ sendInteractive: async (input, opts) => {
1616
+ await gate(input.to);
1617
+ return client.sendInteractive(input, opts);
1618
+ },
1619
+ sendTemplate: async (input, opts) => {
1620
+ await gate(input.to);
1621
+ return client.sendTemplate(input, opts);
1622
+ },
1623
+ sendAuthTemplate: async (input, opts) => {
1624
+ await gate(input.to);
1625
+ return client.sendAuthTemplate(input, opts);
1626
+ },
1627
+ sendVoice: async (input, opts) => {
1628
+ await gate(input.to);
1629
+ return client.sendVoice(input, opts);
1630
+ },
1631
+ sendCarouselTemplate: async (input, opts) => {
1632
+ await gate(input.to);
1633
+ return client.sendCarouselTemplate(input, opts);
1634
+ },
1635
+ sendReaction: async (input, opts) => {
1636
+ await gate(input.to);
1637
+ return client.sendReaction(input, opts);
1638
+ },
1639
+ sendReply: async (replyTo, payload, opts) => {
1640
+ await gate(payload.to);
1641
+ return client.sendReply(replyTo, payload, opts);
1642
+ },
1643
+ listTemplates: (query, opts) => client.listTemplates(query, opts),
1644
+ getTemplate: (templateId, opts) => client.getTemplate(templateId, opts)
1645
+ };
1646
+ return wrapped;
1647
+ }
1648
+
1649
+ // src/webhooks/dedupe.ts
1650
+ var WebhookDeduper = class {
1651
+ #storage;
1652
+ #ttlMs;
1653
+ constructor(storage, ttlMs = WEBHOOK_DEDUPE_TTL_MS) {
1654
+ this.#storage = storage;
1655
+ this.#ttlMs = ttlMs;
1656
+ }
1657
+ markIfNew(eventKey) {
1658
+ return this.#storage.setIfAbsent(eventKey, true, this.#ttlMs);
1659
+ }
1660
+ };
1661
+
1662
+ // src/webhooks/handshake.ts
1663
+ var encoder2 = new TextEncoder();
1664
+ function verifyHandshake({
1665
+ mode,
1666
+ verifyToken,
1667
+ challenge,
1668
+ expectedToken
1669
+ }) {
1670
+ if (mode !== "subscribe") return null;
1671
+ if (typeof verifyToken !== "string" || typeof expectedToken !== "string") return null;
1672
+ if (verifyToken.length === 0 || expectedToken.length === 0) return null;
1673
+ if (verifyToken.length !== expectedToken.length) return null;
1674
+ const a = encoder2.encode(verifyToken);
1675
+ const b = encoder2.encode(expectedToken);
1676
+ if (a.length !== b.length) return null;
1677
+ if (!constantTimeEqual(a, b)) return null;
1678
+ return typeof challenge === "string" ? challenge : null;
1679
+ }
1680
+ function constantTimeEqual(a, b) {
1681
+ if (a.length !== b.length) return false;
1682
+ let diff = 0;
1683
+ for (let i = 0; i < a.length; i++) {
1684
+ diff |= a[i] ^ b[i];
1685
+ }
1686
+ return diff === 0;
1687
+ }
1688
+
1689
+ // src/webhooks/parser.ts
1690
+ var KNOWN_INCOMING_KINDS = /* @__PURE__ */ new Set([
1691
+ "text",
1692
+ "image",
1693
+ "video",
1694
+ "audio",
1695
+ "document",
1696
+ "sticker",
1697
+ "location",
1698
+ "contacts",
1699
+ "button",
1700
+ "order",
1701
+ "reaction",
1702
+ "system",
1703
+ "unsupported",
1704
+ "interactive"
1705
+ ]);
1706
+ function parseWebhookPayload(body) {
1707
+ const envelope = body;
1708
+ if (envelope === null || envelope === void 0 || typeof envelope !== "object") return [];
1709
+ const entries = Array.isArray(envelope.entry) ? envelope.entry : [];
1710
+ if (entries.length === 0) return [];
1711
+ const out = [];
1712
+ for (const entry of entries) {
1713
+ if (entry === null || typeof entry !== "object") continue;
1714
+ const wabaId = typeof entry.id === "string" ? entry.id : "";
1715
+ const changes = Array.isArray(entry.changes) ? entry.changes : [];
1716
+ for (const change of changes) {
1717
+ if (change === null || typeof change !== "object") continue;
1718
+ const field = typeof change.field === "string" ? change.field : "";
1719
+ const value = change.value ?? {};
1720
+ out.push(...parseChange(wabaId, field, value));
1721
+ }
1722
+ }
1723
+ return out;
1724
+ }
1725
+ function parseChange(wabaId, field, value) {
1726
+ const metadata = value["metadata"] ?? {};
1727
+ const phoneNumberId = typeof metadata["phone_number_id"] === "string" ? metadata["phone_number_id"] : void 0;
1728
+ const displayPhoneNumber = typeof metadata["display_phone_number"] === "string" ? metadata["display_phone_number"] : void 0;
1729
+ const baseTimestamp = Date.now();
1730
+ switch (field) {
1731
+ case "messages":
1732
+ return parseMessagesField(value, {
1733
+ wabaId,
1734
+ phoneNumberId,
1735
+ displayPhoneNumber,
1736
+ baseTimestamp
1737
+ });
1738
+ case "message_template_status_update":
1739
+ return [parseTemplateStatus(value, wabaId, baseTimestamp)];
1740
+ case "message_template_quality_update":
1741
+ return [parseTemplateQuality(value, wabaId, baseTimestamp)];
1742
+ case "template_category_update":
1743
+ return [parseTemplateCategory(value, wabaId, baseTimestamp)];
1744
+ case "phone_number_quality_update":
1745
+ return [parsePhoneNumberQuality(value, wabaId, phoneNumberId, baseTimestamp)];
1746
+ case "account_alerts":
1747
+ return [parseAccountAlert(value, wabaId, baseTimestamp)];
1748
+ case "account_review_update":
1749
+ return [parseAccountReview(value, wabaId, baseTimestamp)];
1750
+ default: {
1751
+ const ev = {
1752
+ kind: "unknown",
1753
+ wabaId,
1754
+ timestamp: baseTimestamp,
1755
+ field,
1756
+ value
1757
+ };
1758
+ return [ev];
1759
+ }
1760
+ }
1761
+ }
1762
+ function parseMessagesField(value, ctx) {
1763
+ const out = [];
1764
+ const messages = Array.isArray(value["messages"]) ? value["messages"] : [];
1765
+ for (const m of messages) {
1766
+ out.push(parseInboundMessage(m, ctx));
1767
+ }
1768
+ const statuses = Array.isArray(value["statuses"]) ? value["statuses"] : [];
1769
+ for (const s of statuses) {
1770
+ out.push(parseStatus(s, ctx));
1771
+ }
1772
+ return out;
1773
+ }
1774
+ function parseInboundMessage(m, ctx) {
1775
+ const id = typeof m["id"] === "string" ? m["id"] : "";
1776
+ const from = typeof m["from"] === "string" ? m["from"] : "";
1777
+ const rawType = typeof m["type"] === "string" ? m["type"] : "unsupported";
1778
+ const type = normaliseIncomingType(rawType, m);
1779
+ const timestamp = parseTimestamp(m["timestamp"]) ?? ctx.baseTimestamp;
1780
+ const context = m["context"];
1781
+ const contextId = context && typeof context["id"] === "string" ? context["id"] : void 0;
1782
+ const ev = {
1783
+ kind: "message",
1784
+ wabaId: ctx.wabaId,
1785
+ timestamp,
1786
+ id,
1787
+ from,
1788
+ type,
1789
+ body: m
1790
+ };
1791
+ if (ctx.phoneNumberId !== void 0) ev.phoneNumberId = ctx.phoneNumberId;
1792
+ if (ctx.displayPhoneNumber !== void 0) ev.displayPhoneNumber = ctx.displayPhoneNumber;
1793
+ if (contextId !== void 0) ev.contextId = contextId;
1794
+ return ev;
1795
+ }
1796
+ function normaliseIncomingType(rawType, m) {
1797
+ if (rawType === "interactive") {
1798
+ const interactive = m["interactive"];
1799
+ const subType = interactive && typeof interactive["type"] === "string" ? interactive["type"] : void 0;
1800
+ if (subType === "button_reply") return "interactive_button_reply";
1801
+ if (subType === "list_reply") return "interactive_list_reply";
1802
+ return "unsupported";
1803
+ }
1804
+ if (KNOWN_INCOMING_KINDS.has(rawType)) {
1805
+ return rawType;
1806
+ }
1807
+ return "unsupported";
1808
+ }
1809
+ function parseStatus(s, ctx) {
1810
+ const id = typeof s["id"] === "string" ? s["id"] : "";
1811
+ const status = typeof s["status"] === "string" ? s["status"] : "unknown";
1812
+ const timestamp = parseTimestamp(s["timestamp"]) ?? ctx.baseTimestamp;
1813
+ const recipientId = typeof s["recipient_id"] === "string" ? s["recipient_id"] : void 0;
1814
+ const conversation = s["conversation"];
1815
+ const conversationId = conversation && typeof conversation["id"] === "string" ? conversation["id"] : void 0;
1816
+ const pricing = s["pricing"];
1817
+ const pricingCategory = pricing && typeof pricing["category"] === "string" ? pricing["category"] : void 0;
1818
+ const errors = Array.isArray(s["errors"]) ? s["errors"] : void 0;
1819
+ const ev = {
1820
+ kind: "status",
1821
+ wabaId: ctx.wabaId,
1822
+ timestamp,
1823
+ id,
1824
+ status
1825
+ };
1826
+ if (ctx.phoneNumberId !== void 0) ev.phoneNumberId = ctx.phoneNumberId;
1827
+ if (ctx.displayPhoneNumber !== void 0) ev.displayPhoneNumber = ctx.displayPhoneNumber;
1828
+ if (recipientId !== void 0) ev.recipientId = recipientId;
1829
+ if (conversationId !== void 0) ev.conversationId = conversationId;
1830
+ if (pricingCategory !== void 0) ev.pricingCategory = pricingCategory;
1831
+ if (errors !== void 0) {
1832
+ ev.errors = errors;
1833
+ }
1834
+ return ev;
1835
+ }
1836
+ function parseTemplateStatus(value, wabaId, ts) {
1837
+ const ev = {
1838
+ kind: "template_status",
1839
+ wabaId,
1840
+ timestamp: ts,
1841
+ templateId: pickString(value, "message_template_id") ?? pickString(value, "template_id") ?? "",
1842
+ event: pickString(value, "event") ?? ""
1843
+ };
1844
+ const name = pickString(value, "message_template_name") ?? pickString(value, "template_name");
1845
+ if (name !== void 0) ev.templateName = name;
1846
+ const language = pickString(value, "message_template_language") ?? pickString(value, "language");
1847
+ if (language !== void 0) ev.language = language;
1848
+ const reason = pickString(value, "reason");
1849
+ if (reason !== void 0) ev.reason = reason;
1850
+ return ev;
1851
+ }
1852
+ function parseTemplateQuality(value, wabaId, ts) {
1853
+ const ev = {
1854
+ kind: "template_quality",
1855
+ wabaId,
1856
+ timestamp: ts,
1857
+ templateId: pickString(value, "message_template_id") ?? "",
1858
+ newQualityScore: pickString(value, "new_quality_score") ?? ""
1859
+ };
1860
+ const previous = pickString(value, "previous_quality_score");
1861
+ if (previous !== void 0) ev.previousQualityScore = previous;
1862
+ const name = pickString(value, "message_template_name");
1863
+ if (name !== void 0) ev.templateName = name;
1864
+ return ev;
1865
+ }
1866
+ function parseTemplateCategory(value, wabaId, ts) {
1867
+ const ev = {
1868
+ kind: "template_category",
1869
+ wabaId,
1870
+ timestamp: ts,
1871
+ templateId: pickString(value, "message_template_id") ?? "",
1872
+ newCategory: pickString(value, "new_category") ?? ""
1873
+ };
1874
+ const previous = pickString(value, "previous_category");
1875
+ if (previous !== void 0) ev.previousCategory = previous;
1876
+ const name = pickString(value, "message_template_name");
1877
+ if (name !== void 0) ev.templateName = name;
1878
+ return ev;
1879
+ }
1880
+ function parsePhoneNumberQuality(value, wabaId, phoneNumberId, ts) {
1881
+ const ev = {
1882
+ kind: "phone_number_quality",
1883
+ wabaId,
1884
+ timestamp: ts,
1885
+ newQualityScore: pickString(value, "new_quality_score") ?? ""
1886
+ };
1887
+ if (phoneNumberId !== void 0) ev.phoneNumberId = phoneNumberId;
1888
+ return ev;
1889
+ }
1890
+ function parseAccountAlert(value, wabaId, ts) {
1891
+ const ev = {
1892
+ kind: "account_alert",
1893
+ wabaId,
1894
+ timestamp: ts,
1895
+ raw: value
1896
+ };
1897
+ const severity = pickString(value, "alert_severity");
1898
+ if (severity !== void 0) ev.alertSeverity = severity;
1899
+ const type = pickString(value, "alert_type");
1900
+ if (type !== void 0) ev.alertType = type;
1901
+ return ev;
1902
+ }
1903
+ function parseAccountReview(value, wabaId, ts) {
1904
+ const decision = pickString(value, "decision") ?? "";
1905
+ return {
1906
+ kind: "account_review",
1907
+ wabaId,
1908
+ timestamp: ts,
1909
+ decision,
1910
+ raw: value
1911
+ };
1912
+ }
1913
+ function pickString(obj, key) {
1914
+ const v = obj[key];
1915
+ return typeof v === "string" ? v : void 0;
1916
+ }
1917
+ function parseTimestamp(raw) {
1918
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw * (raw > 1e12 ? 1 : 1e3);
1919
+ if (typeof raw === "string") {
1920
+ const asNum = Number(raw);
1921
+ if (Number.isFinite(asNum)) return asNum * (asNum > 1e12 ? 1 : 1e3);
1922
+ }
1923
+ return void 0;
1924
+ }
1925
+
1926
+ // src/storage/index.ts
1927
+ var InMemoryStorage = class {
1928
+ #map = /* @__PURE__ */ new Map();
1929
+ #now;
1930
+ constructor(options = {}) {
1931
+ this.#now = options.now ?? Date.now;
1932
+ }
1933
+ get(key) {
1934
+ const entry = this.#map.get(key);
1935
+ if (entry === void 0) {
1936
+ return Promise.resolve(void 0);
1937
+ }
1938
+ if (this.#now() >= entry.expiresAt) {
1939
+ this.#map.delete(key);
1940
+ return Promise.resolve(void 0);
1941
+ }
1942
+ return Promise.resolve(entry.value);
1943
+ }
1944
+ set(key, value, ttlMs) {
1945
+ const expiresAt = ttlMs <= 0 ? Number.POSITIVE_INFINITY : this.#now() + ttlMs;
1946
+ this.#map.set(key, { value, expiresAt });
1947
+ return Promise.resolve();
1948
+ }
1949
+ setIfAbsent(key, value, ttlMs) {
1950
+ const existing = this.#map.get(key);
1951
+ if (existing !== void 0 && this.#now() < existing.expiresAt) {
1952
+ return Promise.resolve(false);
1953
+ }
1954
+ const expiresAt = ttlMs <= 0 ? Number.POSITIVE_INFINITY : this.#now() + ttlMs;
1955
+ this.#map.set(key, { value, expiresAt });
1956
+ return Promise.resolve(true);
1957
+ }
1958
+ delete(key) {
1959
+ this.#map.delete(key);
1960
+ return Promise.resolve();
1961
+ }
1962
+ /** Test-only — returns the current map size (including expired entries). */
1963
+ _rawSize() {
1964
+ return this.#map.size;
1965
+ }
1966
+ };
1967
+
1968
+ // src/webhooks/signature.ts
1969
+ var HEX_PREFIX = "sha256=";
1970
+ var HEX_RE = /^[0-9a-fA-F]+$/;
1971
+ var encoder3 = new TextEncoder();
1972
+ async function verifySignature({
1973
+ rawBody,
1974
+ signatureHeader,
1975
+ appSecret
1976
+ }) {
1977
+ if (typeof signatureHeader !== "string" || signatureHeader.length === 0) {
1978
+ return false;
1979
+ }
1980
+ const provided = signatureHeader.startsWith(HEX_PREFIX) ? signatureHeader.slice(HEX_PREFIX.length) : signatureHeader;
1981
+ if (!HEX_RE.test(provided) || provided.length % 2 !== 0) {
1982
+ return false;
1983
+ }
1984
+ const expectedBytes = await computeSignatureBytes(rawBody, appSecret);
1985
+ if (provided.length !== expectedBytes.length * 2) {
1986
+ return false;
1987
+ }
1988
+ const providedBytes = hexToBytes(provided);
1989
+ return constantTimeEqual2(providedBytes, expectedBytes);
1990
+ }
1991
+ async function verifySignatureOrThrow(input) {
1992
+ const ok = await verifySignature(input);
1993
+ if (!ok) {
1994
+ throw new WebhookSignatureError();
1995
+ }
1996
+ }
1997
+ async function computeSignature(rawBody, appSecret) {
1998
+ return bytesToHex(await computeSignatureBytes(rawBody, appSecret));
1999
+ }
2000
+ async function computeSignatureBytes(rawBody, appSecret) {
2001
+ const bodyBytes = typeof rawBody === "string" ? encoder3.encode(rawBody) : toUint8Array(rawBody);
2002
+ const key = await crypto.subtle.importKey(
2003
+ "raw",
2004
+ encoder3.encode(appSecret),
2005
+ { name: "HMAC", hash: "SHA-256" },
2006
+ false,
2007
+ ["sign"]
2008
+ );
2009
+ const signature = await crypto.subtle.sign("HMAC", key, bodyBytes);
2010
+ return new Uint8Array(signature);
2011
+ }
2012
+ function toUint8Array(buf) {
2013
+ return buf instanceof Uint8Array ? buf : new Uint8Array(buf);
2014
+ }
2015
+ function hexToBytes(hex) {
2016
+ const lower = hex.toLowerCase();
2017
+ const out = new Uint8Array(lower.length / 2);
2018
+ for (let i = 0; i < out.length; i++) {
2019
+ out[i] = parseInt(lower.slice(i * 2, i * 2 + 2), 16);
2020
+ }
2021
+ return out;
2022
+ }
2023
+ function bytesToHex(bytes) {
2024
+ let out = "";
2025
+ for (let i = 0; i < bytes.length; i++) {
2026
+ out += bytes[i].toString(16).padStart(2, "0");
2027
+ }
2028
+ return out;
2029
+ }
2030
+ function constantTimeEqual2(a, b) {
2031
+ if (a.length !== b.length) return false;
2032
+ let diff = 0;
2033
+ for (let i = 0; i < a.length; i++) {
2034
+ diff |= a[i] ^ b[i];
2035
+ }
2036
+ return diff === 0;
2037
+ }
2038
+
2039
+ // src/webhooks/receiver.ts
2040
+ var WebhookReceiver = class {
2041
+ #appSecret;
2042
+ #verifyToken;
2043
+ #deduper;
2044
+ #onError;
2045
+ #handlers = /* @__PURE__ */ new Map();
2046
+ constructor(options) {
2047
+ this.#appSecret = options.appSecret;
2048
+ this.#verifyToken = options.verifyToken;
2049
+ this.#deduper = new WebhookDeduper(
2050
+ options.storage ?? new InMemoryStorage(),
2051
+ options.dedupeTtlMs
2052
+ );
2053
+ this.#onError = options.onError;
2054
+ }
2055
+ on(kind, handler) {
2056
+ let set = this.#handlers.get(kind);
2057
+ if (set === void 0) {
2058
+ set = /* @__PURE__ */ new Set();
2059
+ this.#handlers.set(kind, set);
2060
+ }
2061
+ set.add(handler);
2062
+ return this;
2063
+ }
2064
+ off(kind, handler) {
2065
+ this.#handlers.get(kind)?.delete(handler);
2066
+ return this;
2067
+ }
2068
+ verify(rawBody, signatureHeader) {
2069
+ return verifySignature({ rawBody, signatureHeader, appSecret: this.#appSecret });
2070
+ }
2071
+ handleVerifyRequest(input) {
2072
+ const challenge = verifyHandshake({
2073
+ mode: input.mode,
2074
+ verifyToken: input.verifyToken,
2075
+ challenge: input.challenge,
2076
+ expectedToken: this.#verifyToken
2077
+ });
2078
+ if (challenge === null) return { status: 403 };
2079
+ return { status: 200, body: challenge };
2080
+ }
2081
+ async handlePayload(rawBody, signatureHeader, parsedBody) {
2082
+ if (!await this.verify(rawBody, signatureHeader)) {
2083
+ return { status: 401 };
2084
+ }
2085
+ const events = parseWebhookPayload(parsedBody);
2086
+ return { status: 200, dispatchPromise: this.#dispatch(events) };
2087
+ }
2088
+ /** @internal — used by mock-mode (Phase 6) to inject synthetic events. */
2089
+ _dispatchEvents(events) {
2090
+ return this.#dispatch(events);
2091
+ }
2092
+ async #dispatch(events) {
2093
+ const tasks = [];
2094
+ for (const event of events) {
2095
+ const dedupeKey = makeDedupeKey(event);
2096
+ if (dedupeKey !== void 0) {
2097
+ const fresh = await this.#deduper.markIfNew(dedupeKey);
2098
+ if (!fresh) continue;
2099
+ }
2100
+ const handlers = this.#handlers.get(event.kind);
2101
+ if (handlers) {
2102
+ for (const h of handlers) {
2103
+ tasks.push(this.#runHandler(h, event));
2104
+ }
2105
+ }
2106
+ }
2107
+ await Promise.allSettled(tasks);
2108
+ }
2109
+ async #runHandler(h, event) {
2110
+ try {
2111
+ await withSpan(
2112
+ "whatsapp.webhook.dispatch",
2113
+ () => Promise.resolve(h(event)),
2114
+ await spanAttributes(event)
2115
+ );
2116
+ } catch (err) {
2117
+ this.#onError?.(err, event);
2118
+ const errorHandlers = this.#handlers.get("error");
2119
+ if (errorHandlers) {
2120
+ for (const eh of errorHandlers) {
2121
+ try {
2122
+ await eh(err, event);
2123
+ } catch {
2124
+ }
2125
+ }
2126
+ }
2127
+ }
2128
+ }
2129
+ };
2130
+ async function spanAttributes(event) {
2131
+ const attrs = {
2132
+ "whatsapp.event.kind": event.kind,
2133
+ "whatsapp.waba_id": await hashPhoneNumberId(event.wabaId)
2134
+ };
2135
+ if (event.phoneNumberId !== void 0) {
2136
+ attrs["whatsapp.phone_number_id"] = await hashPhoneNumberId(event.phoneNumberId);
2137
+ }
2138
+ if (event.kind === "message" || event.kind === "status") {
2139
+ attrs["whatsapp.event.id"] = event.id;
2140
+ }
2141
+ return attrs;
2142
+ }
2143
+ function makeDedupeKey(event) {
2144
+ switch (event.kind) {
2145
+ case "message":
2146
+ return `msg:${event.id}`;
2147
+ case "status":
2148
+ return `status:${event.id}:${event.status}`;
2149
+ default:
2150
+ return void 0;
2151
+ }
2152
+ }
2153
+
2154
+ // src/window/tracker.ts
2155
+ var WindowTracker = class {
2156
+ phoneNumberId;
2157
+ #storage;
2158
+ #ttlMs;
2159
+ constructor(options) {
2160
+ this.phoneNumberId = options.phoneNumberId;
2161
+ this.#storage = options.storage;
2162
+ this.#ttlMs = options.ttlMs ?? WINDOW_TTL_MS;
2163
+ }
2164
+ get ttlMs() {
2165
+ return this.#ttlMs;
2166
+ }
2167
+ notifyInbound(customerWaId, _atMs) {
2168
+ return this.#storage.set(this.#key(customerWaId), true, this.#ttlMs);
2169
+ }
2170
+ async isWindowOpen(customerWaId) {
2171
+ const seen = await this.#storage.get(this.#key(customerWaId));
2172
+ return seen === true;
2173
+ }
2174
+ /** @internal — exposed so consumers can clear a window after a hard error. */
2175
+ clear(customerWaId) {
2176
+ return this.#storage.delete(this.#key(customerWaId));
2177
+ }
2178
+ #key(customerWaId) {
2179
+ return `window:${this.phoneNumberId}:${customerWaId}`;
2180
+ }
2181
+ };
2182
+
2183
+ export { AuthenticationError, BucketMap, CapabilityError, DEFAULT_RETRY_POLICY, GRAPH_API_VERSION, InMemoryStorage, META_GRAPH_BASE_URL, MissingCredentialsError, MockModeError, MockWhatsAppClient, PermissionError, RateLimitError, TemplateError, TokenBucket, TransientHttpError, WEBHOOK_ACK_DEADLINE_MS, WEBHOOK_DEDUPE_TTL_MS, WINDOW_TTL_MS, WebhookDeduper, WebhookReceiver, WebhookSignatureError, WhatsAppClient, WhatsAppError, WindowClosedError, WindowTracker, buildAudio, buildAuthTemplate, buildCarouselTemplate, buildContacts, buildDocument, buildImage, buildInteractive, buildInteractiveButton, buildInteractiveCtaUrl, buildInteractiveList, buildLocation, buildReaction, buildSticker, buildTemplate, buildText, buildVideo, buildVoice, computeSignature, countTemplatePlaceholders, getTemplate, getTracer, hashPhoneNumberId, listTemplates, parseWebhookPayload, pickWhatsAppClient, sendMessage, setRedactSalt, validateTemplateSend, verifyHandshake, verifySignature, verifySignatureOrThrow, withRateLimit, withSpan };