@noodleseed/one 0.175.1 → 0.176.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 (53) 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/whatsapp-ops.d.ts.map +1 -1
  5. package/dist/commands/whatsapp-ops.js +12 -2
  6. package/dist/commands/whatsapp-ops.js.map +1 -1
  7. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  8. package/dist/commands/whatsapp-output.js +1 -0
  9. package/dist/commands/whatsapp-output.js.map +1 -1
  10. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +14 -8
  11. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +11 -2
  12. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +14 -5
  13. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +5 -3
  14. package/node_modules/@noodle-borg/knowledge-operations/dist/executor.d.ts +3 -5
  15. package/node_modules/@noodle-borg/knowledge-operations/dist/executor.js +2 -7
  16. package/node_modules/@noodle-borg/knowledge-operations/dist/portable.d.ts +1 -1
  17. package/node_modules/@noodle-borg/knowledge-operations/dist/portable.js +1 -1
  18. package/node_modules/@noodle-borg/knowledge-operations/dist/publication.d.ts +0 -1
  19. package/node_modules/@noodle-borg/knowledge-operations/dist/publication.js +1 -10
  20. package/node_modules/@noodle-borg/knowledge-operations/dist/routes.d.ts +0 -4
  21. package/node_modules/@noodle-borg/knowledge-operations/dist/routes.js +1 -17
  22. package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.d.ts +1 -2
  23. package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.js +1 -3
  24. package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.d.ts +4 -4
  25. package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.js +3 -8
  26. package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.d.ts +1 -2
  27. package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.js +0 -14
  28. package/node_modules/@noodle-borg/protocol/dist/knowledge-tool.js +3 -5
  29. package/node_modules/@noodle-borg/runtime/dist/execute.d.ts +2 -3
  30. package/node_modules/@noodle-borg/service/dist/assistant-composition.js +9 -0
  31. package/node_modules/@noodle-borg/service/dist/business-information/validation.js +1 -22
  32. package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +41 -145
  33. package/node_modules/@noodle-borg/service/dist/channels/composition.js +1 -0
  34. package/node_modules/@noodle-borg/service/dist/channels/meta-cloud.js +102 -0
  35. package/node_modules/@noodle-borg/service/dist/channels/provider.js +151 -0
  36. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +49 -21
  37. package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +99 -0
  38. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +17 -0
  39. package/node_modules/@noodle-borg/service/dist/payment-card.js +34 -0
  40. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +2 -0
  41. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -2
  42. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +15 -0
  43. package/node_modules/@noodle-borg/service/dist/routes/assistant-knowledge.js +1 -3
  44. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +8 -6
  45. package/node_modules/@noodle-borg/service/dist/routes/whatsapp-meta.js +130 -0
  46. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +18 -9
  47. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -1
  48. package/node_modules/@noodle-borg/transport-http/dist/index.js +1 -1
  49. package/node_modules/@noodle-borg/transport-http/dist/request-body.d.ts +9 -0
  50. package/node_modules/@noodle-borg/transport-http/dist/request-body.js +2 -1
  51. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +29 -7
  52. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +27 -4
  53. package/package.json +1 -1
@@ -32,11 +32,10 @@ export function budgetCeilingsResolver(resolveVariables) {
32
32
  };
33
33
  };
34
34
  }
