@noodleseed/one 0.187.0 → 0.189.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/behavior-skills.js +19 -35
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/dist/index.d.ts +0 -1
- package/node_modules/@noodle-borg/agent-kit/dist/index.js +9 -27
- package/node_modules/@noodle-borg/agent-kit/dist/plugin-bootstrap-skill.js +1 -10
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-split.js +68 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +38 -4
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +7 -5
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-split.js +68 -0
- package/node_modules/@noodle-borg/agent-kit/dist/skill-existing-app-ref.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-router.d.ts +3 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-router.js +11 -99
- package/node_modules/@noodle-borg/agent-kit/dist/skill-server-playbook-ref.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-verification-ref.js +2 -2
- 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/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/manifest/schema.d.ts +4 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +1 -1
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +11 -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/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/service.js +2 -2
- package/node_modules/@noodle-borg/service/package.json +1 -1
- 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
- package/node_modules/@noodle-borg/agent-kit/dist/plan-execution-skill.d.ts +0 -10
- package/node_modules/@noodle-borg/agent-kit/dist/plan-execution-skill.js +0 -56
|
@@ -9,7 +9,7 @@ export function publicMessaging(input) {
|
|
|
9
9
|
throw new Error(`publicMessaging does not accept ${key}`);
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
if (input.channel !== 'whatsapp' && input.channel !== 'instagram')
|
|
12
|
+
if (input.channel !== 'whatsapp' && input.channel !== 'instagram' && input.channel !== 'sms')
|
|
13
13
|
throw new Error('Unsupported public messaging channel');
|
|
14
14
|
return {
|
|
15
15
|
kind: 'messaging',
|
|
@@ -86,6 +86,32 @@ export const CHANNELS_COMMAND = {
|
|
|
86
86
|
arguments: [],
|
|
87
87
|
flags: [],
|
|
88
88
|
subcommands: [
|
|
89
|
+
group('sms', 'Configure a Twilio SMS number and control two-way text messaging.', operations(leaf('configure', 'Save a paused SMS binding using a managed Twilio Auth Token reference.', [
|
|
90
|
+
revision,
|
|
91
|
+
...mutation,
|
|
92
|
+
flag('account-sid', 'Twilio Account SID (AC...).'),
|
|
93
|
+
flag('phone-number-sid', 'Twilio IncomingPhoneNumber SID (PN...).'),
|
|
94
|
+
flag('phone-number', 'Business sending number in E.164 format.'),
|
|
95
|
+
flag('api-key-secret', 'Managed secret name for the Twilio account Auth Token.'),
|
|
96
|
+
flag('capabilities', 'Comma-separated knowledge:name or tool:name selections.'),
|
|
97
|
+
flag('support-email', 'Contact shown when an answer cannot be completed.'),
|
|
98
|
+
...limits,
|
|
99
|
+
]), [
|
|
100
|
+
leaf('block', 'Block locally; Twilio opt-outs remain separately enforced.', [
|
|
101
|
+
participant,
|
|
102
|
+
blockScope,
|
|
103
|
+
flag('phone-stdin', 'Read a phone number securely from standard input.', 'boolean'),
|
|
104
|
+
flag('until', 'Expiry as an ISO timestamp.'),
|
|
105
|
+
flag('indefinite', 'Retain this block until removed.', 'boolean'),
|
|
106
|
+
...mutation,
|
|
107
|
+
]),
|
|
108
|
+
leaf('unblock', 'Remove a local block; does not override a recipient opt-out.', [
|
|
109
|
+
participant,
|
|
110
|
+
blockScope,
|
|
111
|
+
flag('phone-stdin', 'Supply the number if retained context expired.', 'boolean'),
|
|
112
|
+
...mutation,
|
|
113
|
+
]),
|
|
114
|
+
])),
|
|
89
115
|
group('whatsapp', 'Configure a 360dialog or Meta Cloud API number, test readiness, and control public messaging.', operations(leaf('configure', 'Save a paused binding using managed secret references.', [
|
|
90
116
|
revision,
|
|
91
117
|
...mutation,
|
|
@@ -31,7 +31,7 @@ const paging = (maximum) => [
|
|
|
31
31
|
type: 'string',
|
|
32
32
|
value: '<channel>',
|
|
33
33
|
summary: 'Only conversations from this channel.',
|
|
34
|
-
constraints: { choices: ['website', 'whatsapp', 'instagram'] },
|
|
34
|
+
constraints: { choices: ['website', 'whatsapp', 'instagram', 'sms'] },
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
...OPTIONAL_FLAG,
|
|
@@ -59,6 +59,7 @@ export const SOLUTION_HISTORY = {
|
|
|
59
59
|
channel('signed-in-customers', 'Record signed-in customers.'),
|
|
60
60
|
channel('whatsapp', 'Record WhatsApp conversations.'),
|
|
61
61
|
channel('instagram', 'Record Instagram DM conversations.'),
|
|
62
|
+
channel('sms', 'Record SMS conversations.'),
|
|
62
63
|
{
|
|
63
64
|
...OPTIONAL_FLAG,
|
|
64
65
|
required: true,
|
|
@@ -364,6 +364,7 @@ export declare const manifestV1Schema: z.ZodObject<{
|
|
|
364
364
|
channel: z.ZodEnum<{
|
|
365
365
|
whatsapp: "whatsapp";
|
|
366
366
|
instagram: "instagram";
|
|
367
|
+
sms: "sms";
|
|
367
368
|
}>;
|
|
368
369
|
mode: z.ZodLiteral<"public">;
|
|
369
370
|
capabilities: z.ZodArray<z.ZodObject<{
|
|
@@ -874,6 +875,7 @@ export declare const manifestV2Schema: z.ZodObject<{
|
|
|
874
875
|
channel: z.ZodEnum<{
|
|
875
876
|
whatsapp: "whatsapp";
|
|
876
877
|
instagram: "instagram";
|
|
878
|
+
sms: "sms";
|
|
877
879
|
}>;
|
|
878
880
|
mode: z.ZodLiteral<"public">;
|
|
879
881
|
capabilities: z.ZodArray<z.ZodObject<{
|
|
@@ -1679,6 +1681,7 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
1679
1681
|
channel: z.ZodEnum<{
|
|
1680
1682
|
whatsapp: "whatsapp";
|
|
1681
1683
|
instagram: "instagram";
|
|
1684
|
+
sms: "sms";
|
|
1682
1685
|
}>;
|
|
1683
1686
|
mode: z.ZodLiteral<"public">;
|
|
1684
1687
|
capabilities: z.ZodArray<z.ZodObject<{
|
|
@@ -2188,6 +2191,7 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
2188
2191
|
channel: z.ZodEnum<{
|
|
2189
2192
|
whatsapp: "whatsapp";
|
|
2190
2193
|
instagram: "instagram";
|
|
2194
|
+
sms: "sms";
|
|
2191
2195
|
}>;
|
|
2192
2196
|
mode: z.ZodLiteral<"public">;
|
|
2193
2197
|
capabilities: z.ZodArray<z.ZodObject<{
|
|
@@ -199,7 +199,7 @@ const embeddedAssistantSchema = z
|
|
|
199
199
|
z
|
|
200
200
|
.object({
|
|
201
201
|
kind: z.literal('messaging'),
|
|
202
|
-
channel: z.enum(['whatsapp', 'instagram']),
|
|
202
|
+
channel: z.enum(['whatsapp', 'instagram', 'sms']),
|
|
203
203
|
mode: z.literal('public'),
|
|
204
204
|
capabilities: z.array(assistantCapabilitySchema),
|
|
205
205
|
instructions: z.string().trim().min(1).max(MAX_SERVER_INSTRUCTIONS).optional(),
|
|
@@ -19,6 +19,7 @@ import type { CompileError } from '../errors.js';
|
|
|
19
19
|
*/
|
|
20
20
|
interface AssistantSurfaceInput {
|
|
21
21
|
readonly kind?: 'website' | 'messaging' | undefined;
|
|
22
|
+
readonly channel?: string | undefined;
|
|
22
23
|
readonly mode: 'authenticated' | 'public' | 'mixed';
|
|
23
24
|
readonly capabilities?: readonly {
|
|
24
25
|
readonly kind: string;
|
|
@@ -55,7 +55,7 @@ export function validateWebsiteProjection(assistant, surfaces) {
|
|
|
55
55
|
const path = `tools.${tool.name}`;
|
|
56
56
|
if (surface.kind === 'messaging') {
|
|
57
57
|
const selected = new Set(surface.capabilities?.flatMap((entry) => (entry.kind === 'tool' ? [entry.name] : [])));
|
|
58
|
-
const requirement = messagingRequirement(tool, selected, surfaces);
|
|
58
|
+
const requirement = messagingRequirement(tool, selected, surfaces, surface.channel);
|
|
59
59
|
if (requirement !== undefined)
|
|
60
60
|
errors.push({ ...requirement, path });
|
|
61
61
|
}
|
|
@@ -87,9 +87,18 @@ export function validateWebsiteProjection(assistant, surfaces) {
|
|
|
87
87
|
* opener may carry a browser widget (the website keeps its React view) because the profile renders
|
|
88
88
|
* the same block as natural collection. Anything else names the requirement it lacks.
|
|
89
89
|
*/
|
|
90
|
-
function messagingRequirement(tool, selected, surfaces) {
|
|
90
|
+
function messagingRequirement(tool, selected, surfaces, channel) {
|
|
91
91
|
const operations = operationRefs(tool.fulfilment);
|
|
92
92
|
const interaction = surfaces.interactions?.[tool.name];
|
|
93
|
+
if (channel === 'sms' &&
|
|
94
|
+
(interaction !== undefined ||
|
|
95
|
+
operations.length > 0 ||
|
|
96
|
+
tool.annotations?.readOnlyHint !== true ||
|
|
97
|
+
tool._meta?.ui !== undefined))
|
|
98
|
+
return {
|
|
99
|
+
code: 'channel_requirement_unsupported',
|
|
100
|
+
message: `"${tool.name}" requires an interaction not supported by the SMS question-answering profile`,
|
|
101
|
+
};
|
|
93
102
|
if (interaction !== undefined && !selected.has(interaction.action)) {
|
|
94
103
|
return {
|
|
95
104
|
code: 'channel_dependency_missing',
|
|
@@ -92,7 +92,7 @@ export interface PublicAdmissionContext {
|
|
|
92
92
|
readonly messaging?: string;
|
|
93
93
|
readonly provenance?: {
|
|
94
94
|
readonly kind: 'messaging';
|
|
95
|
-
readonly channel: 'whatsapp' | 'instagram';
|
|
95
|
+
readonly channel: 'whatsapp' | 'instagram' | 'sms';
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
/** A single connector-operation invocation, fully resolved and credentialed by the runtime. */
|
|
@@ -5,6 +5,7 @@ const WIRE_SOURCES = {
|
|
|
5
5
|
signedInCustomers: 'signed_in_customers',
|
|
6
6
|
whatsapp: 'whatsapp',
|
|
7
7
|
instagram: 'instagram',
|
|
8
|
+
sms: 'sms',
|
|
8
9
|
};
|
|
9
10
|
/**
|
|
10
11
|
* The surfaces the served application declared `history: false` on (ADR 0241 decision 11), so staff
|
|
@@ -58,6 +59,7 @@ export function historySettingsProjection(setting, allowance, revision, canEdit)
|
|
|
58
59
|
signedInCustomers: setting.sources.signed_in_customers,
|
|
59
60
|
whatsapp: setting.sources.whatsapp,
|
|
60
61
|
instagram: setting.sources.instagram,
|
|
62
|
+
sms: setting.sources.sms,
|
|
61
63
|
},
|
|
62
64
|
},
|
|
63
65
|
};
|
|
@@ -7,6 +7,7 @@ export function serviceInfoPayload(buildInfo, developerMcp, channels = {}) {
|
|
|
7
7
|
features: {
|
|
8
8
|
mixedCustomerAuth: MIXED_CUSTOMER_AUTH_FEATURE_VERSION,
|
|
9
9
|
...(channels.whatsapp ? { whatsapp: 1 } : {}),
|
|
10
|
+
...(channels.sms ? { sms: 1 } : {}),
|
|
10
11
|
...(channels.instagram ? { instagram: 1 } : {}),
|
|
11
12
|
},
|
|
12
13
|
...(developerMcp
|
|
@@ -15,10 +15,11 @@ export async function createPostgresWhatsApp(pool, secretBox, options) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
export function createWhatsAppRuntime(options, deps, history) {
|
|
18
|
-
if (!options)
|
|
18
|
+
if (!options.whatsapp)
|
|
19
19
|
return undefined;
|
|
20
20
|
return new WhatsAppRuntime({
|
|
21
|
-
...options,
|
|
21
|
+
...options.whatsapp,
|
|
22
|
+
publicBaseUrl: options.publicBaseUrl,
|
|
22
23
|
...(history.installations ? { installations: history.installations } : {}),
|
|
23
24
|
...(history.allowance
|
|
24
25
|
? {
|
|
@@ -6,7 +6,7 @@ import { isNativeRecordsOperation, } from '@noodle-borg/compiler';
|
|
|
6
6
|
* its confirmed native-record action selected beside it; everything else names, as a channel error
|
|
7
7
|
* code, the requirement the messaging profile lacks.
|
|
8
8
|
*/
|
|
9
|
-
export function messagingProjectionIneligibility(artifact) {
|
|
9
|
+
export function messagingProjectionIneligibility(artifact, channel = 'whatsapp') {
|
|
10
10
|
const interactions = artifact.toolInteractions ?? {};
|
|
11
11
|
const present = new Set(artifact.tools.map((tool) => tool.name));
|
|
12
12
|
const actions = new Set(Object.entries(interactions)
|
|
@@ -14,6 +14,11 @@ export function messagingProjectionIneligibility(artifact) {
|
|
|
14
14
|
.map(([, interaction]) => interaction.action));
|
|
15
15
|
for (const tool of artifact.tools) {
|
|
16
16
|
const interaction = interactions[tool.name];
|
|
17
|
+
if (channel === 'sms' &&
|
|
18
|
+
(interaction !== undefined ||
|
|
19
|
+
tool.annotations?.readOnlyHint !== true ||
|
|
20
|
+
tool._meta?.ui !== undefined))
|
|
21
|
+
return 'messaging_action_unsupported';
|
|
17
22
|
if (interaction !== undefined && !present.has(interaction.action))
|
|
18
23
|
return 'channel_dependency_missing';
|
|
19
24
|
const operations = operationRefs(tool.fulfilment);
|
|
@@ -3,6 +3,10 @@ const EXPAND = '\n\nAsk me to expand on any part.';
|
|
|
3
3
|
const INSTAGRAM_BYTES = 1000;
|
|
4
4
|
/** One reply within its channel's limit, cut on a character boundary and inviting a follow-up. */
|
|
5
5
|
export function boundReply(channel, answer) {
|
|
6
|
+
if (channel === 'sms')
|
|
7
|
+
return answer.length <= 600
|
|
8
|
+
? answer
|
|
9
|
+
: `${answer.slice(0, 530).replace(/[\uD800-\uDBFF]$/u, '')}${EXPAND}\nReply STOP to unsubscribe.`;
|
|
6
10
|
if (channel === 'whatsapp')
|
|
7
11
|
return answer.length <= 3500
|
|
8
12
|
? answer
|
|
@@ -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
|
}
|