@noodleseed/one 0.186.1 → 0.188.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/solutions-conversations.js +1 -1
- package/dist/commands/solutions-conversations.js.map +1 -1
- package/dist/commands/solutions-history.d.ts.map +1 -1
- package/dist/commands/solutions-history.js +5 -2
- package/dist/commands/solutions-history.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts +1 -1
- package/dist/commands/whatsapp-ops.d.ts.map +1 -1
- package/dist/commands/whatsapp-ops.js +31 -9
- package/dist/commands/whatsapp-ops.js.map +1 -1
- package/dist/commands/whatsapp-output.d.ts +1 -1
- package/dist/commands/whatsapp-output.d.ts.map +1 -1
- package/dist/commands/whatsapp-output.js +8 -6
- package/dist/commands/whatsapp-output.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/curated/error-fixes.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-auth-ref.js +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +6 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +1 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/catalog-emission.js +3 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +5 -0
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.d.ts +2 -1
- package/node_modules/@noodle-borg/compiler/dist/artifact/version.js +2 -1
- package/node_modules/@noodle-borg/compiler/dist/compile.js +2 -1
- package/node_modules/@noodle-borg/compiler/dist/errors.d.ts +1 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +6 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +4 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +3 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +23 -2
- package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
- package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
- package/node_modules/@noodle-borg/service/dist/business-information/managed-solution-executable.js +2 -0
- package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
- package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
- package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
- package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
- package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
- package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
- package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
- package/node_modules/@noodle-borg/service/dist/serve-local.js +2 -0
- package/node_modules/@noodle-borg/service/dist/service.js +3 -2
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +5 -0
- package/node_modules/@noodle-borg/transport-http/dist/handler.js +2 -0
- package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +3 -1
- package/node_modules/@noodle-borg/transport-http/dist/tool-authorization.js +25 -3
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
- 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/service-info.d.ts +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
- package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
- package/package.json +2 -2
|
@@ -17,6 +17,7 @@ import { messagingProjectionIneligibility } from './messaging-eligibility.js';
|
|
|
17
17
|
import { MetaCloud, WHATSAPP_META_CALLBACK_PATH } from './meta-cloud.js';
|
|
18
18
|
import { boundReply } from './reply-bound.js';
|
|
19
19
|
import { reviewButtonBinding } from './reply-buttons.js';
|
|
20
|
+
import { TwilioSms } from './twilio-sms.js';
|
|
20
21
|
export class WhatsAppRuntime {
|
|
21
22
|
options;
|
|
22
23
|
deps;
|
|
@@ -35,6 +36,18 @@ export class WhatsAppRuntime {
|
|
|
35
36
|
async provider(binding) {
|
|
36
37
|
const values = await this.deps.registry.configStore.resolveConfigValues('secret', resolveConfigScope(binding.tenant));
|
|
37
38
|
const apiKey = values[binding.apiKeySecret];
|
|
39
|
+
if (binding.channel === 'sms') {
|
|
40
|
+
if (!apiKey)
|
|
41
|
+
throw new ChannelError('channel_credentials_missing');
|
|
42
|
+
const base = this.options.publicBaseUrl;
|
|
43
|
+
if (!base || !base.startsWith('https://'))
|
|
44
|
+
throw new ChannelError('sms_public_url_required');
|
|
45
|
+
await this.channels.checkCredentials(binding.id, channelDigest(JSON.stringify([apiKey])));
|
|
46
|
+
return {
|
|
47
|
+
adapter: new TwilioSms(apiKey, binding, `${base.replace(/\/$/, '')}/v1/channels/sms/webhooks/${binding.id}`, this.options.providerFetch),
|
|
48
|
+
webhookSecret: apiKey,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
38
51
|
if (binding.provider === 'meta') {
|
|
39
52
|
const meta = this.options.meta;
|
|
40
53
|
if (!meta)
|
|
@@ -62,7 +75,9 @@ export class WhatsAppRuntime {
|
|
|
62
75
|
};
|
|
63
76
|
}
|
|
64
77
|
webhookUrl(req, binding) {
|
|
65
|
-
const base = this.deps.serviceBase(req).replace(/\/$/, '');
|
|
78
|
+
const base = (binding.channel === 'sms' ? (this.options.publicBaseUrl ?? '') : this.deps.serviceBase(req)).replace(/\/$/, '');
|
|
79
|
+
if (binding.channel === 'sms')
|
|
80
|
+
return `${base}/v1/channels/sms/webhooks/${binding.id}`;
|
|
66
81
|
if (binding.channel === 'instagram')
|
|
67
82
|
return `${base}${INSTAGRAM_META_CALLBACK_PATH}`;
|
|
68
83
|
return binding.provider === 'meta'
|
|
@@ -79,7 +94,7 @@ export class WhatsAppRuntime {
|
|
|
79
94
|
if (binding.capabilities.some((cap) => !surface.capabilities.some((authored) => authored.kind === cap.kind && authored.name === cap.name)))
|
|
80
95
|
throw new ChannelError('capability_not_authored');
|
|
81
96
|
const artifact = projectArtifactForSurface(target.served.artifact, binding.capabilities);
|
|
82
|
-
const ineligible = messagingProjectionIneligibility(artifact);
|
|
97
|
+
const ineligible = messagingProjectionIneligibility(artifact, binding.channel);
|
|
83
98
|
if (ineligible !== undefined)
|
|
84
99
|
throw new ChannelError(ineligible);
|
|
85
100
|
return { ...target, served: { ...target.served, artifact } };
|
|
@@ -129,7 +144,11 @@ export class WhatsAppRuntime {
|
|
|
129
144
|
});
|
|
130
145
|
await check('provider_asset', async () => {
|
|
131
146
|
const health = await (await this.provider(binding)).adapter.health();
|
|
132
|
-
const asset = binding.channel === '
|
|
147
|
+
const asset = binding.channel === 'sms'
|
|
148
|
+
? binding.phoneNumberSid
|
|
149
|
+
: binding.channel === 'instagram'
|
|
150
|
+
? binding.instagramAccountId
|
|
151
|
+
: binding.phoneNumberId;
|
|
133
152
|
if (health.assetId !== asset)
|
|
134
153
|
throw new ChannelError('asset_mismatch');
|
|
135
154
|
if (!health.canSend)
|
|
@@ -178,7 +197,7 @@ export class WhatsAppRuntime {
|
|
|
178
197
|
return {
|
|
179
198
|
status: 'unavailable',
|
|
180
199
|
capabilities: [],
|
|
181
|
-
reason: `${channel === 'instagram' ? 'Instagram' : 'WhatsApp'} is not configured.`,
|
|
200
|
+
reason: `${channel === 'sms' ? 'SMS' : channel === 'instagram' ? 'Instagram' : 'WhatsApp'} is not configured.`,
|
|
182
201
|
};
|
|
183
202
|
const view = {
|
|
184
203
|
bindingId: binding.id,
|
|
@@ -236,10 +255,12 @@ export class WhatsAppRuntime {
|
|
|
236
255
|
}
|
|
237
256
|
async blocks(id, after) {
|
|
238
257
|
const local = await this.channels.blocks(id, after);
|
|
258
|
+
const optouts = await this.channels.optouts(id, after);
|
|
239
259
|
const provider = await channelProviderBlocks(this.channels.store, id, this.channels.now(), after);
|
|
240
260
|
const ids = [
|
|
241
261
|
...new Set([
|
|
242
262
|
...local.map((item) => item.participantId),
|
|
263
|
+
...optouts.map((item) => item.participantId),
|
|
243
264
|
...provider.map((item) => item.participantId),
|
|
244
265
|
]),
|
|
245
266
|
];
|
|
@@ -248,14 +269,16 @@ export class WhatsAppRuntime {
|
|
|
248
269
|
.slice(0, 100)
|
|
249
270
|
.map((participantId) => {
|
|
250
271
|
const block = local.find((item) => item.participantId === participantId);
|
|
272
|
+
const optout = optouts.find((item) => item.participantId === participantId);
|
|
251
273
|
const match = provider.find((item) => item.participantId === participantId);
|
|
252
274
|
return {
|
|
253
275
|
...(block ?? {
|
|
254
276
|
participantId,
|
|
255
|
-
actor: match
|
|
256
|
-
reason: 'operator',
|
|
277
|
+
actor: match?.actor ?? 'recipient',
|
|
278
|
+
reason: match ? 'operator' : 'recipient_opt_out',
|
|
257
279
|
until: null,
|
|
258
280
|
}),
|
|
281
|
+
...(optout ? { optedOut: optout.blocked } : {}),
|
|
259
282
|
local: !!block && (block.until === null || block.until > this.channels.now()),
|
|
260
283
|
...(match
|
|
261
284
|
? {
|
|
@@ -463,10 +486,15 @@ export class WhatsAppRuntime {
|
|
|
463
486
|
? ((await this.deps.conversations?.retentionDays(binding.tenant, binding.channel, surface)) ?? 0)
|
|
464
487
|
: 0;
|
|
465
488
|
const kept = days > 0 ? ` ${historyNoticeSentence(days, surface.noticeTemplate)}` : '';
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
answer =
|
|
489
|
+
const brand = artifact.server.branding?.name ?? artifact.server.title;
|
|
490
|
+
const displayName = binding.channel === 'sms' ? [...brand].slice(0, 80).join('') : brand;
|
|
491
|
+
const disclosure = participant.history.length === 0 ? `I’m ${displayName}’s AI assistant.${kept}\n\n` : '';
|
|
492
|
+
answer =
|
|
493
|
+
disclosure +
|
|
494
|
+
text.trim() +
|
|
495
|
+
(binding.channel === 'sms' && participant.history.length === 0
|
|
496
|
+
? '\nReply STOP to unsubscribe.'
|
|
497
|
+
: '');
|
|
470
498
|
}
|
|
471
499
|
const bounded = boundReply(binding.channel, answer);
|
|
472
500
|
// Marked-private values never persist: history and the event row keep redacted text, and the
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ChannelError } from '@noodle-borg/assistant-gateway/portable';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { boundedJson, } from './provider.js';
|
|
4
|
+
const phone = z.string().regex(/^\+[1-9]\d{1,14}$/);
|
|
5
|
+
const messageId = z.string().regex(/^(SM|MM)[a-fA-F0-9]{32}$/);
|
|
6
|
+
const numberSchema = z.object({
|
|
7
|
+
sid: z.string(),
|
|
8
|
+
account_sid: z.string(),
|
|
9
|
+
phone_number: phone,
|
|
10
|
+
capabilities: z.object({ sms: z.boolean() }),
|
|
11
|
+
sms_url: z.string().nullable(),
|
|
12
|
+
sms_method: z.string(),
|
|
13
|
+
sms_application_sid: z.string().nullable(),
|
|
14
|
+
});
|
|
15
|
+
/** Twilio REST transport, contained behind the existing messaging provider port. */
|
|
16
|
+
export class TwilioSms {
|
|
17
|
+
token;
|
|
18
|
+
asset;
|
|
19
|
+
callback;
|
|
20
|
+
fetcher;
|
|
21
|
+
constructor(token, asset, callback, fetcher = fetch) {
|
|
22
|
+
this.token = token;
|
|
23
|
+
this.asset = asset;
|
|
24
|
+
this.callback = callback;
|
|
25
|
+
this.fetcher = fetcher;
|
|
26
|
+
}
|
|
27
|
+
request(path, fields) {
|
|
28
|
+
return this.fetcher(`https://api.twilio.com/2010-04-01/Accounts/${this.asset.accountSid}${path}`, {
|
|
29
|
+
method: fields ? 'POST' : 'GET',
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `Basic ${Buffer.from(`${this.asset.accountSid}:${this.token}`).toString('base64')}`,
|
|
32
|
+
...(fields ? { 'content-type': 'application/x-www-form-urlencoded' } : {}),
|
|
33
|
+
},
|
|
34
|
+
...(fields ? { body: new URLSearchParams(fields).toString() } : {}),
|
|
35
|
+
redirect: 'error',
|
|
36
|
+
signal: AbortSignal.timeout(8_000),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async number() {
|
|
40
|
+
const response = await this.request(`/IncomingPhoneNumbers/${this.asset.phoneNumberSid}.json`);
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
await response.body?.cancel();
|
|
43
|
+
throw new ChannelError('provider_unavailable');
|
|
44
|
+
}
|
|
45
|
+
const parsed = numberSchema.safeParse(await boundedJson(response));
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
throw new ChannelError('provider_response_invalid');
|
|
48
|
+
const number = parsed.data;
|
|
49
|
+
if (number.sid !== this.asset.phoneNumberSid ||
|
|
50
|
+
number.account_sid !== this.asset.accountSid ||
|
|
51
|
+
number.phone_number !== this.asset.phoneNumber)
|
|
52
|
+
throw new ChannelError('asset_mismatch');
|
|
53
|
+
return number;
|
|
54
|
+
}
|
|
55
|
+
async health() {
|
|
56
|
+
const number = await this.number();
|
|
57
|
+
const response = await this.request('.json');
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
await response.body?.cancel();
|
|
60
|
+
throw new ChannelError('provider_unavailable');
|
|
61
|
+
}
|
|
62
|
+
const account = z
|
|
63
|
+
.object({ sid: z.string(), status: z.string() })
|
|
64
|
+
.safeParse(await boundedJson(response));
|
|
65
|
+
if (!account.success || account.data.sid !== this.asset.accountSid)
|
|
66
|
+
throw new ChannelError('asset_mismatch');
|
|
67
|
+
const canSend = number.capabilities.sms && account.data.status === 'active';
|
|
68
|
+
return {
|
|
69
|
+
assetId: number.sid,
|
|
70
|
+
canSend,
|
|
71
|
+
status: canSend ? 'AVAILABLE' : 'BLOCKED',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async inspectWebhook(url) {
|
|
75
|
+
const number = await this.number();
|
|
76
|
+
return {
|
|
77
|
+
matches: number.sms_url === url && number.sms_method === 'POST' && !number.sms_application_sid,
|
|
78
|
+
authenticated: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async configureWebhook(url) {
|
|
82
|
+
const number = await this.number();
|
|
83
|
+
if (number.sms_application_sid ||
|
|
84
|
+
(number.sms_url &&
|
|
85
|
+
number.sms_url !== url &&
|
|
86
|
+
number.sms_url !== 'https://demo.twilio.com/welcome/sms/reply'))
|
|
87
|
+
throw new ChannelError('webhook_ownership_conflict');
|
|
88
|
+
const response = await this.request(`/IncomingPhoneNumbers/${this.asset.phoneNumberSid}.json`, {
|
|
89
|
+
SmsUrl: url,
|
|
90
|
+
SmsMethod: 'POST',
|
|
91
|
+
});
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new ChannelError('webhook_configuration_failed');
|
|
95
|
+
}
|
|
96
|
+
async setBlocked() {
|
|
97
|
+
return { state: 'error', code: 'provider_block_unsupported' };
|
|
98
|
+
}
|
|
99
|
+
async send(input) {
|
|
100
|
+
if (input.to.kind !== 'phone' ||
|
|
101
|
+
!phone.safeParse(input.to.value).success ||
|
|
102
|
+
!input.text ||
|
|
103
|
+
input.text.length > 600)
|
|
104
|
+
return { state: 'failed', code: 'reply_invalid' };
|
|
105
|
+
try {
|
|
106
|
+
const response = await this.request('/Messages.json', {
|
|
107
|
+
From: this.asset.phoneNumber,
|
|
108
|
+
To: input.to.value,
|
|
109
|
+
Body: input.text,
|
|
110
|
+
StatusCallback: `${this.callback}/status`,
|
|
111
|
+
ValidityPeriod: '300',
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
return {
|
|
116
|
+
state: response.status >= 500 || response.status === 408 ? 'unknown' : 'failed',
|
|
117
|
+
code: `provider_http_${response.status}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const parsed = z.object({ sid: messageId }).safeParse(await boundedJson(response));
|
|
121
|
+
return parsed.success
|
|
122
|
+
? { state: 'accepted', providerMessageId: parsed.data.sid }
|
|
123
|
+
: { state: 'unknown', code: 'provider_receipt_invalid' };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return { state: 'unknown', code: 'provider_outcome_unknown' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Twilio callbacks lack a creation timestamp. Read the immutable resource timestamp so retries
|
|
130
|
+
* deduplicate identically and a signed callback cannot revive a message after retention expires. */
|
|
131
|
+
async receive(sid) {
|
|
132
|
+
if (!messageId.safeParse(sid).success)
|
|
133
|
+
throw new ChannelError('message_invalid');
|
|
134
|
+
const response = await this.request(`/Messages/${sid}.json`);
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
await response.body?.cancel();
|
|
137
|
+
throw new ChannelError('provider_unavailable');
|
|
138
|
+
}
|
|
139
|
+
const parsed = z
|
|
140
|
+
.object({
|
|
141
|
+
sid: messageId,
|
|
142
|
+
account_sid: z.string(),
|
|
143
|
+
direction: z.string(),
|
|
144
|
+
from: phone,
|
|
145
|
+
to: phone,
|
|
146
|
+
body: z.string().max(16_384),
|
|
147
|
+
num_media: z.string(),
|
|
148
|
+
date_created: z.string(),
|
|
149
|
+
})
|
|
150
|
+
.safeParse(await boundedJson(response));
|
|
151
|
+
if (!parsed.success)
|
|
152
|
+
throw new ChannelError('provider_response_invalid');
|
|
153
|
+
const value = parsed.data;
|
|
154
|
+
if (value.sid !== sid ||
|
|
155
|
+
value.account_sid !== this.asset.accountSid ||
|
|
156
|
+
value.to !== this.asset.phoneNumber ||
|
|
157
|
+
value.direction !== 'inbound')
|
|
158
|
+
throw new ChannelError('message_asset_mismatch');
|
|
159
|
+
const eventAt = Date.parse(value.date_created);
|
|
160
|
+
if (!Number.isSafeInteger(eventAt))
|
|
161
|
+
throw new ChannelError('timestamp_invalid');
|
|
162
|
+
const word = value.body.trim().toUpperCase();
|
|
163
|
+
const control = [
|
|
164
|
+
'STOP',
|
|
165
|
+
'STOPALL',
|
|
166
|
+
'UNSUBSCRIBE',
|
|
167
|
+
'CANCEL',
|
|
168
|
+
'END',
|
|
169
|
+
'QUIT',
|
|
170
|
+
'REVOKE',
|
|
171
|
+
'OPTOUT',
|
|
172
|
+
].includes(word)
|
|
173
|
+
? 'stop'
|
|
174
|
+
: ['START', 'UNSTOP'].includes(word)
|
|
175
|
+
? 'start'
|
|
176
|
+
: ['HELP', 'INFO'].includes(word)
|
|
177
|
+
? 'help'
|
|
178
|
+
: undefined;
|
|
179
|
+
return {
|
|
180
|
+
providerId: sid,
|
|
181
|
+
address: { kind: 'phone', value: value.from },
|
|
182
|
+
eventAt,
|
|
183
|
+
...(value.num_media === '0' && value.body ? { text: value.body } : {}),
|
|
184
|
+
...(control ? { control } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=twilio-sms.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** Twilio's documented form signature: the exact public URL plus every sorted POST parameter.
|
|
3
|
+
* Duplicate parameters are rejected before this boundary; unknown provider fields stay signed. */
|
|
4
|
+
export function verifyTwilioSignature(url, params, signature, token) {
|
|
5
|
+
if (typeof signature !== 'string' || !/^[A-Za-z0-9+/]{27}=$/.test(signature))
|
|
6
|
+
return false;
|
|
7
|
+
const payload = url +
|
|
8
|
+
[...params.keys()]
|
|
9
|
+
.sort()
|
|
10
|
+
.map((key) => key + params.get(key))
|
|
11
|
+
.join('');
|
|
12
|
+
const expected = createHmac('sha1', token).update(payload).digest();
|
|
13
|
+
const provided = Buffer.from(signature, 'base64');
|
|
14
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=twilio-webhook.js.map
|
|
@@ -59,9 +59,10 @@ export async function projectBusinessChannels(installation, canEdit, base, deps)
|
|
|
59
59
|
};
|
|
60
60
|
const registered = await deps.registry.getActiveByTenant(tenant);
|
|
61
61
|
const target = registered && (await resolveTargetOrigins(registered));
|
|
62
|
-
const [whatsapp, instagram] = await Promise.all([
|
|
62
|
+
const [whatsapp, instagram, sms] = await Promise.all([
|
|
63
63
|
deps.readMessaging?.(tenant, 'whatsapp'),
|
|
64
64
|
deps.readMessaging?.(tenant, 'instagram'),
|
|
65
|
+
deps.readMessaging?.(tenant, 'sms'),
|
|
65
66
|
]);
|
|
66
67
|
// Pausing agent channels pauses every messaging channel with them.
|
|
67
68
|
const intake = (view) => installation.intakeActive ? view : { ...view, status: 'paused' };
|
|
@@ -74,6 +75,7 @@ export async function projectBusinessChannels(installation, canEdit, base, deps)
|
|
|
74
75
|
: {}),
|
|
75
76
|
...(whatsapp ? { whatsapp: intake(whatsapp) } : {}),
|
|
76
77
|
...(instagram ? { instagram: intake(instagram) } : {}),
|
|
78
|
+
...(sms ? { sms: intake(sms) } : {}),
|
|
77
79
|
};
|
|
78
80
|
if (!target?.deploymentId)
|
|
79
81
|
return {
|
|
@@ -28,6 +28,7 @@ function handleServiceProbe(req, res, url, { tls, moduleHost, buildInfo, options
|
|
|
28
28
|
applySecurityHeaders(res, tls);
|
|
29
29
|
return sendJson(res, 200, serviceInfoPayload(buildInfo, options.developerMcp === true, {
|
|
30
30
|
whatsapp: whatsapp !== undefined,
|
|
31
|
+
sms: whatsapp !== undefined,
|
|
31
32
|
instagram: whatsapp?.options.meta !== undefined,
|
|
32
33
|
}));
|
|
33
34
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { recordChannelDelivery } from '@noodle-borg/assistant-gateway/portable';
|
|
2
|
+
import { readBodyBuffer, sendJson } from '@noodle-borg/transport-http';
|
|
3
|
+
import { TwilioSms } from '../channels/twilio-sms.js';
|
|
4
|
+
import { verifyTwilioSignature } from '../channels/twilio-webhook.js';
|
|
5
|
+
/** Per-binding routing selects only a credential candidate. Signature, account and number must all
|
|
6
|
+
* match before a callback may change durable state. The public URL comes from operator config. */
|
|
7
|
+
export async function smsWebhook(req, res, id, status, url, runtime) {
|
|
8
|
+
if (req.method !== 'POST')
|
|
9
|
+
return sendJson(res, 405, { error: 'method_not_allowed' });
|
|
10
|
+
if (url.search ||
|
|
11
|
+
req.headers['content-type']?.split(';')[0] !== 'application/x-www-form-urlencoded')
|
|
12
|
+
return sendJson(res, 400, { error: 'webhook_invalid' });
|
|
13
|
+
const binding = await runtime.channels.internal(id);
|
|
14
|
+
if (binding.channel !== 'sms')
|
|
15
|
+
return sendJson(res, 404, { error: 'not_found' });
|
|
16
|
+
const { adapter, webhookSecret } = await runtime.provider(binding);
|
|
17
|
+
const body = await readBodyBuffer(req, 65_536);
|
|
18
|
+
if (!body.ok)
|
|
19
|
+
return sendJson(res, 413, { error: 'webhook_invalid' });
|
|
20
|
+
const params = new URLSearchParams(body.buffer.toString('utf8'));
|
|
21
|
+
if ([...params.keys()].length > 100 ||
|
|
22
|
+
[...params.keys()].some((key) => params.getAll(key).length !== 1))
|
|
23
|
+
return sendJson(res, 400, { error: 'webhook_invalid' });
|
|
24
|
+
const callback = runtime.webhookUrl(req, binding) + (status ? '/status' : '');
|
|
25
|
+
if (!webhookSecret ||
|
|
26
|
+
!verifyTwilioSignature(callback, params, req.headers['x-twilio-signature'], webhookSecret))
|
|
27
|
+
return sendJson(res, 401, { error: 'unauthorized' });
|
|
28
|
+
if (params.get('AccountSid') !== binding.accountSid ||
|
|
29
|
+
params.get(status ? 'From' : 'To') !== binding.phoneNumber)
|
|
30
|
+
return sendJson(res, 400, { error: 'asset_mismatch' });
|
|
31
|
+
const sid = params.get('MessageSid');
|
|
32
|
+
if (!sid || !/^(SM|MM)[a-fA-F0-9]{32}$/.test(sid))
|
|
33
|
+
return sendJson(res, 400, { error: 'message_invalid' });
|
|
34
|
+
if (status) {
|
|
35
|
+
const state = params.get('MessageStatus');
|
|
36
|
+
const delivery = state === 'delivered'
|
|
37
|
+
? 'delivered'
|
|
38
|
+
: state === 'sent'
|
|
39
|
+
? 'sent'
|
|
40
|
+
: state === 'failed' || state === 'undelivered'
|
|
41
|
+
? 'failed'
|
|
42
|
+
: undefined;
|
|
43
|
+
if (delivery)
|
|
44
|
+
await recordChannelDelivery(runtime.channels.store, id, sid, delivery, runtime.channels.now());
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
if (!(adapter instanceof TwilioSms))
|
|
48
|
+
return sendJson(res, 503, { error: 'provider_unavailable' });
|
|
49
|
+
const message = await adapter.receive(sid);
|
|
50
|
+
if (message.address.value !== params.get('From'))
|
|
51
|
+
return sendJson(res, 400, { error: 'asset_mismatch' });
|
|
52
|
+
const optOut = params.get('OptOutType');
|
|
53
|
+
const control = optOut === 'STOP'
|
|
54
|
+
? 'stop'
|
|
55
|
+
: optOut === 'START'
|
|
56
|
+
? 'start'
|
|
57
|
+
: optOut === 'HELP'
|
|
58
|
+
? 'help'
|
|
59
|
+
: message.control;
|
|
60
|
+
await runtime.channels.receive(id, [{ ...message, ...(control ? { control } : {}) }], await runtime.retention(binding));
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(200, { 'content-type': 'text/xml; charset=utf-8' });
|
|
63
|
+
res.end('<Response/>');
|
|
64
|
+
runtime.options.worker.wake();
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=sms-twilio.js.map
|
|
@@ -6,6 +6,7 @@ import { INSTAGRAM_META_CALLBACK_PATH } from '../channels/instagram-meta.js';
|
|
|
6
6
|
import { WHATSAPP_META_CALLBACK_PATH } from '../channels/meta-cloud.js';
|
|
7
7
|
import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
|
|
8
8
|
import { instagramWebhook } from './instagram-meta.js';
|
|
9
|
+
import { smsWebhook } from './sms-twilio.js';
|
|
9
10
|
import { metaWebhook } from './whatsapp-meta.js';
|
|
10
11
|
const participant = /^p_[a-f0-9]{64}$/;
|
|
11
12
|
const eventId = /^e_[0-9_]+$/;
|
|
@@ -35,30 +36,33 @@ async function body(req, schema) {
|
|
|
35
36
|
}
|
|
36
37
|
export function dispatchWhatsAppRoutes(req, res, url, runtime, grants) {
|
|
37
38
|
const callback = /^\/v1\/channels\/whatsapp\/webhooks\/([a-z0-9-]+)$/.exec(url.pathname);
|
|
38
|
-
const operator = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/channels\/(whatsapp|instagram)(?:\/(.*))?$/.exec(url.pathname);
|
|
39
|
+
const operator = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/channels\/(whatsapp|instagram|sms)(?:\/(.*))?$/.exec(url.pathname);
|
|
40
|
+
const sms = /^\/v1\/channels\/sms\/webhooks\/([a-z0-9-]+)(\/status)?$/.exec(url.pathname);
|
|
39
41
|
const meta = url.pathname === WHATSAPP_META_CALLBACK_PATH;
|
|
40
42
|
const instagram = url.pathname === INSTAGRAM_META_CALLBACK_PATH;
|
|
41
|
-
if (!callback && !operator && !meta && !instagram)
|
|
43
|
+
if (!callback && !operator && !meta && !instagram && !sms)
|
|
42
44
|
return false;
|
|
43
45
|
res.setHeader('Cache-Control', 'no-store');
|
|
44
46
|
if (!runtime) {
|
|
45
47
|
sendJson(res, 503, {
|
|
46
|
-
error: '
|
|
47
|
-
code: '
|
|
48
|
+
error: 'Messaging is unavailable on this service',
|
|
49
|
+
code: 'channel_unavailable',
|
|
48
50
|
});
|
|
49
51
|
return true;
|
|
50
52
|
}
|
|
51
|
-
const run = Promise.resolve().then(() =>
|
|
52
|
-
?
|
|
53
|
-
:
|
|
54
|
-
?
|
|
55
|
-
:
|
|
56
|
-
?
|
|
57
|
-
:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
const run = Promise.resolve().then(() => sms
|
|
54
|
+
? smsWebhook(req, res, sms[1], !!sms[2], url, runtime)
|
|
55
|
+
: meta
|
|
56
|
+
? metaWebhook(req, res, url, runtime)
|
|
57
|
+
: instagram
|
|
58
|
+
? instagramWebhook(req, res, url, runtime)
|
|
59
|
+
: callback
|
|
60
|
+
? webhook(req, res, callback[1], runtime)
|
|
61
|
+
: operate(req, res, url, {
|
|
62
|
+
org: decodeURIComponent(operator[1]),
|
|
63
|
+
app: decodeURIComponent(operator[2]),
|
|
64
|
+
env: decodeURIComponent(operator[3]),
|
|
65
|
+
}, operator[4], operator[5] ?? '', runtime, grants));
|
|
62
66
|
void run.catch((error) => {
|
|
63
67
|
const code = error instanceof ChannelError ? error.code : 'channel_unavailable';
|
|
64
68
|
const status = code === 'channel_not_found'
|
|
@@ -71,7 +75,7 @@ export function dispatchWhatsAppRoutes(req, res, url, runtime, grants) {
|
|
|
71
75
|
'deployment_changed',
|
|
72
76
|
].includes(code)
|
|
73
77
|
? 409
|
|
74
|
-
:
|
|
78
|
+
: ['channel_unavailable', 'ingress_capacity', 'provider_unavailable'].includes(code)
|
|
75
79
|
? 503
|
|
76
80
|
: 400;
|
|
77
81
|
if (!res.headersSent)
|
|
@@ -118,9 +122,11 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
|
|
|
118
122
|
const idempotency = typeof key === 'string' ? key : '';
|
|
119
123
|
const visible = await channels.get(tenant, channel);
|
|
120
124
|
// Every other operation's shape is channel-neutral; the binding and its configuration are not.
|
|
121
|
-
const bindingSchema = channel === '
|
|
122
|
-
? wire.
|
|
123
|
-
:
|
|
125
|
+
const bindingSchema = channel === 'sms'
|
|
126
|
+
? wire.SmsBindingResponseSchema
|
|
127
|
+
: channel === 'instagram'
|
|
128
|
+
? wire.InstagramBindingResponseSchema
|
|
129
|
+
: wire.WhatsAppBindingResponseSchema;
|
|
124
130
|
const audit = async (action) => deps.audit.emit({
|
|
125
131
|
eventType: `assistant.channel.${action}`,
|
|
126
132
|
...tenant,
|
|
@@ -136,7 +142,11 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
|
|
|
136
142
|
throw new ChannelError('deployment_missing');
|
|
137
143
|
const deploymentId = target.deploymentId;
|
|
138
144
|
let configured;
|
|
139
|
-
if (channel === '
|
|
145
|
+
if (channel === 'sms') {
|
|
146
|
+
const { expectedRevision, ...configuration } = await body(req, wire.SmsConfigureRequestSchema);
|
|
147
|
+
configured = await channels.configure({ ...configuration, channel, tenant, deploymentId }, identity.subject, idempotency, expectedRevision);
|
|
148
|
+
}
|
|
149
|
+
else if (channel === 'instagram') {
|
|
140
150
|
const { expectedRevision, ...configuration } = await body(req, wire.InstagramConfigureRequestSchema);
|
|
141
151
|
configured = await channels.configure({ ...configuration, channel, tenant, deploymentId }, identity.subject, idempotency, expectedRevision);
|
|
142
152
|
}
|
|
@@ -198,7 +208,7 @@ async function operate(req, res, url, tenant, channel, path, runtime, grants) {
|
|
|
198
208
|
const participantId = 'phone' in parsed && parsed.phone
|
|
199
209
|
? channelParticipantId(await channels.internal(id), {
|
|
200
210
|
kind: 'phone',
|
|
201
|
-
value: parsed.phone.slice(1),
|
|
211
|
+
value: channel === 'sms' ? parsed.phone : parsed.phone.slice(1),
|
|
202
212
|
})
|
|
203
213
|
: parsed.participantId;
|
|
204
214
|
await channels.block(id, participantId, identity.subject, idempotency, parsed.until);
|
|
@@ -66,6 +66,8 @@ export async function serveLocalService(options = {}) {
|
|
|
66
66
|
});
|
|
67
67
|
const handler = createServiceHandler(registry, {
|
|
68
68
|
capabilities,
|
|
69
|
+
// Loopback only (checked above): the one anonymous caller is the author running `noodle dev`.
|
|
70
|
+
mcpAnonymousCallers: 'author',
|
|
69
71
|
deployGate: {
|
|
70
72
|
authorize: () => ({
|
|
71
73
|
ok: true,
|
|
@@ -107,6 +107,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
107
107
|
logger,
|
|
108
108
|
tls,
|
|
109
109
|
protocolMode: options.mcpProtocolMode ?? 'dual',
|
|
110
|
+
...(options.mcpAnonymousCallers ? { anonymousCallers: options.mcpAnonymousCallers } : {}),
|
|
110
111
|
...(options.mcpRequestState === undefined ? {} : { requestState: options.mcpRequestState }),
|
|
111
112
|
...(options.mcpConfirmationNonceLedger === undefined
|
|
112
113
|
? {}
|
|
@@ -199,7 +200,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
199
200
|
logger,
|
|
200
201
|
conversationPolicy,
|
|
201
202
|
});
|
|
202
|
-
const whatsapp = createWhatsAppRuntime(options
|
|
203
|
+
const whatsapp = createWhatsAppRuntime(options, assistantDeps, {
|
|
203
204
|
allowance: moduleHost.resolveActivityHistoryAllowance,
|
|
204
205
|
installations: businessInformationStore,
|
|
205
206
|
activity,
|
|
@@ -356,7 +357,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
356
357
|
})) {
|
|
357
358
|
return;
|
|
358
359
|
}
|
|
359
|
-
if (/\/channels\/(whatsapp|instagram)(\/|$)/.test(url.pathname)) {
|
|
360
|
+
if (/\/channels\/(whatsapp|instagram|sms)(\/|$)/.test(url.pathname)) {
|
|
360
361
|
applySecurityHeaders(res, tls);
|
|
361
362
|
if (enforceHttps(req, res, tls))
|
|
362
363
|
return;
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@noodle-borg/managed-capabilities": "0.0.0",
|
|
42
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
43
|
"@noodle-borg/admission-limits": "0.0.0",
|
|
44
|
-
"@noodle-borg/agent-kit": "0.
|
|
44
|
+
"@noodle-borg/agent-kit": "0.112.0",
|
|
45
45
|
"@noodle-borg/app-package": "0.0.0",
|
|
46
46
|
"@noodle-borg/assistant-gateway": "0.0.0",
|
|
47
47
|
"@noodle-borg/auth": "0.0.0",
|
|
@@ -46,6 +46,11 @@ export interface HttpHandlerOptions {
|
|
|
46
46
|
readonly authorizeDataPlaneIdentity?: DataPlaneIdentityAuthorizer;
|
|
47
47
|
/** Request admission hook after authentication and JSON parsing, before SDK execution. */
|
|
48
48
|
readonly admissionGate?: AdmissionGate;
|
|
49
|
+
/**
|
|
50
|
+
* `author`: every anonymous caller is the app's own author, as on the loopback `noodle dev` service, so
|
|
51
|
+
* tools that change something run without sign-in. Hosted services never set it.
|
|
52
|
+
*/
|
|
53
|
+
readonly anonymousCallers?: 'author';
|
|
49
54
|
/** Enqueue one scalar-only analytics event after the response is written. */
|
|
50
55
|
readonly captureRequestEvent?: (event: RequestEventInput) => void;
|
|
51
56
|
/** Separate, best-effort operator intent stream. Requires `intentCaptureMode: starter-v1`. */
|
|
@@ -37,6 +37,7 @@ export function createMcpHttpHandler(target, options = {}) {
|
|
|
37
37
|
authentication: resolveTargetAuthentication({}, options.verifyOwnerToken),
|
|
38
38
|
authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
|
|
39
39
|
admissionGate: options.admissionGate,
|
|
40
|
+
anonymousCallers: options.anonymousCallers,
|
|
40
41
|
captureRequestEvent: options.captureRequestEvent,
|
|
41
42
|
captureIntentEvent: options.captureIntentEvent,
|
|
42
43
|
intentCaptureMode: options.intentCaptureMode ?? 'off',
|
|
@@ -105,6 +106,7 @@ export function createMcpRouter(lookup, options = {}) {
|
|
|
105
106
|
authentication: resolveTargetAuthentication(resolvedTarget, options.verifyOwnerToken),
|
|
106
107
|
authorizeDataPlaneIdentity: options.authorizeDataPlaneIdentity,
|
|
107
108
|
admissionGate: options.admissionGate,
|
|
109
|
+
anonymousCallers: options.anonymousCallers,
|
|
108
110
|
captureRequestEvent: options.captureRequestEvent,
|
|
109
111
|
captureIntentEvent: options.captureIntentEvent,
|
|
110
112
|
intentCaptureMode: resolvedTarget.intentCaptureMode ?? 'off',
|
|
@@ -159,7 +159,9 @@ export async function serveRequest(req, res, target, auth, routeId, maxBody, all
|
|
|
159
159
|
return denied(`admission_denied.${boundedReason(decision.reason)}`, parsed);
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
-
const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller
|
|
162
|
+
const authorization = preflightToolAuthorization(parsed, target, protocolContext.caller,
|
|
163
|
+
// On the loopback author loop the anonymous caller is the author, so no public-write rule applies.
|
|
164
|
+
auth.anonymousCallers === 'author' ? undefined : auth.accessMode);
|
|
163
165
|
for (const observation of authorization.observations) {
|
|
164
166
|
logger.info('mcp.tool_authorization', {
|
|
165
167
|
toolName: observation.toolName,
|