@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.
Files changed (77) hide show
  1. package/dist/commands/solutions-conversations.js +1 -1
  2. package/dist/commands/solutions-conversations.js.map +1 -1
  3. package/dist/commands/solutions-history.d.ts.map +1 -1
  4. package/dist/commands/solutions-history.js +5 -2
  5. package/dist/commands/solutions-history.js.map +1 -1
  6. package/dist/commands/whatsapp-ops.d.ts +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  8. package/dist/commands/whatsapp-ops.js +31 -9
  9. package/dist/commands/whatsapp-ops.js.map +1 -1
  10. package/dist/commands/whatsapp-output.d.ts +1 -1
  11. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  12. package/dist/commands/whatsapp-output.js +8 -6
  13. package/dist/commands/whatsapp-output.js.map +1 -1
  14. package/node_modules/@noodle-borg/agent-kit/dist/behavior-skills.js +19 -35
  15. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +2 -2
  16. package/node_modules/@noodle-borg/agent-kit/dist/index.d.ts +0 -1
  17. package/node_modules/@noodle-borg/agent-kit/dist/index.js +9 -27
  18. package/node_modules/@noodle-borg/agent-kit/dist/plugin-bootstrap-skill.js +1 -10
  19. package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-split.js +68 -0
  20. package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +38 -4
  21. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +7 -5
  22. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-split.js +68 -0
  23. package/node_modules/@noodle-borg/agent-kit/dist/skill-existing-app-ref.js +1 -1
  24. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.d.ts +3 -2
  25. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.js +11 -99
  26. package/node_modules/@noodle-borg/agent-kit/dist/skill-server-playbook-ref.js +1 -1
  27. package/node_modules/@noodle-borg/agent-kit/dist/skill-verification-ref.js +2 -2
  28. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
  36. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
  37. package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
  38. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
  39. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
  40. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
  41. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +4 -0
  42. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +1 -1
  43. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
  44. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +11 -2
  45. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
  46. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
  47. package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
  48. package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
  49. package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
  50. package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
  51. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
  52. package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
  53. package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
  54. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
  55. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
  56. package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
  57. package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
  58. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
  59. package/node_modules/@noodle-borg/service/dist/service.js +2 -2
  60. package/node_modules/@noodle-borg/service/package.json +1 -1
  61. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  62. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
  63. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
  64. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
  65. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
  66. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
  67. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  68. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  69. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  70. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
  71. package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
  72. package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
  73. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
  74. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
  75. package/package.json +2 -2
  76. package/node_modules/@noodle-borg/agent-kit/dist/plan-execution-skill.d.ts +0 -10
  77. 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 === 'instagram' ? binding.instagramAccountId : binding.phoneNumberId;
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.actor,
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 disclosure = participant.history.length === 0
467
- ? `I’m ${artifact.server.branding?.name ?? artifact.server.title}’s AI assistant.${kept}\n\n`
468
- : '';
469
- answer = disclosure + text.trim();
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
@@ -10,6 +10,7 @@ export const ALL_CONVERSATION_SOURCES = {
10
10
  signed_in_customers: true,
11
11
  whatsapp: true,
12
12
  instagram: true,
13
+ sms: true,
13
14
  };
14
15
  /**
15
16
  * The latest expiry a shorter window allows an item from `at`; Off (0 days) ends every item now. The
@@ -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
  }