@noodleseed/one 0.187.0 → 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.
Files changed (63) 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/generated/example-files.js +1 -1
  15. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
  16. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
  24. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
  25. package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
  26. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
  27. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
  28. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
  29. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +4 -0
  30. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +1 -1
  31. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
  32. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +11 -2
  33. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
  34. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
  35. package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
  36. package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
  37. package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
  38. package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
  39. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
  40. package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
  41. package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
  42. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
  43. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
  44. package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
  45. package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
  46. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
  47. package/node_modules/@noodle-borg/service/dist/service.js +2 -2
  48. package/node_modules/@noodle-borg/service/package.json +1 -1
  49. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  50. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
  51. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
  52. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
  53. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
  54. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
  55. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  56. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  57. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  58. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
  59. package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
  60. package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
  61. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
  62. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
  63. 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 === '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
  }
@@ -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: 'WhatsApp is unavailable on this service',
47
- code: 'whatsapp_unavailable',
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(() => meta
52
- ? metaWebhook(req, res, url, runtime)
53
- : instagram
54
- ? instagramWebhook(req, res, url, runtime)
55
- : callback
56
- ? webhook(req, res, callback[1], runtime)
57
- : operate(req, res, url, {
58
- org: decodeURIComponent(operator[1]),
59
- app: decodeURIComponent(operator[2]),
60
- env: decodeURIComponent(operator[3]),
61
- }, operator[4], operator[5] ?? '', runtime, grants));
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
- : code === 'channel_unavailable' || code === 'ingress_capacity'
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 === 'instagram'
122
- ? wire.InstagramBindingResponseSchema
123
- : wire.WhatsAppBindingResponseSchema;
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 === 'instagram') {
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);
@@ -200,7 +200,7 @@ export function createServiceHandler(registry, options = {}) {
200
200
  logger,
201
201
  conversationPolicy,
202
202
  });
203
- const whatsapp = createWhatsAppRuntime(options.whatsapp, assistantDeps, {
203
+ const whatsapp = createWhatsAppRuntime(options, assistantDeps, {
204
204
  allowance: moduleHost.resolveActivityHistoryAllowance,
205
205
  installations: businessInformationStore,
206
206
  activity,
@@ -357,7 +357,7 @@ export function createServiceHandler(registry, options = {}) {
357
357
  })) {
358
358
  return;
359
359
  }
360
- if (/\/channels\/(whatsapp|instagram)(\/|$)/.test(url.pathname)) {
360
+ if (/\/channels\/(whatsapp|instagram|sms)(\/|$)/.test(url.pathname)) {
361
361
  applySecurityHeaders(res, tls);
362
362
  if (enforceHttps(req, res, tls))
363
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.111.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",
@@ -60,6 +60,17 @@ export declare const ApplicationChannelsProjectionSchema: z.ZodObject<{
60
60
  capabilities: z.ZodArray<z.ZodString>;
61
61
  reason: z.ZodOptional<z.ZodString>;
62
62
  }, z.core.$strict>>;
63
+ sms: z.ZodOptional<z.ZodObject<{
64
+ status: z.ZodEnum<{
65
+ unavailable: "unavailable";
66
+ ready: "ready";
67
+ paused: "paused";
68
+ }>;
69
+ bindingId: z.ZodOptional<z.ZodString>;
70
+ revision: z.ZodOptional<z.ZodNumber>;
71
+ capabilities: z.ZodArray<z.ZodString>;
72
+ reason: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strict>>;
63
74
  }, z.core.$strict>;
64
75
  export type ApplicationChannelsProjection = z.infer<typeof ApplicationChannelsProjectionSchema>;
65
76
  export declare const ApplicationChannelsResponseSchema: z.ZodObject<{
@@ -125,6 +136,17 @@ export declare const ApplicationChannelsResponseSchema: z.ZodObject<{
125
136
  capabilities: z.ZodArray<z.ZodString>;
126
137
  reason: z.ZodOptional<z.ZodString>;
127
138
  }, z.core.$strict>>;
139
+ sms: z.ZodOptional<z.ZodObject<{
140
+ status: z.ZodEnum<{
141
+ unavailable: "unavailable";
142
+ ready: "ready";
143
+ paused: "paused";
144
+ }>;
145
+ bindingId: z.ZodOptional<z.ZodString>;
146
+ revision: z.ZodOptional<z.ZodNumber>;
147
+ capabilities: z.ZodArray<z.ZodString>;
148
+ reason: z.ZodOptional<z.ZodString>;
149
+ }, z.core.$strict>>;
128
150
  }, z.core.$strict>;
129
151
  }, z.core.$strict>;
130
152
  export declare const ApplicationChannelsClientResponseSchema: z.ZodObject<{
@@ -166,6 +188,17 @@ export declare const ApplicationChannelsClientResponseSchema: z.ZodObject<{
166
188
  capabilities: z.ZodArray<z.ZodString>;
167
189
  reason: z.ZodOptional<z.ZodString>;
168
190
  }, z.core.$strip>>;
191
+ sms: z.ZodOptional<z.ZodObject<{
192
+ status: z.ZodEnum<{
193
+ unavailable: "unavailable";
194
+ ready: "ready";
195
+ paused: "paused";
196
+ }>;
197
+ bindingId: z.ZodOptional<z.ZodString>;
198
+ revision: z.ZodOptional<z.ZodNumber>;
199
+ capabilities: z.ZodArray<z.ZodString>;
200
+ reason: z.ZodOptional<z.ZodString>;
201
+ }, z.core.$strip>>;
169
202
  assistant: z.ZodObject<{
170
203
  status: z.ZodEnum<{
171
204
  unavailable: "unavailable";
@@ -39,7 +39,7 @@ const assistant = z
39
39
  usage: usage.optional(),
40
40
  })
41
41
  .strict();
42
- /** One messaging channel (WhatsApp, Instagram) of the application, without provider identifiers. */
42
+ /** One messaging channel (WhatsApp, Instagram, SMS) of the application, without provider identifiers. */
43
43
  const messaging = z.strictObject({
44
44
  status,
45
45
  bindingId: z.string().max(256).optional(),
@@ -58,6 +58,7 @@ export const ApplicationChannelsProjectionSchema = z
58
58
  assistant,
59
59
  whatsapp: messaging.optional(),
60
60
  instagram: messaging.optional(),
61
+ sms: messaging.optional(),
61
62
  })
62
63
  .strict();
63
64
  export const ApplicationChannelsResponseSchema = z
@@ -70,6 +71,7 @@ export const ApplicationChannelsClientResponseSchema = z
70
71
  mcp: mcp.strip(),
71
72
  whatsapp: messaging.strip().optional(),
72
73
  instagram: messaging.strip().optional(),
74
+ sms: messaging.strip().optional(),
73
75
  assistant: assistant
74
76
  .extend({
75
77
  usage: usage