@bliggy/chat-runtime 0.0.0-stage → 0.3.1

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/src/runtime.js ADDED
@@ -0,0 +1,525 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const TURN_STATUSES = Object.freeze([
6
+ 'received',
7
+ 'authenticated',
8
+ 'processing',
9
+ 'awaiting_confirmation',
10
+ 'executing',
11
+ 'completed',
12
+ 'cancelled',
13
+ 'rejected',
14
+ 'failed',
15
+ 'expired'
16
+ ]);
17
+
18
+ const TURN_TRANSITIONS = Object.freeze({
19
+ received: ['authenticated', 'rejected', 'failed'],
20
+ authenticated: ['processing', 'rejected', 'failed'],
21
+ processing: ['awaiting_confirmation', 'completed', 'failed'],
22
+ awaiting_confirmation: ['executing', 'cancelled', 'expired'],
23
+ executing: ['completed', 'cancelled', 'failed'],
24
+ completed: [],
25
+ cancelled: [],
26
+ rejected: [],
27
+ failed: [],
28
+ expired: []
29
+ });
30
+
31
+ class ChannelRuntimeError extends Error {
32
+ constructor(message, { code = 'CHANNEL_RUNTIME_ERROR', statusCode = 500 } = {}) {
33
+ super(message);
34
+ this.name = this.constructor.name;
35
+ this.code = code;
36
+ this.statusCode = statusCode;
37
+ }
38
+ }
39
+
40
+ class EnvelopeValidationError extends ChannelRuntimeError {
41
+ constructor(message) {
42
+ super(message, { code: 'INVALID_CHANNEL_ENVELOPE', statusCode: 400 });
43
+ }
44
+ }
45
+
46
+ class SecurityError extends ChannelRuntimeError {
47
+ constructor(message = 'Channel request could not be authenticated') {
48
+ super(message, { code: 'CHANNEL_AUTHENTICATION_FAILED', statusCode: 401 });
49
+ }
50
+ }
51
+
52
+ class RateLimitError extends ChannelRuntimeError {
53
+ constructor(message = 'Channel rate limit exceeded') {
54
+ super(message, { code: 'CHANNEL_RATE_LIMITED', statusCode: 429 });
55
+ }
56
+ }
57
+
58
+ function parseDecision(message) {
59
+ const normalized = typeof message === 'string' ? message.trim().toLowerCase() : '';
60
+ if (['confirm', 'approve', 'yes'].includes(normalized)) return 'confirm';
61
+ if (['cancel', 'reject', 'no'].includes(normalized)) return 'cancel';
62
+ return null;
63
+ }
64
+
65
+ function stableValue(value) {
66
+ if (Array.isArray(value)) return value.map(stableValue);
67
+ if (!value || typeof value !== 'object') return value;
68
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
69
+ }
70
+
71
+ function actionSignature(action) {
72
+ return JSON.stringify(stableValue(action));
73
+ }
74
+
75
+ function actionIdempotencyKey(turn, action, index) {
76
+ return crypto.createHash('sha256')
77
+ .update(JSON.stringify([turn.id, index, stableValue(action)]))
78
+ .digest('hex');
79
+ }
80
+
81
+ function validateEnvelope(channel, envelope, maxTextLength) {
82
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
83
+ throw new EnvelopeValidationError('Channel adapter must return an envelope object');
84
+ }
85
+ if (envelope.channel !== channel) {
86
+ throw new EnvelopeValidationError(`Envelope channel must be ${channel}`);
87
+ }
88
+ for (const key of ['sourceKey', 'threadKey']) {
89
+ if (typeof envelope[key] !== 'string' || envelope[key].trim() === '') {
90
+ throw new EnvelopeValidationError(`${key} is required`);
91
+ }
92
+ }
93
+ if (typeof envelope.text !== 'string') {
94
+ throw new EnvelopeValidationError('text must be a string');
95
+ }
96
+ if (envelope.text.length > maxTextLength) {
97
+ throw new EnvelopeValidationError(`text exceeds the ${maxTextLength} character limit`);
98
+ }
99
+ }
100
+
101
+ function validatePrincipal(resolution) {
102
+ if (!resolution || typeof resolution !== 'object') {
103
+ throw new SecurityError('Channel actor could not be resolved');
104
+ }
105
+ for (const key of ['tenantKey', 'actorKey']) {
106
+ if (typeof resolution[key] !== 'string' || resolution[key].trim() === '') {
107
+ throw new SecurityError('Channel actor could not be resolved');
108
+ }
109
+ }
110
+ if (!resolution.principal) throw new SecurityError('Channel actor could not be resolved');
111
+ }
112
+
113
+ function sanitizeAuthentication(authentication) {
114
+ if (!authentication || typeof authentication !== 'object') return null;
115
+ const { allowed, method, provider, accountKey, reason } = authentication;
116
+ return { allowed: !!allowed, method, provider, accountKey, reason };
117
+ }
118
+
119
+ function classifyActions(actions, authorization) {
120
+ const approved = new Set((authorization?.approved || []).map(actionSignature));
121
+ const pending = new Set(
122
+ (authorization?.requiresConfirmation || authorization?.requiresConfirm || []).map(actionSignature)
123
+ );
124
+ const rejected = new Map((authorization?.rejected || []).map(item => {
125
+ const { reason = 'Action rejected by policy', ...actionWithoutReason } = item;
126
+ return [actionSignature(item.action || actionWithoutReason), reason];
127
+ }));
128
+
129
+ return actions.map(action => {
130
+ const signature = actionSignature(action);
131
+ if (rejected.has(signature)) return { action, decision: 'reject', reason: rejected.get(signature) };
132
+ if (pending.has(signature)) return { action, decision: 'confirm' };
133
+ if (approved.has(signature)) return { action, decision: 'approve' };
134
+ return { action, decision: 'reject', reason: 'Action was not classified by policy' };
135
+ });
136
+ }
137
+
138
+ function defaultRender({ plan, results, pendingActions, decision }) {
139
+ let content = plan?.content || plan?.summary || '';
140
+ if (decision === 'cancel') content ||= 'The pending action was cancelled.';
141
+ if (decision === 'confirm') content ||= 'The pending action was confirmed.';
142
+ if (pendingActions.length > 0) {
143
+ content = `${content}${content ? '\n\n' : ''}Would you like me to go ahead with these actions?`;
144
+ }
145
+ return { content, results, pendingActions };
146
+ }
147
+
148
+ class ChannelRuntime {
149
+ constructor({
150
+ adapters,
151
+ turnStore,
152
+ resolvePrincipal,
153
+ plan,
154
+ authorize,
155
+ execute,
156
+ render = defaultRender,
157
+ rateLimit = null,
158
+ parseConfirmation = parseDecision,
159
+ confirmationTtlMs = 24 * 60 * 60 * 1000,
160
+ maxTextLength = 50_000,
161
+ maxActions = 25,
162
+ now = () => new Date(),
163
+ onSecurityEvent = () => {},
164
+ onError = () => {}
165
+ }) {
166
+ if (!adapters || typeof adapters !== 'object') throw new TypeError('adapters are required');
167
+ for (const method of ['claim', 'transition', 'getPending', 'claimPending']) {
168
+ if (typeof turnStore?.[method] !== 'function') throw new TypeError(`turnStore.${method} is required`);
169
+ }
170
+ for (const [name, value] of Object.entries({ resolvePrincipal, plan, authorize, execute, render })) {
171
+ if (typeof value !== 'function') throw new TypeError(`${name} must be a function`);
172
+ }
173
+ if (typeof now !== 'function') throw new TypeError('now must be a function');
174
+ for (const [name, value] of Object.entries({ confirmationTtlMs, maxTextLength, maxActions })) {
175
+ if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
176
+ }
177
+
178
+ this.adapters = adapters;
179
+ this.turnStore = turnStore;
180
+ this.resolvePrincipal = resolvePrincipal;
181
+ this.plan = plan;
182
+ this.authorize = authorize;
183
+ this.execute = execute;
184
+ this.render = render;
185
+ this.rateLimit = rateLimit;
186
+ this.parseConfirmation = parseConfirmation;
187
+ this.confirmationTtlMs = confirmationTtlMs;
188
+ this.maxTextLength = maxTextLength;
189
+ this.maxActions = maxActions;
190
+ this.now = now;
191
+ this.onSecurityEvent = onSecurityEvent;
192
+ this.onError = onError;
193
+ }
194
+
195
+ async handle({ channel, payload, context = {}, emit = () => {} }) {
196
+ const adapter = this.adapters[channel];
197
+ if (!adapter || typeof adapter.normalize !== 'function' || typeof adapter.authenticate !== 'function') {
198
+ throw new EnvelopeValidationError(`Unsupported channel: ${channel}`);
199
+ }
200
+
201
+ const envelope = await adapter.normalize(payload, context);
202
+ validateEnvelope(channel, envelope, this.maxTextLength);
203
+
204
+ const authentication = await adapter.authenticate(envelope, context);
205
+ if (!authentication?.allowed) {
206
+ await this.onSecurityEvent({
207
+ channel,
208
+ sourceKey: envelope.sourceKey,
209
+ threadKey: envelope.threadKey,
210
+ sender: envelope.sender,
211
+ provider: authentication?.provider,
212
+ method: authentication?.method,
213
+ reason: authentication?.reason
214
+ });
215
+ throw new SecurityError();
216
+ }
217
+
218
+ const resolution = await this.resolvePrincipal({ envelope, authentication, context });
219
+ validatePrincipal(resolution);
220
+ const scope = {
221
+ tenantKey: resolution.tenantKey,
222
+ actorKey: resolution.actorKey,
223
+ channel,
224
+ threadKey: envelope.threadKey
225
+ };
226
+ const storeContext = { principal: resolution.principal, context };
227
+
228
+ if (this.rateLimit) {
229
+ const access = await this.rateLimit({
230
+ ...scope,
231
+ envelope,
232
+ principal: resolution.principal,
233
+ context
234
+ });
235
+ if (!access?.allowed) throw new RateLimitError(access?.reason);
236
+ }
237
+
238
+ const claimed = await this.turnStore.claim({
239
+ ...scope,
240
+ sourceKey: envelope.sourceKey,
241
+ status: 'received',
242
+ input: envelope,
243
+ security: sanitizeAuthentication(authentication)
244
+ }, storeContext);
245
+ if (!claimed.created) {
246
+ return await this._result(adapter, claimed.turn, envelope, context, true);
247
+ }
248
+
249
+ let turn = claimed.turn;
250
+ try {
251
+ turn = await this.turnStore.transition(turn, 'authenticated', {}, storeContext);
252
+ await emit({ type: 'turn', status: turn.status, turnId: turn.id });
253
+
254
+ // Read without claiming: interpretation can be asynchronous or inconclusive.
255
+ // The later atomic claim must still match this exact scoped action snapshot.
256
+ const pendingTurn = await this.turnStore.getPending(scope, storeContext);
257
+ const interpreted = await this.parseConfirmation(envelope.text, {
258
+ channel, envelope, context, principal: resolution.principal,
259
+ pendingTurn: pendingTurn ? structuredClone(pendingTurn) : null
260
+ });
261
+ const decision = [null, undefined, 'confirm', 'cancel'].includes(interpreted)
262
+ ? interpreted : 'clarify';
263
+ if (decision === 'clarify') {
264
+ turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
265
+ const output = await this.render({
266
+ channel, envelope, results: [], pendingActions: [], decision,
267
+ plan: { content: 'Please tell me whether you want to go ahead with the pending actions, or what you would like to change.' },
268
+ context
269
+ });
270
+ turn = await this.turnStore.transition(turn, 'completed', { output, results: [] }, storeContext);
271
+ return await this._result(adapter, turn, envelope, context, false);
272
+ }
273
+ if (decision) {
274
+ return await this._handleDecision({
275
+ adapter,
276
+ context,
277
+ decision,
278
+ emit,
279
+ envelope,
280
+ principal: resolution.principal,
281
+ scope,
282
+ storeContext,
283
+ pendingTurn,
284
+ turn
285
+ });
286
+ }
287
+
288
+ turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
289
+ await emit({ type: 'turn', status: turn.status, turnId: turn.id });
290
+ const plan = await this.plan({
291
+ principal: resolution.principal,
292
+ envelope,
293
+ turn,
294
+ emit,
295
+ context
296
+ });
297
+ const actions = Array.isArray(plan?.actions) ? plan.actions : [];
298
+ if (actions.length > this.maxActions) {
299
+ throw new ChannelRuntimeError('Plan contains too many actions', {
300
+ code: 'CHANNEL_ACTION_LIMIT_EXCEEDED',
301
+ statusCode: 400
302
+ });
303
+ }
304
+ const authorization = await this.authorize({
305
+ principal: resolution.principal,
306
+ envelope,
307
+ turn,
308
+ plan,
309
+ actions,
310
+ confirmed: false,
311
+ context
312
+ });
313
+ const classified = classifyActions(actions, authorization);
314
+ const approved = classified.filter(item => item.decision === 'approve').map(item => item.action);
315
+ const pendingActions = classified.filter(item => item.decision === 'confirm').map(item => item.action);
316
+ const results = classified
317
+ .filter(item => item.decision === 'reject')
318
+ .map(item => ({ action: item.action, status: 'rejected', message: item.reason }));
319
+ results.push(...await this._executeActions(
320
+ resolution.principal,
321
+ turn,
322
+ approved,
323
+ envelope,
324
+ emit,
325
+ context
326
+ ));
327
+
328
+ const output = await this.render({
329
+ channel,
330
+ envelope,
331
+ plan,
332
+ results,
333
+ pendingActions,
334
+ decision: null,
335
+ context
336
+ });
337
+ turn = await this.turnStore.transition(
338
+ turn,
339
+ pendingActions.length > 0 ? 'awaiting_confirmation' : 'completed',
340
+ {
341
+ plan,
342
+ output,
343
+ pendingActions,
344
+ results,
345
+ ...(pendingActions.length > 0
346
+ ? { expiresAt: new Date(this.now().getTime() + this.confirmationTtlMs).toISOString() }
347
+ : {})
348
+ },
349
+ storeContext
350
+ );
351
+ await emit({ type: 'turn', status: turn.status, turnId: turn.id });
352
+ return await this._result(adapter, turn, envelope, context, false);
353
+ } catch (error) {
354
+ await this.onError(error, { turn, envelope, scope });
355
+ if (TURN_TRANSITIONS[turn.status]?.includes('failed')) {
356
+ turn = await this.turnStore.transition(turn, 'failed', {
357
+ lastError: error.code || 'CHANNEL_PROCESSING_FAILED'
358
+ }, storeContext);
359
+ }
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ async _handleDecision({
365
+ adapter,
366
+ context,
367
+ decision,
368
+ emit,
369
+ envelope,
370
+ principal,
371
+ scope,
372
+ storeContext,
373
+ pendingTurn,
374
+ turn
375
+ }) {
376
+ turn = await this.turnStore.transition(turn, 'processing', {}, storeContext);
377
+ const pending = pendingTurn && await this.turnStore.claimPending({
378
+ ...scope, turnId: pendingTurn.id, pendingActions: pendingTurn.pendingActions
379
+ }, storeContext);
380
+ if (!pending) {
381
+ const output = await this.render({
382
+ channel: envelope.channel,
383
+ envelope,
384
+ plan: { content: 'There are no pending actions for this conversation.' },
385
+ results: [],
386
+ pendingActions: [],
387
+ decision,
388
+ context
389
+ });
390
+ turn = await this.turnStore.transition(turn, 'completed', { output, results: [] }, storeContext);
391
+ return await this._result(adapter, turn, envelope, context, false);
392
+ }
393
+
394
+ if (pending.id !== pendingTurn.id ||
395
+ pending.tenantKey !== scope.tenantKey || pending.actorKey !== scope.actorKey ||
396
+ pending.channel !== scope.channel || pending.threadKey !== scope.threadKey ||
397
+ pending.status !== 'executing' || pending.sourceKey === envelope.sourceKey ||
398
+ !(new Date(pending.expiresAt) > this.now()) ||
399
+ actionSignature(pending.pendingActions) !== actionSignature(pendingTurn.pendingActions)) {
400
+ throw new SecurityError('Pending action changed before confirmation');
401
+ }
402
+
403
+ if (decision === 'cancel') {
404
+ await this.turnStore.transition(pending, 'cancelled', {}, storeContext);
405
+ const output = await this.render({
406
+ channel: envelope.channel,
407
+ envelope,
408
+ plan: { content: 'The pending action was cancelled.' },
409
+ results: [],
410
+ pendingActions: [],
411
+ decision,
412
+ context
413
+ });
414
+ turn = await this.turnStore.transition(turn, 'completed', {
415
+ output,
416
+ results: [],
417
+ resolvedTurnId: pending.id
418
+ }, storeContext);
419
+ return await this._result(adapter, turn, envelope, context, false);
420
+ }
421
+
422
+ try {
423
+ const pendingActions = pending.pendingActions || [];
424
+ const authorization = await this.authorize({
425
+ principal,
426
+ envelope,
427
+ turn: pending,
428
+ plan: pending.plan,
429
+ actions: pendingActions,
430
+ confirmed: true,
431
+ context
432
+ });
433
+ const classified = classifyActions(pendingActions, authorization);
434
+ const executable = classified
435
+ .filter(item => item.decision !== 'reject')
436
+ .map(item => item.action);
437
+ const results = classified
438
+ .filter(item => item.decision === 'reject')
439
+ .map(item => ({ action: item.action, status: 'rejected', message: item.reason }));
440
+ results.push(...await this._executeActions(
441
+ principal,
442
+ pending,
443
+ executable,
444
+ envelope,
445
+ emit,
446
+ context,
447
+ { decision: 'confirm', turnId: pending.id, sourceKey: envelope.sourceKey }
448
+ ));
449
+
450
+ const output = await this.render({
451
+ channel: envelope.channel,
452
+ envelope,
453
+ plan: pending.plan,
454
+ results,
455
+ pendingActions: [],
456
+ decision,
457
+ context
458
+ });
459
+ await this.turnStore.transition(
460
+ pending,
461
+ 'completed',
462
+ { output, results, pendingActions: [] },
463
+ storeContext
464
+ );
465
+ turn = await this.turnStore.transition(turn, 'completed', {
466
+ output,
467
+ results,
468
+ resolvedTurnId: pending.id
469
+ }, storeContext);
470
+ await emit({ type: 'turn', status: turn.status, turnId: turn.id });
471
+ return await this._result(adapter, turn, envelope, context, false);
472
+ } catch (error) {
473
+ await this.turnStore.transition(pending, 'failed', {
474
+ lastError: error.code || 'CHANNEL_CONFIRMATION_FAILED'
475
+ }, storeContext);
476
+ throw error;
477
+ }
478
+ }
479
+
480
+ async _executeActions(principal, turn, actions, envelope, emit, context, confirmation = null) {
481
+ const results = [];
482
+ for (let index = 0; index < actions.length; index++) {
483
+ const action = actions[index];
484
+ await emit({ type: 'action', status: 'executing', action, turnId: turn.id });
485
+ try {
486
+ const execution = await this.execute({
487
+ principal,
488
+ action,
489
+ envelope,
490
+ turn,
491
+ context,
492
+ confirmation,
493
+ idempotencyKey: actionIdempotencyKey(turn, action, index)
494
+ });
495
+ const succeeded = !execution || !['error', 'failed', 'rejected'].includes(execution.status);
496
+ results.push(succeeded
497
+ ? { action, status: 'succeeded', result: execution?.result ?? execution }
498
+ : { action, status: 'failed', message: execution.message || 'Action failed' });
499
+ } catch (error) {
500
+ await this.onError(error, { turn, envelope, action });
501
+ results.push({ action, status: 'failed', message: 'Action failed' });
502
+ }
503
+ await emit({ type: 'action', ...results.at(-1), turnId: turn.id });
504
+ }
505
+ return results;
506
+ }
507
+
508
+ async _result(adapter, turn, envelope, context, duplicate) {
509
+ const response = typeof adapter.format === 'function'
510
+ ? await adapter.format(turn.output || {}, { envelope, turn, context, duplicate })
511
+ : turn.output;
512
+ return { turn, output: turn.output || null, response, duplicate };
513
+ }
514
+ }
515
+
516
+ module.exports = {
517
+ ChannelRuntime,
518
+ ChannelRuntimeError,
519
+ EnvelopeValidationError,
520
+ SecurityError,
521
+ RateLimitError,
522
+ TURN_STATUSES,
523
+ TURN_TRANSITIONS,
524
+ parseDecision
525
+ };
package/src/twilio.js ADDED
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { EnvelopeValidationError } = require('./runtime');
5
+
6
+ function loadTwilio(injected) {
7
+ if (injected) return injected;
8
+ return require('twilio');
9
+ }
10
+
11
+ function signatureFrom(context) {
12
+ const headers = context.headers || {};
13
+ const key = Object.keys(headers).find(name => name.toLowerCase() === 'x-twilio-signature');
14
+ return key ? headers[key] : null;
15
+ }
16
+
17
+ function addTurnKey(url, turnKey) {
18
+ const parsed = new URL(url);
19
+ parsed.searchParams.set('turnKey', turnKey);
20
+ return parsed.toString();
21
+ }
22
+
23
+ function nextVoiceTurnKey(turn) {
24
+ if (!turn?.id) throw new EnvelopeValidationError('Voice response requires a persisted turn');
25
+ return crypto.createHash('sha256').update(String(turn.id)).digest('hex').slice(0, 32);
26
+ }
27
+
28
+ function voiceInput(body) {
29
+ if (typeof body.SpeechResult === 'string') return body.SpeechResult;
30
+ if (typeof body.Digits === 'string') return body.Digits;
31
+ return '';
32
+ }
33
+
34
+ function createTwilioChannels({
35
+ authToken,
36
+ accountSid = null,
37
+ twilio: injectedTwilio = null,
38
+ client: injectedClient = null,
39
+ messagingServiceSid = null,
40
+ smsFrom = null,
41
+ voiceFrom = null,
42
+ voiceActionUrl = null,
43
+ voice = 'Polly.Joanna',
44
+ language = 'en-US',
45
+ gatherPrompt = 'How else can I help?'
46
+ } = {}) {
47
+ if (typeof authToken !== 'string' || authToken === '') {
48
+ throw new TypeError('Twilio authToken is required');
49
+ }
50
+ const twilio = loadTwilio(injectedTwilio);
51
+
52
+ async function authenticate(envelope, context) {
53
+ const signature = signatureFrom(context);
54
+ const url = context.url;
55
+ if (!signature || typeof url !== 'string' || url === '') {
56
+ return { allowed: false, provider: 'twilio', reason: 'Missing Twilio signature context' };
57
+ }
58
+ const allowed = twilio.validateRequest(authToken, signature, url, context.params || {});
59
+ return {
60
+ allowed,
61
+ method: 'twilio-signature',
62
+ provider: 'twilio',
63
+ accountKey: envelope.providerAccountKey,
64
+ ...(allowed ? {} : { reason: 'Invalid Twilio signature' })
65
+ };
66
+ }
67
+
68
+ const sms = {
69
+ async normalize(payload) {
70
+ const mediaCount = Number.parseInt(payload?.NumMedia || '0', 10) || 0;
71
+ const attachments = Array.from({ length: mediaCount }, (_, index) => ({
72
+ url: payload[`MediaUrl${index}`],
73
+ contentType: payload[`MediaContentType${index}`] || null
74
+ }));
75
+ return {
76
+ channel: 'sms',
77
+ sourceKey: payload?.MessageSid || payload?.SmsSid || '',
78
+ threadKey: payload?.From && payload?.To ? `${payload.From}:${payload.To}` : '',
79
+ sender: payload?.From || null,
80
+ recipient: payload?.To || null,
81
+ text: payload?.Body || '',
82
+ attachments,
83
+ providerAccountKey: payload?.AccountSid || null,
84
+ metadata: {
85
+ messagingServiceSid: payload?.MessagingServiceSid || null,
86
+ numSegments: payload?.NumSegments || null
87
+ }
88
+ };
89
+ },
90
+ authenticate,
91
+ async format(output) {
92
+ const response = new twilio.twiml.MessagingResponse();
93
+ response.message(output.content || '');
94
+ return { contentType: 'text/xml', body: response.toString() };
95
+ }
96
+ };
97
+
98
+ const voiceChannel = {
99
+ async normalize(payload, context) {
100
+ const text = voiceInput(payload || {});
101
+ const turnKey = context.turnKey || payload?.EventSid || payload?.SequenceNumber;
102
+ if (text && !turnKey) {
103
+ throw new EnvelopeValidationError('Twilio Voice speech callbacks require a turnKey');
104
+ }
105
+ return {
106
+ channel: 'voice',
107
+ sourceKey: payload?.CallSid ? `${payload.CallSid}:${turnKey || 'initial'}` : '',
108
+ threadKey: payload?.CallSid || '',
109
+ sender: payload?.From || null,
110
+ recipient: payload?.To || null,
111
+ text,
112
+ attachments: [],
113
+ providerAccountKey: payload?.AccountSid || null,
114
+ metadata: {
115
+ callStatus: payload?.CallStatus || null,
116
+ confidence: payload?.Confidence ?? null
117
+ }
118
+ };
119
+ },
120
+ authenticate,
121
+ async format(output, { envelope, turn, context }) {
122
+ const response = new twilio.twiml.VoiceResponse();
123
+ if (output.content) response.say({ voice, language }, output.content);
124
+ if (output.endConversation) {
125
+ response.hangup();
126
+ } else {
127
+ const baseActionUrl = typeof voiceActionUrl === 'function'
128
+ ? await voiceActionUrl({ envelope, context })
129
+ : voiceActionUrl;
130
+ if (!baseActionUrl) throw new EnvelopeValidationError('voiceActionUrl is required');
131
+ const gather = response.gather({
132
+ input: 'speech dtmf',
133
+ action: addTurnKey(baseActionUrl, nextVoiceTurnKey(turn)),
134
+ method: 'POST',
135
+ actionOnEmptyResult: true,
136
+ speechTimeout: 'auto'
137
+ });
138
+ gather.say({ voice, language }, gatherPrompt);
139
+ }
140
+ return { contentType: 'text/xml', body: response.toString() };
141
+ }
142
+ };
143
+
144
+ function getClient() {
145
+ if (injectedClient) return injectedClient;
146
+ if (!accountSid) throw new TypeError('Twilio accountSid is required for outbound calls');
147
+ return twilio(accountSid, authToken);
148
+ }
149
+
150
+ async function sendSms({ to, body, from = smsFrom, statusCallback = null }) {
151
+ if (!to || !body) throw new TypeError('SMS to and body are required');
152
+ if (!from && !messagingServiceSid) {
153
+ throw new TypeError('smsFrom or messagingServiceSid is required');
154
+ }
155
+ return await getClient().messages.create({
156
+ to,
157
+ body,
158
+ ...(messagingServiceSid ? { messagingServiceSid } : { from }),
159
+ ...(statusCallback ? { statusCallback } : {})
160
+ });
161
+ }
162
+
163
+ async function startCall({ to, url, from = voiceFrom, statusCallback = null }) {
164
+ if (!to || !from || !url) throw new TypeError('Voice to, from, and url are required');
165
+ return await getClient().calls.create({
166
+ to,
167
+ from,
168
+ url,
169
+ ...(statusCallback ? { statusCallback } : {})
170
+ });
171
+ }
172
+
173
+ return { sms, voice: voiceChannel, sendSms, startCall };
174
+ }
175
+
176
+ module.exports = { createTwilioChannels };