@noodleseed/one 0.175.1 → 0.177.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.
Files changed (72) hide show
  1. package/dist/commands/knowledge.d.ts.map +1 -1
  2. package/dist/commands/knowledge.js +4 -7
  3. package/dist/commands/knowledge.js.map +1 -1
  4. package/dist/commands/solutions-conversations.d.ts +6 -0
  5. package/dist/commands/solutions-conversations.d.ts.map +1 -0
  6. package/dist/commands/solutions-conversations.js +137 -0
  7. package/dist/commands/solutions-conversations.js.map +1 -0
  8. package/dist/commands/solutions-ops.d.ts.map +1 -1
  9. package/dist/commands/solutions-ops.js +3 -0
  10. package/dist/commands/solutions-ops.js.map +1 -1
  11. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  12. package/dist/commands/whatsapp-ops.js +12 -2
  13. package/dist/commands/whatsapp-ops.js.map +1 -1
  14. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  15. package/dist/commands/whatsapp-output.js +1 -0
  16. package/dist/commands/whatsapp-output.js.map +1 -1
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +14 -8
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +11 -2
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +14 -5
  20. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +5 -3
  21. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +97 -0
  22. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +2 -0
  23. package/node_modules/@noodle-borg/knowledge-operations/dist/executor.d.ts +3 -5
  24. package/node_modules/@noodle-borg/knowledge-operations/dist/executor.js +2 -7
  25. package/node_modules/@noodle-borg/knowledge-operations/dist/portable.d.ts +1 -1
  26. package/node_modules/@noodle-borg/knowledge-operations/dist/portable.js +1 -1
  27. package/node_modules/@noodle-borg/knowledge-operations/dist/publication.d.ts +0 -1
  28. package/node_modules/@noodle-borg/knowledge-operations/dist/publication.js +1 -10
  29. package/node_modules/@noodle-borg/knowledge-operations/dist/routes.d.ts +0 -4
  30. package/node_modules/@noodle-borg/knowledge-operations/dist/routes.js +1 -17
  31. package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.d.ts +1 -2
  32. package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.js +1 -3
  33. package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.d.ts +4 -4
  34. package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.js +3 -8
  35. package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.d.ts +1 -2
  36. package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.js +0 -14
  37. package/node_modules/@noodle-borg/protocol/dist/knowledge-tool.js +3 -5
  38. package/node_modules/@noodle-borg/runtime/dist/execute.d.ts +2 -3
  39. package/node_modules/@noodle-borg/service/dist/assistant-composition.js +9 -0
  40. package/node_modules/@noodle-borg/service/dist/business-information/validation.js +1 -22
  41. package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +41 -145
  42. package/node_modules/@noodle-borg/service/dist/channels/composition.js +1 -0
  43. package/node_modules/@noodle-borg/service/dist/channels/meta-cloud.js +102 -0
  44. package/node_modules/@noodle-borg/service/dist/channels/provider.js +187 -0
  45. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +49 -21
  46. package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +99 -0
  47. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +17 -0
  48. package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +184 -0
  49. package/node_modules/@noodle-borg/service/dist/payment-card.js +34 -0
  50. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +2 -0
  51. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -2
  52. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +15 -0
  53. package/node_modules/@noodle-borg/service/dist/routes/assistant-knowledge.js +1 -3
  54. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +8 -6
  55. package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +65 -0
  56. package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +3 -0
  57. package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +16 -0
  58. package/node_modules/@noodle-borg/service/dist/routes/whatsapp-meta.js +130 -0
  59. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +18 -9
  60. package/node_modules/@noodle-borg/service/dist/service-history-projections.js +24 -0
  61. package/node_modules/@noodle-borg/service/dist/service.js +3 -7
  62. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -1
  63. package/node_modules/@noodle-borg/transport-http/dist/index.js +1 -1
  64. package/node_modules/@noodle-borg/transport-http/dist/request-body.d.ts +9 -0
  65. package/node_modules/@noodle-borg/transport-http/dist/request-body.js +2 -1
  66. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +347 -0
  67. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +120 -0
  68. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  69. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  70. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +31 -9
  71. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +27 -4
  72. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { looksLikePaymentCard } from '../payment-card.js';
1
2
  import { MANAGED_RETENTION_DAYS } from './contracts.js';
