@bliggy/chat-runtime 0.0.0-stage → 0.3.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/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 };