@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.
- package/dist/commands/knowledge.d.ts.map +1 -1
- package/dist/commands/knowledge.js +4 -7
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/solutions-conversations.d.ts +6 -0
- package/dist/commands/solutions-conversations.d.ts.map +1 -0
- package/dist/commands/solutions-conversations.js +137 -0
- package/dist/commands/solutions-conversations.js.map +1 -0
- package/dist/commands/solutions-ops.d.ts.map +1 -1
- package/dist/commands/solutions-ops.js +3 -0
- package/dist/commands/solutions-ops.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts.map +1 -1
- package/dist/commands/whatsapp-ops.js +12 -2
- package/dist/commands/whatsapp-ops.js.map +1 -1
- package/dist/commands/whatsapp-output.d.ts.map +1 -1
- package/dist/commands/whatsapp-output.js +1 -0
- package/dist/commands/whatsapp-output.js.map +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +14 -8
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +11 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +14 -5
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +5 -3
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +97 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solutions.js +2 -0
- package/node_modules/@noodle-borg/knowledge-operations/dist/executor.d.ts +3 -5
- package/node_modules/@noodle-borg/knowledge-operations/dist/executor.js +2 -7
- package/node_modules/@noodle-borg/knowledge-operations/dist/portable.d.ts +1 -1
- package/node_modules/@noodle-borg/knowledge-operations/dist/portable.js +1 -1
- package/node_modules/@noodle-borg/knowledge-operations/dist/publication.d.ts +0 -1
- package/node_modules/@noodle-borg/knowledge-operations/dist/publication.js +1 -10
- package/node_modules/@noodle-borg/knowledge-operations/dist/routes.d.ts +0 -4
- package/node_modules/@noodle-borg/knowledge-operations/dist/routes.js +1 -17
- package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.d.ts +1 -2
- package/node_modules/@noodle-borg/knowledge-operations/dist/search-port.js +1 -3
- package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.d.ts +4 -4
- package/node_modules/@noodle-borg/knowledge-operations/dist/service-wiring.js +3 -8
- package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.d.ts +1 -2
- package/node_modules/@noodle-borg/knowledge-operations/dist/status-routes.js +0 -14
- package/node_modules/@noodle-borg/protocol/dist/knowledge-tool.js +3 -5
- package/node_modules/@noodle-borg/runtime/dist/execute.d.ts +2 -3
- package/node_modules/@noodle-borg/service/dist/assistant-composition.js +9 -0
- package/node_modules/@noodle-borg/service/dist/business-information/validation.js +1 -22
- package/node_modules/@noodle-borg/service/dist/channels/360dialog.js +41 -145
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +1 -0
- package/node_modules/@noodle-borg/service/dist/channels/meta-cloud.js +102 -0
- package/node_modules/@noodle-borg/service/dist/channels/provider.js +187 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +49 -21
- package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +99 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +17 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +184 -0
- package/node_modules/@noodle-borg/service/dist/payment-card.js +34 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +2 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -2
- package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +15 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-knowledge.js +1 -3
- package/node_modules/@noodle-borg/service/dist/routes/assistant.js +8 -6
- package/node_modules/@noodle-borg/service/dist/routes/business-information-conversations.js +65 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-dispatch.js +3 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-paths.js +16 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp-meta.js +130 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +18 -9
- package/node_modules/@noodle-borg/service/dist/service-history-projections.js +24 -0
- package/node_modules/@noodle-borg/service/dist/service.js +3 -7
- package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/index.js +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/request-body.d.ts +9 -0
- package/node_modules/@noodle-borg/transport-http/dist/request-body.js +2 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +347 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +120 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +31 -9
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +27 -4
- 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 (
|
|
85
|
-
address = { kind: 'opaque', value:
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|