2
3
  export const MAX_PAYLOAD_BYTES = 32 * 1024;
3
4
  export const MAX_PAYLOAD_DEPTH = 6;
@@ -113,28 +114,6 @@ function validateString(value, path) {
113
114
  throw new PayloadValidationError('health_biometric_value', `${path} contains health or biometric data`);
114
115
  }
115
116
  }
116
- function looksLikePaymentCard(value) {
117
- const candidates = value.match(/(?:\d[ -]?){13,19}/g) ?? [];
118
- return candidates.some((candidate) => {
119
- const digits = candidate.replace(/\D/g, '');
120
- return digits.length >= 13 && digits.length <= 19 && luhnValid(digits);
121
- });
122
- }
123
- function luhnValid(digits) {
124
- let sum = 0;
125
- let double = false;
126
- for (let index = digits.length - 1; index >= 0; index -= 1) {
127
- let digit = Number(digits[index]);
128
- if (double) {
129
- digit *= 2;
130
- if (digit > 9)
131
- digit -= 9;
132
- }
133
- sum += digit;
134
- double = !double;
135
- }
136
- return sum % 10 === 0;
137
- }
138
117
  function isPlainObject(value) {
139
118
  if (typeof value !== 'object' || value === null || Array.isArray(value))
140
119
  return false;
@@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { ChannelError, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { guardedFetch } from '@noodle-borg/connector-http';
4
4
  import { z } from 'zod';
5
+ import { boundedJson, cloudApiHealth, cloudApiSend, cloudApiSetBlocked, } from './provider.js';
5
6
  export const WHATSAPP_CALLBACK_HEADER = 'x-noodle-webhook-secret';
6
7
  const identifier = z.string().min(1).max(512);
7
8
  const timestamp = z
@@ -17,6 +18,10 @@ const valueSchema = z.object({
17
18
  id: identifier,
18
19
  from: z.string().max(128).optional(),
19
20
  user_id: z.string().max(128).optional(),
21
+ // Meta's business-scoped user id field for senders who adopted usernames (Meta BSUID guide,
22
+ // https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/,
23
+ // read 2026-09-22); `from` still wins whenever the phone number is present.
24
+ from_user_id: z.string().max(128).optional(),
20
25
  timestamp,
21
26
  type: z.string().max(64),
22
27
  text: z.object({ body: z.string().max(1 << 20) }).optional(),
@@ -79,10 +84,11 @@ export function parseWhatsAppWebhook(input, phoneNumberId) {
79
84
  throw new ChannelError('asset_mismatch');
80
85
  for (const message of value.messages ?? []) {
81
86
  let address;
87
+ const opaque = message.user_id ?? message.from_user_id;
82
88
  if (message.from && /^[1-9]\d{5,14}$/.test(message.from))
83
89
  address = { kind: 'phone', value: message.from };
84
- else if (message.user_id && /^[A-Z]{2}\.[A-Za-z0-9]{1,124}$/.test(message.user_id))
85
- address = { kind: 'opaque', value: message.user_id };
90
+ else if (opaque && /^[A-Z]{2}\.[A-Za-z0-9]{1,124}$/.test(opaque))
91
+ address = { kind: 'opaque', value: opaque };
86
92
  else
87
93
  throw new ChannelError('recipient_invalid');
88
94
  const button = message.type === 'interactive' && message.interactive?.type === 'button_reply'
@@ -103,32 +109,6 @@ export function parseWhatsAppWebhook(input, phoneNumberId) {
103
109
  }
104
110
  return { messages, statuses };
105
111
  }
106
- /** A 4xx that judges the payload itself; auth, throttling and timeouts are no reason to resend differently. */
107
- function rejectedPayload(status) {
108
- return status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
109
- }
110
- async function boundedJson(response) {
111
- const reader = response.body?.getReader();
112
- if (!reader)
113
- throw new ChannelError('provider_response_invalid');
114
- const chunks = [];
115
- let size = 0;
116
- try {
117
- for (;;) {
118
- const result = await reader.read();
119
- if (result.done)
120
- break;
121
- size += result.value.byteLength;
122
- if (size > 65_536)
123
- throw new ChannelError('provider_response_invalid');
124
- chunks.push(result.value);
125
- }
126
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
127
- }
128
- finally {
129
- await reader.cancel().catch(() => undefined);
130
- }
131
- }
132
112
  export class Dialog360 {
133
113
  apiKey;
134
114
  fetcher;
@@ -146,23 +126,7 @@ export class Dialog360 {
146
126
  });
147
127
  }
148
128
  async health() {
149
- const response = await this.request('/health_status?fields=id');
150
- if (!response.ok)
151
- throw new ChannelError('provider_unavailable');
152
- const parsed = z
153
- .object({
154
- id: identifier,
155
- health_status: z.object({ can_send_message: z.enum(['AVAILABLE', 'LIMITED', 'BLOCKED']) }),
156
- })
157
- .safeParse(await boundedJson(response));
158
- if (!parsed.success)
159
- throw new ChannelError('provider_response_invalid');
160
- return {
161
- phoneNumberId: parsed.data.id,
162
- // LIMITED meets provider messaging requirements; provider limits still govern each send.
163
- canSend: parsed.data.health_status.can_send_message !== 'BLOCKED',
164
- status: parsed.data.health_status.can_send_message,
165
- };
129
+ return cloudApiHealth(await this.request('/health_status?fields=id'));
166
130
  }
167
131
  async webhook() {
168
132
  const response = await this.request('/v1/configs/webhook');
@@ -194,107 +158,39 @@ export class Dialog360 {
194
158
  throw new ChannelError('webhook_configuration_failed');
195
159
  await response.body?.cancel();
196
160
  }
197
- async setBlocked(phone, blocked) {
198
- try {
199
- const response = await this.request('/block_users', blocked ? 'POST' : 'DELETE', {
200
- messaging_product: 'whatsapp',
201
- block_users: [{ user: phone }],
202
- });
203
- if (!response.ok) {
204
- await response.body?.cancel();
205
- return {
206
- state: response.status >= 500 ? 'unknown' : 'error',
207
- code: `provider_http_${response.status}`,
208
- };
209
- }
210
- const field = blocked ? 'added_users' : 'removed_users';
211
- const parsed = z
212
- .object({
213
- block_users: z.object({
214
- [field]: z.array(z.object({ input: z.string(), wa_id: z.string() })),
215
- }),
216
- })
217
- .safeParse(await boundedJson(response));
218
- return parsed.success &&
219
- parsed.data.block_users[field]?.some((user) => user.input === phone || user.wa_id === phone)
220
- ? { state: 'confirmed' }
221
- : { state: 'unknown', code: 'provider_receipt_invalid' };
222
- }
223
- catch {
224
- return { state: 'unknown', code: 'provider_outcome_unknown' };
225
- }
161
+ setBlocked(phone, blocked) {
162
+ return cloudApiSetBlocked((...args) => this.request(...args), phone, blocked);
226
163
  }
227
- /**
228
- * One reply. With buttons the review goes as an interactive reply-button message: at most three
229
- * buttons, titles up to 20 characters, ids up to 256 and a body up to 1024 characters, per the
230
- * Meta Cloud API reply-buttons reference that the 360dialog Cloud API host relays (360dialog's own
231
- * interactive-messages page states only the three-button limit; its webhook reference gives the
232
- * inbound shape); all checked 2026-09-21. A payload the provider rejects outright dispatched
233
- * nothing, so the same text goes plain instead; an ambiguous outcome is never retried.
234
- */
235
- async send(input) {
236
- if (!input.text || input.text.length > 4096)
237
- return { state: 'failed', code: 'reply_invalid' };
238
- const buttons = input.buttons ?? [];
239
- const interactive = buttons.length > 0 &&
240
- buttons.length <= 3 &&
241
- input.text.length <= 1024 &&
242
- buttons.every((button) => button.id.length <= 256 && button.title.length <= 20);
243
- const envelope = {
244
- messaging_product: 'whatsapp',
245
- recipient_type: 'individual',
246
- ...(input.to.kind === 'phone' ? { to: input.to.value } : { recipient: input.to.value }),
247
- };
248
- try {
249
- let code;
250
- let response = interactive
251
- ? await this.request('/messages', 'POST', {
252
- ...envelope,
253
- type: 'interactive',
254
- interactive: {
255
- type: 'button',
256
- body: { text: input.text },
257
- action: {
258
- buttons: buttons.map((button) => ({
259
- type: 'reply',
260
- reply: { id: button.id, title: button.title },
261
- })),
262
- },
263
- },
264
- })
265
- : undefined;
266
- if (response !== undefined && !response.ok && rejectedPayload(response.status)) {
267
- await response.body?.cancel();
268
- code = 'interactive_rejected';
269
- response = undefined;
270
- }
271
- if (response === undefined)
272
- response = await this.request('/messages', 'POST', {
273
- ...envelope,
274
- type: 'text',
275
- text: { body: input.text, preview_url: false },
276
- });
277
- if (!response.ok) {
278
- await response.body?.cancel();
279
- return {
280
- state: response.status >= 500 || response.status === 408 ? 'unknown' : 'failed',
281
- code: `provider_http_${response.status}`,
282
- };
283
- }
284
- const parsed = z
285
- .object({ messages: z.array(z.object({ id: identifier })).length(1) })
286
- .safeParse(await boundedJson(response));
287
- return parsed.success
288
- ? {
289
- state: 'accepted',
290
- providerMessageId: parsed.data.messages[0].id,
291
- ...(code === undefined ? {} : { code }),
292
- }
293
- : { state: 'unknown', code: 'provider_receipt_invalid' };
294
- }
295
- catch {
296
- return { state: 'unknown', code: 'send_outcome_unknown' };
297
- }
164
+ /** One reply; request bodies and fallback semantics are shared in `cloudApiSend`. */
165
+ send(input) {
166
+ return cloudApiSend((...args) => this.request(...args), input);
298
167
  }
299
168
  }
169
+ /**
170
+ * One 360dialog binding as a provider: its channel key's adapter plus its per-binding callback secret.
171
+ * The callback carries the secret as a header; an existing callback owned by another URL is never
172
+ * taken over.
173
+ */
174
+ export function dialog360Provider(adapter, webhookSecret) {
175
+ return {
176
+ health: () => adapter.health(),
177
+ setBlocked: (phone, blocked) => adapter.setBlocked(phone, blocked),
178
+ send: (input) => adapter.send(input),
179
+ async inspectWebhook(url) {
180
+ const current = await adapter.webhook();
181
+ return {
182
+ matches: current.url === url,
183
+ authenticated: verifyWhatsAppWebhookSecret(current.headers?.[WHATSAPP_CALLBACK_HEADER], webhookSecret),
184
+ };
185
+ },
186
+ async configureWebhook(url) {
187
+ const existing = await adapter.webhook();
188
+ if (existing.url && existing.url !== url)
189
+ throw new ChannelError('webhook_ownership_conflict');
190
+ if (existing.url !== url ||
191
+ !verifyWhatsAppWebhookSecret(existing.headers?.[WHATSAPP_CALLBACK_HEADER], webhookSecret))
192
+ await adapter.configureWebhook(url, webhookSecret);
193
+ },
194
+ };
195
+ }
300
196
  //# sourceMappingURL=360dialog.js.map
@@ -11,6 +11,7 @@ export async function createPostgresWhatsApp(pool, secretBox, options) {
11
11
  return {
12
12
  store,
13
13
  worker: new ChannelWorkerLoop((code) => (options.logger ?? noopLogger).warn('assistant.channel.worker', { code })),
14
+ ...(options.whatsappMeta ? { meta: options.whatsappMeta } : {}),
14
15
  };
15
16
  }
16
17
  export function createWhatsAppRuntime(options, deps, history) {
@@ -0,0 +1,102 @@
1
+ import { ChannelError } from '@noodle-borg/assistant-gateway/portable';
2
+ import { guardedFetch } from '@noodle-borg/connector-http';
3
+ import { z } from 'zod';
4
+ import { boundedJson, cloudApiHealth, cloudApiSend, cloudApiSetBlocked, } from './provider.js';
5
+ /**
6
+ * Graph API version for every Meta call. Graph API changelog (read 2026-09-22,
7
+ * https://developers.facebook.com/docs/graph-api/changelog): v25.0 released 2026-02-18 and available
8
+ * until 2028-07-29; v26.0 (2026-07-29) is newer, but Meta's WhatsApp references and Noodle Seed's
9
+ * App Dashboard examples use v25.0. Operators may pin another with NOODLE_WHATSAPP_META_GRAPH_VERSION.
10
+ */
11
+ export const WHATSAPP_META_GRAPH_VERSION = 'v25.0';
12
+ /** The one callback every Meta-connected number shares; bindings are found by phone-number id. */
13
+ export const WHATSAPP_META_CALLBACK_PATH = '/v1/channels/whatsapp/meta';
14
+ const node = /^\d{1,32}$/;
15
+ const subscriptions = z.object({
16
+ data: z
17
+ .array(z.object({ whatsapp_business_api_data: z.object({ id: z.string().max(64) }) }))
18
+ .max(100),
19
+ });
20
+ /**
21
+ * One Meta-connected number, called directly on graph.facebook.com with the business's token.
22
+ * Sources, all read 2026-09-22 and using v25.0 in their examples:
23
+ * - send: POST /{phone_number_id}/messages, Bearer token, `messages[].id` receipt
24
+ * (https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages); a business-scoped
25
+ * user id goes in `recipient` instead of `to`
26
+ * (https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/).
27
+ * - health: GET /{node}?fields=health_status returns `id` and `health_status.can_send_message`
28
+ * AVAILABLE | LIMITED | BLOCKED (https://developers.facebook.com/docs/whatsapp/cloud-api/health-status).
29
+ * - block: POST | DELETE /{phone_number_id}/block_users, receipts in `block_users.added_users` /
30
+ * `removed_users` (https://developers.facebook.com/docs/whatsapp/cloud-api/block-users).
31
+ * - webhooks: GET | POST /{waba_id}/subscribed_apps; the GET lists
32
+ * `data[].whatsapp_business_api_data.id`, the POST answers `{"success": true}`
33
+ * (https://developers.facebook.com/docs/whatsapp/embedded-signup/webhooks).
34
+ */
35
+ export class MetaCloud {
36
+ token;
37
+ asset;
38
+ app;
39
+ fetcher;
40
+ phone;
41
+ constructor(token, asset, app, fetcher = (url, init) => guardedFetch(new URL(url), init)) {
42
+ this.token = token;
43
+ this.asset = asset;
44
+ this.app = app;
45
+ this.fetcher = fetcher;
46
+ if (!/^v\d{1,3}\.\d{1,2}$/.test(app.graphVersion) || !node.test(app.appId))
47
+ throw new ChannelError('provider_configuration_invalid');
48
+ this.phone = (path, method, body) => this.request(asset.phoneNumberId, path, method, body);
49
+ }
50
+ async request(id, path, method = 'GET', body) {
51
+ if (!node.test(id))
52
+ throw new ChannelError('provider_asset_invalid');
53
+ return this.fetcher(`https://graph.facebook.com/${this.app.graphVersion}/${id}${path}`, {
54
+ method,
55
+ headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
56
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
57
+ redirect: 'manual',
58
+ signal: AbortSignal.timeout(15_000),
59
+ });
60
+ }
61
+ async health() {
62
+ return cloudApiHealth(await this.phone('?fields=id,health_status'));
63
+ }
64
+ setBlocked(phone, blocked) {
65
+ return cloudApiSetBlocked(this.phone, phone, blocked);
66
+ }
67
+ send(input) {
68
+ return cloudApiSend(this.phone, input);
69
+ }
70
+ async subscribed() {
71
+ const response = await this.request(this.asset.wabaId, '/subscribed_apps');
72
+ if (!response.ok) {
73
+ await response.body?.cancel();
74
+ throw new ChannelError('provider_unavailable');
75
+ }
76
+ const parsed = subscriptions.safeParse(await boundedJson(response));
77
+ if (!parsed.success)
78
+ throw new ChannelError('provider_response_invalid');
79
+ return parsed.data.data.some((app) => app.whatsapp_business_api_data.id === this.app.appId);
80
+ }
81
+ /**
82
+ * `matches` means the WABA delivers to this Meta app; `authenticated` means the platform holds the
83
+ * app secret that signs callbacks, which constructing this adapter requires. The app-level
84
+ * callback URL is set once in the Meta App Dashboard and is not read here.
85
+ */
86
+ async inspectWebhook(_url) {
87
+ return { matches: await this.subscribed(), authenticated: true };
88
+ }
89
+ async configureWebhook(_url) {
90
+ if (await this.subscribed())
91
+ return;
92
+ const response = await this.request(this.asset.wabaId, '/subscribed_apps', 'POST');
93
+ const parsed = response.ok
94
+ ? z.object({ success: z.literal(true) }).safeParse(await boundedJson(response))
95
+ : undefined;
96
+ if (!response.ok)
97
+ await response.body?.cancel();
98
+ if (!parsed?.success)
99
+ throw new ChannelError('webhook_configuration_failed');
100
+ }
101
+ }
102
+ //# sourceMappingURL=meta-cloud.js.map
@@ -0,0 +1,187 @@
1
+ import { ChannelError, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { z } from 'zod';
3
+ const identifier = z.string().min(1).max(512);
4
+ /** A 4xx that judges the payload itself; auth, throttling and timeouts are no reason to resend differently. */
5
+ function rejectedPayload(status) {
6
+ return status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
7
+ }
8
+ export async function boundedJson(response) {
9
+ const reader = response.body?.getReader();
10
+ if (!reader)
11
+ throw new ChannelError('provider_response_invalid');
12
+ const chunks = [];
13
+ let size = 0;
14
+ try {
15
+ for (;;) {
16
+ const result = await reader.read();
17
+ if (result.done)
18
+ break;
19
+ size += result.value.byteLength;
20
+ if (size > 65_536)
21
+ throw new ChannelError('provider_response_invalid');
22
+ chunks.push(result.value);
23
+ }
24
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
25
+ }
26
+ finally {
27
+ await reader.cancel().catch(() => undefined);
28
+ }
29
+ }
30
+ const sendState = z.enum(['AVAILABLE', 'LIMITED', 'BLOCKED']);
31
+ /**
32
+ * Health errors that block only business-initiated conversations. 141006 reads "There is an error
33
+ * with the payment method. This will block business initiated conversations." (observed on a WABA
34
+ * with no payment method, 2026-09-22). Channels only reply inside the customer service window,
35
+ * where non-template messages are free (https://developers.facebook.com/docs/whatsapp/pricing/).
36
+ */
37
+ const REPLY_SAFE_BLOCKS = new Set([141006]);
38
+ /**
39
+ * Parse a Cloud API health read. The aggregate `can_send_message` is BLOCKED when any entity is;
40
+ * it is downgraded to LIMITED only when the phone number itself can send and every blocked entity
41
+ * names nothing but reply-safe errors.
42
+ */
43
+ export async function cloudApiHealth(response) {
44
+ if (!response.ok)
45
+ throw new ChannelError('provider_unavailable');
46
+ const parsed = z
47
+ .object({
48
+ id: identifier,
49
+ health_status: z.object({
50
+ can_send_message: sendState,
51
+ entities: z
52
+ .array(z.object({
53
+ entity_type: z.string().max(64),
54
+ can_send_message: sendState.optional(),
55
+ errors: z
56
+ .array(z.object({ error_code: z.number().int() }))
57
+ .max(50)
58
+ .optional(),
59
+ }))
60
+ .max(20)
61
+ .optional(),
62
+ }),
63
+ })
64
+ .safeParse(await boundedJson(response));
65
+ if (!parsed.success)
66
+ throw new ChannelError('provider_response_invalid');
67
+ const { can_send_message: aggregate, entities = [] } = parsed.data.health_status;
68
+ const phone = entities.find((entity) => entity.entity_type === 'PHONE_NUMBER');
69
+ const replySafe = aggregate === 'BLOCKED' &&
70
+ phone !== undefined &&
71
+ phone.can_send_message !== 'BLOCKED' &&
72
+ entities
73
+ .filter((entity) => entity.can_send_message === 'BLOCKED')
74
+ .every(({ errors = [] }) => {
75
+ return (errors.length > 0 && errors.every((error) => REPLY_SAFE_BLOCKS.has(error.error_code)));
76
+ });
77
+ const status = replySafe ? 'LIMITED' : aggregate;
78
+ return {
79
+ phoneNumberId: parsed.data.id,
80
+ // LIMITED meets provider messaging requirements; provider limits still govern each send.
81
+ canSend: status !== 'BLOCKED',
82
+ status,
83
+ };
84
+ }
85
+ export async function cloudApiSetBlocked(request, phone, blocked) {
86
+ try {
87
+ const response = await request('/block_users', blocked ? 'POST' : 'DELETE', {
88
+ messaging_product: 'whatsapp',
89
+ block_users: [{ user: phone }],
90
+ });
91
+ if (!response.ok) {
92
+ await response.body?.cancel();
93
+ return {
94
+ state: response.status >= 500 ? 'unknown' : 'error',
95
+ code: `provider_http_${response.status}`,
96
+ };
97
+ }
98
+ const field = blocked ? 'added_users' : 'removed_users';
99
+ const parsed = z
100
+ .object({
101
+ block_users: z.object({
102
+ [field]: z.array(z.object({ input: z.string(), wa_id: z.string() })),
103
+ }),
104
+ })
105
+ .safeParse(await boundedJson(response));
106
+ return parsed.success &&
107
+ parsed.data.block_users[field]?.some((user) => user.input === phone || user.wa_id === phone)
108
+ ? { state: 'confirmed' }
109
+ : { state: 'unknown', code: 'provider_receipt_invalid' };
110
+ }
111
+ catch {
112
+ return { state: 'unknown', code: 'provider_outcome_unknown' };
113
+ }
114
+ }
115
+ /**
116
+ * One reply. With buttons the review goes as an interactive reply-button message: at most three
117
+ * buttons, titles up to 20 characters, ids up to 256 and a body up to 1024 characters, per the
118
+ * Meta Cloud API reply-buttons reference that the 360dialog Cloud API host relays (360dialog's own
119
+ * interactive-messages page states only the three-button limit; its webhook reference gives the
120
+ * inbound shape); all checked 2026-09-21. A payload the provider rejects outright dispatched
121
+ * nothing, so the same text goes plain instead; an ambiguous outcome is never retried.
122
+ */
123
+ export async function cloudApiSend(request, input) {
124
+ if (!input.text || input.text.length > 4096)
125
+ return { state: 'failed', code: 'reply_invalid' };
126
+ const buttons = input.buttons ?? [];
127
+ const interactive = buttons.length > 0 &&
128
+ buttons.length <= 3 &&
129
+ input.text.length <= 1024 &&
130
+ buttons.every((button) => button.id.length <= 256 && button.title.length <= 20);
131
+ const envelope = {
132
+ messaging_product: 'whatsapp',
133
+ recipient_type: 'individual',
134
+ ...(input.to.kind === 'phone' ? { to: input.to.value } : { recipient: input.to.value }),
135
+ };
136
+ try {
137
+ let code;
138
+ let response = interactive
139
+ ? await request('/messages', 'POST', {
140
+ ...envelope,
141
+ type: 'interactive',
142
+ interactive: {
143
+ type: 'button',
144
+ body: { text: input.text },
145
+ action: {
146
+ buttons: buttons.map((button) => ({
147
+ type: 'reply',
148
+ reply: { id: button.id, title: button.title },
149
+ })),
150
+ },
151
+ },
152
+ })
153
+ : undefined;
154
+ if (response !== undefined && !response.ok && rejectedPayload(response.status)) {
155
+ await response.body?.cancel();
156
+ code = 'interactive_rejected';
157
+ response = undefined;
158
+ }
159
+ if (response === undefined)
160
+ response = await request('/messages', 'POST', {
161
+ ...envelope,
162
+ type: 'text',
163
+ text: { body: input.text, preview_url: false },
164
+ });
165
+ if (!response.ok) {
166
+ await response.body?.cancel();
167
+ return {
168
+ state: response.status >= 500 || response.status === 408 ? 'unknown' : 'failed',
169
+ code: `provider_http_${response.status}`,
170
+ };
171
+ }
172
+ const parsed = z
173
+ .object({ messages: z.array(z.object({ id: identifier })).length(1) })
174
+ .safeParse(await boundedJson(response));
175
+ return parsed.success
176
+ ? {
177
+ state: 'accepted',
178
+ providerMessageId: parsed.data.messages[0].id,
179
+ ...(code === undefined ? {} : { code }),
180
+ }
181
+ : { state: 'unknown', code: 'provider_receipt_invalid' };
182
+ }
183
+ catch {
184
+ return { state: 'unknown', code: 'send_outcome_unknown' };
185
+ }
186
+ }
187
+ //# sourceMappingURL=provider.js.map