@noodleseed/one 0.175.0 → 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.
- 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/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/assistant-gateway/package.json +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +5 -3
- 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 +151 -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/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/whatsapp-meta.js +130 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +18 -9
- 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/whatsapp.d.ts +29 -7
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +27 -4
- package/node_modules/@noodleseed/assistant/package.json +1 -1
- package/package.json +1 -1
|
@@ -20,8 +20,8 @@ export interface KnowledgeServiceStores {
|
|
|
20
20
|
readonly crawlState?: CrawlStateStore;
|
|
21
21
|
/**
|
|
22
22
|
* Composition seams for tests and BYO providers: fetcher selection defaults to the managed
|
|
23
|
-
* first-party crawler; the
|
|
24
|
-
*
|
|
23
|
+
* first-party crawler; the managed crawler needs no provisioning and BYO configuration checks
|
|
24
|
+
* bind here.
|
|
25
25
|
*/
|
|
26
26
|
readonly fetcherFor?: (tenant: KnowledgeTenantRef, component: CrawlableComponent) => SiteFetcher;
|
|
27
27
|
/** BYO index selection override; defaults to declaration-driven provider adapters. */
|
|
@@ -32,8 +32,8 @@ export interface KnowledgeServiceStores {
|
|
|
32
32
|
export declare function defaultKnowledgeStores(): KnowledgeServiceStores;
|
|
33
33
|
/** Budget ceilings from managed config with the ratified v0 defaults (2000 org / 1000 app). */
|
|
34
34
|
export declare function budgetCeilingsResolver(resolveVariables: (tenant: KnowledgeTenantRef) => Promise<Record<string, string>>): (tenant: KnowledgeTenantRef) => Promise<SearchBudgetCeilings>;
|
|
35
|
-
/** Assemble
|
|
36
|
-
export declare function buildKnowledgeRouteDeps(stores: KnowledgeServiceStores,
|
|
35
|
+
/** Assemble the always-available knowledge route dependencies. */
|
|
36
|
+
export declare function buildKnowledgeRouteDeps(stores: KnowledgeServiceStores, maxBodyBytes: number): KnowledgeRouteDeps;
|
|
37
37
|
/** Map a compiled index declaration plus resolved config values onto its provider adapter. */
|
|
38
38
|
export declare function knowledgeIndexFromDeclaration(declaration: NonNullable<CompiledKnowledgeComponent['index']>, config: Readonly<Record<string, string>>, scope: KnowledgeScope): KnowledgeIndex;
|
|
39
39
|
/**
|
|
@@ -32,11 +32,10 @@ export function budgetCeilingsResolver(resolveVariables) {
|
|
|
32
32
|
};
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
-
/** Assemble
|
|
36
|
-
export function buildKnowledgeRouteDeps(stores,
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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
|
-
*
|
|
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' | '
|
|
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 (
|
|
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
|