35
- /** Assemble route deps over the service's managed-config resolver (`NOODLE_KNOWLEDGE_ENABLED`). */
36
- export function buildKnowledgeRouteDeps(stores, resolveVariables, maxBodyBytes) {
35
+ /** Assemble the always-available knowledge route dependencies. */
36
+ export function buildKnowledgeRouteDeps(stores, maxBodyBytes) {
37
37
  return {
38
38
  staging: stores.staging,
39
- knowledgeEnabled: async (tenant) => (await resolveVariables(tenant)).NOODLE_KNOWLEDGE_ENABLED === 'true',
40
39
  maxBodyBytes,
41
40
  ...(stores.codec === undefined ? {} : { codec: stores.codec }),
42
41
  };
@@ -61,7 +60,7 @@ export function knowledgeIndexFromDeclaration(declaration, config, scope) {
61
60
  */
62
61
  export function wireKnowledge(registry, stores, resolveVariables, maxBodyBytes, resolveSecrets = async () => ({})) {
63
62
  const resolved = stores ?? defaultKnowledgeStores();
64
- const deps = buildKnowledgeRouteDeps(resolved, resolveVariables, maxBodyBytes);
63
+ const deps = buildKnowledgeRouteDeps(resolved, maxBodyBytes);
65
64
  const ceilings = budgetCeilingsResolver(resolveVariables);
66
65
  // The managed first-party crawler needs no provisioning, so the deploy gate defaults open;
67
66
  // BYO provider configuration checks replace this seam when a component declares a provider.
@@ -117,7 +116,6 @@ export function wireKnowledge(registry, stores, resolveVariables, maxBodyBytes,
117
116
  return knowledgeIndexFromDeclaration(resolvedDeclaration, config, scope);
118
117
  };
119
118
  const hooks = createKnowledgeDeployHooks(resolved, {
120
- knowledgeEnabled: deps.knowledgeEnabled,
121
119
  siteProvisioned,
122
120
  indexFor: resolved.indexFor ?? defaultIndexFor,
123
121
  });
@@ -146,12 +144,10 @@ export function wireKnowledge(registry, stores, resolveVariables, maxBodyBytes,
146
144
  };
147
145
  const bareExecutor = createKnowledgeSearchExecutor({
148
146
  hooks,
149
- knowledgeEnabled: deps.knowledgeEnabled,
150
147
  });
151
148
  // Refresh-on-traffic: serving a sites component also checks whether its refresh is due and
152
149
  // kicks the crawl in the background — the visitor's answer never waits on it.
153
150
  const executor = {
154
- enabled: bareExecutor.enabled,
155
151
  search: async (tenant, component, request) => {
156
152
  const hits = await bareExecutor.search(tenant, component, request);
157
153
  if (component.sites.length > 0) {
@@ -182,7 +178,6 @@ export function wireKnowledge(registry, stores, resolveVariables, maxBodyBytes,
182
178
  registry.setKnowledgeDeployHooks(crawlingHooks, knowledgeSearchPortFactory(executor));
183
179
  const status = {
184
180
  revisionStore: resolved.revisionStore,
185
- knowledgeEnabled: deps.knowledgeEnabled,
186
181
  crawlState: (tenant, componentName) => crawlState.get(tenant, componentName),
187
182
  refresh: (tenant, component) => runComponentCrawl(crawlDeps, tenant, component),
188
183
  // Mirrors the fail-closed deploy gate above: without the managed project every site() is
@@ -7,10 +7,9 @@
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http';
8
8
  import type { CompiledKnowledgeComponent, KnowledgeRevisionStore } from '@noodle-borg/knowledge/portable';
9
9
  import type { KnowledgeBudgetState, KnowledgeCrawlState } from '@noodle-borg/wire-contracts';
10
- import { type KnowledgeTenantRef } from './routes.js';
10
+ import type { KnowledgeTenantRef } from './routes.js';
11
11
  export interface KnowledgeStatusDeps {
12
12
  readonly revisionStore: KnowledgeRevisionStore;
13
- readonly knowledgeEnabled: (tenant: KnowledgeTenantRef) => Promise<boolean>;
14
13
  /** The active deployment's compiled knowledge components, or undefined when none is active. */
15
14
  readonly activeKnowledge: (tenant: KnowledgeTenantRef) => Promise<{
16
15
  readonly components: readonly CompiledKnowledgeComponent[];
@@ -1,12 +1,4 @@
1
1
  import { sendJson } from './http.js';
2
- import { knowledgeEnableCommand } from './routes.js';
3
- function gateClosed(res, tenant) {
4
- sendJson(res, 403, {
5
- code: 'knowledge_not_enabled',
6
- error: 'knowledge is not enabled for this org/app/env',
7
- fix: knowledgeEnableCommand(tenant),
8
- });
9
- }
10
2
  async function componentSummary(tenant, component, deploymentId, revisionStore) {
11
3
  const scope = { org: tenant.org, app: tenant.app, env: tenant.env };
12
4
  const active = component.documents.length > 0 ? await revisionStore.active(scope, component.name) : undefined;
@@ -22,8 +14,6 @@ async function componentSummary(tenant, component, deploymentId, revisionStore)
22
14
  }
23
15
  /** `GET /v1/orgs/{o}/apps/{a}/envs/{e}/knowledge` */
24
16
  export async function handleKnowledgeList(_req, res, tenant, deps) {
25
- if (!(await deps.knowledgeEnabled(tenant)))
26
- return gateClosed(res, tenant);
27
17
  const active = await deps.activeKnowledge(tenant);
28
18
  const components = [];
29
19
  for (const component of active?.components ?? []) {
@@ -38,8 +28,6 @@ export async function handleKnowledgeList(_req, res, tenant, deps) {
38
28
  }
39
29
  /** `GET /v1/orgs/{o}/apps/{a}/envs/{e}/knowledge/{name}/status` */
40
30
  export async function handleKnowledgeStatus(_req, res, tenant, componentName, deps) {
41
- if (!(await deps.knowledgeEnabled(tenant)))
42
- return gateClosed(res, tenant);
43
31
  const active = await deps.activeKnowledge(tenant);
44
32
  const component = active?.components.find((candidate) => candidate.name === componentName);
45
33
  if (active === undefined || component === undefined) {
@@ -70,8 +58,6 @@ export async function handleKnowledgeStatus(_req, res, tenant, componentName, de
70
58
  * The outcome (including failures) is the returned crawl state, never a thrown error.
71
59
  */
72
60
  export async function handleKnowledgeRefresh(_req, res, tenant, componentName, deps) {
73
- if (!(await deps.knowledgeEnabled(tenant)))
74
- return gateClosed(res, tenant);
75
61
  const active = await deps.activeKnowledge(tenant);
76
62
  const component = active?.components.find((candidate) => candidate.name === componentName);
77
63
  if (component === undefined || deps.refresh === undefined) {
@@ -2,20 +2,18 @@ import { coerceToolArguments } from './input-validation.js';
2
2
  export function findKnowledgeComponent(artifact, toolName) {
3
3
  return artifact.server.knowledge?.find((component) => component.generatedTool.name === toolName);
4
4
  }
5
- /** Listing predicate: the artifact declares knowledge AND the deployment's port is live. */
5
+ /** Listing predicate: the artifact declares knowledge and the deployment has a search port. */
6
6
  export async function knowledgeToolsEnabled(artifact, deps) {
7
7
  if ((artifact.server.knowledge?.length ?? 0) === 0)
8
8
  return false;
9
- if (deps.knowledgeSearch === undefined)
10
- return false;
11
- return deps.knowledgeSearch.enabled();
9
+ return deps.knowledgeSearch !== undefined;
12
10
  }
13
11
  export async function runKnowledgeSearchTool(component, deps, rawArguments) {
14
12
  const coerced = coerceToolArguments(component.generatedTool.inputSchema, rawArguments);
15
13
  if (coerced.issues.length > 0)
16
14
  return { kind: 'invalid', issues: coerced.issues };
17
15
  if (deps.knowledgeSearch === undefined) {
18
- return { kind: 'error', reason: 'not_enabled', message: 'knowledge search is unavailable' };
16
+ return { kind: 'error', reason: 'provider_error', message: 'knowledge search is unavailable' };
19
17
  }
20
18
  const request = coerced.value;
21
19
  const outcome = await deps.knowledgeSearch.search(component.name, request);
@@ -61,10 +61,9 @@ export interface KnowledgeSearchHit {
61
61
  }
62
62
  /**
63
63
  * Deployment-bound knowledge retrieval port (structural; the service adapts its executor).
64
- * `enabled` gates listing as well as calls — a disabled surface lists no knowledge tools.
64
+ * Presence of the port makes declared knowledge tools available.
65
65
  */
66
66
  export interface KnowledgeSearchPort {
67
- enabled(): Promise<boolean>;
68
67
  search(componentName: string, request: {
69
68
  readonly query: string;
70
69
  readonly limit?: number | undefined;
@@ -73,7 +72,7 @@ export interface KnowledgeSearchPort {
73
72
  readonly hits: readonly KnowledgeSearchHit[];
74
73
  } | {
75
74
  readonly ok: false;
76
- readonly reason: 'budget_exhausted' | 'not_enabled' | 'provider_error';
75
+ readonly reason: 'budget_exhausted' | 'provider_error';
77
76
  readonly message: string;
78
77
  }>;
79
78
  }
@@ -1,3 +1,4 @@
1
+ import { ConversationCapture } from './conversation-history/capture.js';
1
2
  /** Browser and messaging resolve model, execution and tenant policy from one composition. */
2
3
  export function assistantRouteDependencies(options, core) {
3
4
  return {
@@ -13,6 +14,14 @@ export function assistantRouteDependencies(options, core) {
13
14
  : {}),
14
15
  ...(options.captureRequestEvent ? { captureRequestEvent: options.captureRequestEvent } : {}),
15
16
  ...(options.clock ? { clock: options.clock } : {}),
17
+ ...(options.conversationHistory
18
+ ? {
19
+ conversations: new ConversationCapture(options.conversationHistory.store, options.conversationHistory.policy, {
20
+ now: () => (options.clock?.() ?? new Date()).getTime(),
21
+ ...(core.logger ? { logger: core.logger } : {}),
22
+ }),
23
+ }
24
+ : {}),
16
25
  };
17
26
  }
18
27
  //# sourceMappingURL=assistant-composition.js.map
@@ -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