@tiledesk/tiledesk-voice-twilio-connector 0.1.28 → 0.2.0-rc3
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/LICENSE +179 -0
- package/README.md +44 -0
- package/index.js +7 -1562
- package/package.json +23 -22
- package/src/app.js +146 -0
- package/src/config/index.js +32 -0
- package/src/controllers/VoiceController.js +488 -0
- package/src/controllers/VoiceController.original.js +811 -0
- package/src/middlewares/httpLogger.js +31 -0
- package/src/models/KeyValueStore.js +78 -0
- package/src/routes/manageApp.js +298 -0
- package/src/routes/voice.js +22 -0
- package/src/services/AiService.js +219 -0
- package/src/services/AiService.sdk.js +367 -0
- package/src/services/IntegrationService.js +74 -0
- package/src/services/MessageService.js +133 -0
- package/src/services/README_SDK.md +107 -0
- package/src/services/SessionService.js +143 -0
- package/src/services/SpeechService.js +134 -0
- package/src/services/TiledeskMessageBuilder.js +135 -0
- package/src/services/TwilioService.js +122 -0
- package/src/services/UploadService.js +78 -0
- package/src/services/channels/TiledeskChannel.js +269 -0
- package/{tiledesk → src/services/channels}/VoiceChannel.js +17 -56
- package/src/services/clients/TiledeskSubscriptionClient.js +78 -0
- package/src/services/index.js +45 -0
- package/src/services/translators/TiledeskTwilioTranslator.js +509 -0
- package/{tiledesk/TiledeskTwilioTranslator.js → src/services/translators/TiledeskTwilioTranslator.original.js} +119 -212
- package/src/utils/fileUtils.js +24 -0
- package/src/utils/logger.js +32 -0
- package/{tiledesk → src/utils}/utils-message.js +6 -21
- package/logs/app.log +0 -3082
- package/routes/manageApp.js +0 -419
- package/tiledesk/KVBaseMongo.js +0 -101
- package/tiledesk/TiledeskChannel.js +0 -363
- package/tiledesk/TiledeskSubscriptionClient.js +0 -135
- package/tiledesk/fileUtils.js +0 -55
- package/tiledesk/services/AiService.js +0 -230
- package/tiledesk/services/IntegrationService.js +0 -81
- package/tiledesk/services/UploadService.js +0 -88
- /package/{winston.js → src/config/logger.js} +0 -0
- /package/{tiledesk → src}/services/voiceEventEmitter.js +0 -0
- /package/{template → src/template}/configure.html +0 -0
- /package/{template → src/template}/css/configure.css +0 -0
- /package/{template → src/template}/css/error.css +0 -0
- /package/{template → src/template}/css/style.css +0 -0
- /package/{template → src/template}/error.html +0 -0
- /package/{tiledesk → src/utils}/constants.js +0 -0
- /package/{tiledesk → src/utils}/errors.js +0 -0
- /package/{tiledesk → src/utils}/utils.js +0 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TiledeskTwilioTranslator - Refactored Version
|
|
3
|
+
*
|
|
4
|
+
* Uses Twilio's VoiceResponse for TwiML generation instead of manual XML.
|
|
5
|
+
* Benefits: Type safety, auto-validation, better error messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { twiml } = require('twilio');
|
|
9
|
+
const querystring = require('querystring');
|
|
10
|
+
|
|
11
|
+
const logger = require('../../utils/logger');
|
|
12
|
+
const utils = require('../../utils/utils');
|
|
13
|
+
const utilsMessage = require('../../utils/utils-message');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
SETTING_MESSAGE,
|
|
17
|
+
WAIT_MESSAGE,
|
|
18
|
+
TEXT_MESSAGE,
|
|
19
|
+
VOICE_PROVIDER,
|
|
20
|
+
OPENAI_SETTINGS,
|
|
21
|
+
ELEVENLABS_SETTINGS,
|
|
22
|
+
TYPE_ACTION_VXML,
|
|
23
|
+
TYPE_MESSAGE,
|
|
24
|
+
INFO_MESSAGE_TYPE
|
|
25
|
+
} = require('../../utils/constants');
|
|
26
|
+
|
|
27
|
+
const voiceEventEmitter = require('../voiceEventEmitter');
|
|
28
|
+
const { SttError } = require('../../utils/errors');
|
|
29
|
+
|
|
30
|
+
class TiledeskTwilioTranslator {
|
|
31
|
+
constructor(config) {
|
|
32
|
+
if (!config.BASE_URL) {
|
|
33
|
+
throw new Error('[TiledeskTwilioTranslator] config.BASE_URL is mandatory');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.BASE_URL = config.BASE_URL;
|
|
37
|
+
this.aiService = config.aiService;
|
|
38
|
+
this.uploadService = config.uploadService;
|
|
39
|
+
this.lastCallSidVerb = {};
|
|
40
|
+
this.voiceProvider = VOICE_PROVIDER.TWILIO;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Main entry point: Convert Tiledesk message to TwiML
|
|
45
|
+
*/
|
|
46
|
+
async toVXML(msg, id, vxmlAttributes, sessionInfo) {
|
|
47
|
+
vxmlAttributes.intentName = msg.attributes?.intentName || '';
|
|
48
|
+
vxmlAttributes = this._processVoiceAttributes(msg, vxmlAttributes);
|
|
49
|
+
|
|
50
|
+
// Handle close/hangup
|
|
51
|
+
if (this._shouldHangup(msg)) {
|
|
52
|
+
return this._createHangupResponse();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const commands = msg.attributes?.commands;
|
|
56
|
+
if (!commands?.length) {
|
|
57
|
+
return this._createEmptyResponse();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set VXML attributes from commands
|
|
61
|
+
this._setAttributesFromCommands(commands, vxmlAttributes);
|
|
62
|
+
|
|
63
|
+
// Route to appropriate converter based on message type
|
|
64
|
+
return this._routeToConverter(msg, vxmlAttributes, sessionInfo);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Route message to the appropriate TwiML converter
|
|
69
|
+
*/
|
|
70
|
+
async _routeToConverter(msg, vxmlAttributes, sessionInfo) {
|
|
71
|
+
const converters = [
|
|
72
|
+
{ check: () => this._isWaitMessage(msg), convert: () => this._convertDelay(msg, vxmlAttributes, sessionInfo) },
|
|
73
|
+
{ check: () => this._hasSubType(msg, TYPE_ACTION_VXML.DTMF_FORM), convert: () => this._convertDtmfForm(msg, vxmlAttributes, sessionInfo) },
|
|
74
|
+
{ check: () => this._hasSubType(msg, TYPE_ACTION_VXML.BLIND_TRANSFER), convert: () => this._convertBlindTransfer(msg, vxmlAttributes, sessionInfo) },
|
|
75
|
+
{ check: () => this._hasSubType(msg, TYPE_ACTION_VXML.DTMF_MENU), convert: () => this._convertMenu(msg, vxmlAttributes, sessionInfo) },
|
|
76
|
+
{ check: () => this._hasSubType(msg, TYPE_ACTION_VXML.SPEECH_FORM), convert: () => this._convertSpeechForm(msg, vxmlAttributes, sessionInfo) },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const { check, convert } of converters) {
|
|
80
|
+
if (check()) {
|
|
81
|
+
return convert();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Default: Play prompt
|
|
86
|
+
return this._convertPlayPrompt(msg, vxmlAttributes, sessionInfo);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ==================== Message Type Checks ====================
|
|
90
|
+
|
|
91
|
+
_shouldHangup(msg) {
|
|
92
|
+
const isInfoSupport = utilsMessage.messageType(TYPE_MESSAGE.INFO_SUPPORT, msg);
|
|
93
|
+
return isInfoSupport && utilsMessage.infoMessageType(msg) === INFO_MESSAGE_TYPE.CHAT_CLOSED;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_hasSubType(msg, subType) {
|
|
97
|
+
const settings = msg.attributes?.commands?.find(cmd => cmd.type === SETTING_MESSAGE);
|
|
98
|
+
return settings?.subType === subType;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_isWaitMessage(msg) {
|
|
102
|
+
const commands = msg.attributes?.commands;
|
|
103
|
+
if (!commands) return false;
|
|
104
|
+
const hasWait = commands.some(cmd => cmd.type === WAIT_MESSAGE);
|
|
105
|
+
const hasMessage = commands.some(cmd => cmd.type === TEXT_MESSAGE);
|
|
106
|
+
return hasWait && !hasMessage;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ==================== Voice Attributes Management ====================
|
|
110
|
+
|
|
111
|
+
_processVoiceAttributes(msg, vxmlAttributes) {
|
|
112
|
+
const flowAttrs = msg.attributes?.flowAttributes;
|
|
113
|
+
if (!flowAttrs) {
|
|
114
|
+
voiceEventEmitter.emit('saveSettings', vxmlAttributes);
|
|
115
|
+
return vxmlAttributes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Copy flow attributes to vxml attributes
|
|
119
|
+
Object.keys(vxmlAttributes).forEach(key => {
|
|
120
|
+
if (flowAttrs[key]) {
|
|
121
|
+
vxmlAttributes[key] = flowAttrs[key];
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Set voice provider with defaults
|
|
126
|
+
this.voiceProvider = flowAttrs.VOICE_PROVIDER || VOICE_PROVIDER.TWILIO;
|
|
127
|
+
vxmlAttributes.VOICE_PROVIDER = this.voiceProvider;
|
|
128
|
+
|
|
129
|
+
// Apply provider-specific defaults
|
|
130
|
+
this._applyProviderDefaults(vxmlAttributes, flowAttrs);
|
|
131
|
+
|
|
132
|
+
logger.debug('[TiledeskTwilioTranslator] Processed vxmlAttributes:', vxmlAttributes);
|
|
133
|
+
voiceEventEmitter.emit('saveSettings', vxmlAttributes);
|
|
134
|
+
|
|
135
|
+
return vxmlAttributes;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_applyProviderDefaults(attrs, flowAttrs) {
|
|
139
|
+
const providerDefaults = {
|
|
140
|
+
[VOICE_PROVIDER.OPENAI]: OPENAI_SETTINGS,
|
|
141
|
+
[VOICE_PROVIDER.ELEVENLABS]: ELEVENLABS_SETTINGS
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const defaults = providerDefaults[this.voiceProvider];
|
|
145
|
+
if (!defaults) return;
|
|
146
|
+
|
|
147
|
+
attrs.TTS_VOICE_NAME = flowAttrs.TTS_VOICE_NAME || defaults.TTS_VOICE_NAME;
|
|
148
|
+
attrs.TTS_MODEL = flowAttrs.TTS_MODEL || defaults.TTS_MODEL;
|
|
149
|
+
attrs.STT_MODEL = flowAttrs.STT_MODEL || defaults.STT_MODEL;
|
|
150
|
+
|
|
151
|
+
if (this.voiceProvider === VOICE_PROVIDER.ELEVENLABS) {
|
|
152
|
+
attrs.TTS_VOICE_LANGUAGE = flowAttrs.TTS_VOICE_LANGUAGE || defaults.TTS_VOICE_LANGUAGE;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_setAttributesFromCommands(commands, attributes) {
|
|
157
|
+
const settingsCommand = commands.slice(-1)[0];
|
|
158
|
+
if (settingsCommand?.settings) {
|
|
159
|
+
Object.assign(attributes, settingsCommand.settings);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ==================== TwiML Converters ====================
|
|
164
|
+
|
|
165
|
+
_createHangupResponse() {
|
|
166
|
+
const response = new twiml.VoiceResponse();
|
|
167
|
+
response.hangup();
|
|
168
|
+
return response.toString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_createEmptyResponse() {
|
|
172
|
+
const response = new twiml.VoiceResponse();
|
|
173
|
+
return response.toString();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async _convertDelay(msg, attrs, sessionInfo) {
|
|
177
|
+
const response = new twiml.VoiceResponse();
|
|
178
|
+
await this._addPromptElements(response, msg, attrs, sessionInfo);
|
|
179
|
+
response.redirect({ method: 'POST' }, `${this.BASE_URL}/nextblock/${attrs.callSid}`);
|
|
180
|
+
return response.toString();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async _convertPlayPrompt(msg, attrs, sessionInfo) {
|
|
184
|
+
const response = new twiml.VoiceResponse();
|
|
185
|
+
const settings = msg.attributes?.commands?.find(cmd => cmd.type === SETTING_MESSAGE)?.settings;
|
|
186
|
+
const bargeIn = settings?.bargein === true;
|
|
187
|
+
|
|
188
|
+
if (bargeIn) {
|
|
189
|
+
// Wrap prompt in Gather to capture user input when barge-in occurs
|
|
190
|
+
const intentQuery = this._buildIntentQuery(attrs);
|
|
191
|
+
const bargeInInput = 'speech dtmf'; // Default to both
|
|
192
|
+
const noInputNoMatch = this._getNoInputNoMatchActions(msg);
|
|
193
|
+
const supportsSpeech = bargeInInput.includes('speech');
|
|
194
|
+
|
|
195
|
+
const gatherOptions = {
|
|
196
|
+
input: bargeInInput,
|
|
197
|
+
action: `${this.BASE_URL}/nextBlock/${attrs.callSid}?${intentQuery}&bargein=true`,
|
|
198
|
+
method: 'POST',
|
|
199
|
+
language: attrs.TTS_VOICE_LANGUAGE
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Apply speech-specific options only if speech is enabled
|
|
203
|
+
if (supportsSpeech) {
|
|
204
|
+
gatherOptions.speechTimeout = 'auto';
|
|
205
|
+
gatherOptions.enhanced = true;
|
|
206
|
+
if (attrs.incompleteSpeechTimeout) {
|
|
207
|
+
gatherOptions.speechTimeout = Math.round(attrs.incompleteSpeechTimeout / 1000);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Apply timeout if specified (for DTMF or as fallback)
|
|
212
|
+
if (attrs.noInputTimeout) {
|
|
213
|
+
gatherOptions.timeout = Math.round(attrs.noInputTimeout / 1000);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const gather = response.gather(gatherOptions);
|
|
217
|
+
await this._addPromptElements(gather, msg, attrs, sessionInfo, true); // Pass bargeIn flag
|
|
218
|
+
|
|
219
|
+
// Handle no input/no match scenarios
|
|
220
|
+
if (noInputNoMatch?.queryNoInput) {
|
|
221
|
+
response.redirect({ method: 'POST' },
|
|
222
|
+
`${this.BASE_URL}/handle/${attrs.callSid}/no_input?${noInputNoMatch.queryNoInput}`);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// No barge-in: simple play prompt
|
|
226
|
+
await this._addPromptElements(response, msg, attrs, sessionInfo, false);
|
|
227
|
+
response.redirect({ method: 'POST' }, `${this.BASE_URL}/nextblock/${attrs.callSid}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return response.toString();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async _convertSpeechForm(msg, attrs, sessionInfo) {
|
|
234
|
+
const response = new twiml.VoiceResponse();
|
|
235
|
+
const intentQuery = this._buildIntentQuery(attrs);
|
|
236
|
+
const noInputNoMatch = this._getNoInputNoMatchActions(msg);
|
|
237
|
+
|
|
238
|
+
if (this.voiceProvider === VOICE_PROVIDER.TWILIO) {
|
|
239
|
+
const gatherOptions = {
|
|
240
|
+
input: 'speech',
|
|
241
|
+
action: `${this.BASE_URL}/nextBlock/${attrs.callSid}?${intentQuery}`,
|
|
242
|
+
method: 'POST',
|
|
243
|
+
language: attrs.TTS_VOICE_LANGUAGE,
|
|
244
|
+
speechTimeout: 'auto',
|
|
245
|
+
enhanced: true
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (attrs.incompleteSpeechTimeout) {
|
|
249
|
+
gatherOptions.speechTimeout = Math.round(attrs.incompleteSpeechTimeout / 1000);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const gather = response.gather(gatherOptions);
|
|
253
|
+
await this._addPromptElements(gather, msg, attrs, sessionInfo);
|
|
254
|
+
|
|
255
|
+
if (noInputNoMatch?.queryNoInput) {
|
|
256
|
+
response.redirect({ method: 'POST' },
|
|
257
|
+
`${this.BASE_URL}/handle/${attrs.callSid}/no_input?${noInputNoMatch.queryNoInput}`);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// OpenAI/ElevenLabs: Use Record
|
|
261
|
+
await this._addPromptElements(response, msg, attrs, sessionInfo);
|
|
262
|
+
|
|
263
|
+
let queryUrl = intentQuery;
|
|
264
|
+
if (noInputNoMatch?.queryNoInput) {
|
|
265
|
+
queryUrl += `&${noInputNoMatch.queryNoInput}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
response.record({
|
|
269
|
+
action: `${this.BASE_URL}/record/action/${attrs.callSid}?${queryUrl}`,
|
|
270
|
+
method: 'POST',
|
|
271
|
+
playBeep: false,
|
|
272
|
+
trim: 'trim-silence',
|
|
273
|
+
timeout: 2,
|
|
274
|
+
recordingStatusCallback: `${this.BASE_URL}/record/callback/${attrs.callSid}?${queryUrl}`,
|
|
275
|
+
recordingStatusCallbackMethod: 'POST'
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return response.toString();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async _convertMenu(msg, attrs, sessionInfo) {
|
|
283
|
+
const response = new twiml.VoiceResponse();
|
|
284
|
+
const options = this._getButtonsFromMessage(msg);
|
|
285
|
+
const menuOptions = options.map(o => `${o.value}:${o.action.substring(1)}`).join(';') + ';';
|
|
286
|
+
const noInputNoMatch = this._getNoInputNoMatchActions(msg);
|
|
287
|
+
|
|
288
|
+
let queryUrl = `${this._buildIntentQuery(attrs)}&menu_options=${menuOptions}`;
|
|
289
|
+
if (noInputNoMatch?.queryNoMatch) {
|
|
290
|
+
queryUrl += `&${noInputNoMatch.queryNoMatch}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const gather = response.gather({
|
|
294
|
+
input: 'dtmf',
|
|
295
|
+
timeout: Math.round(attrs.noInputTimeout / 1000),
|
|
296
|
+
numDigits: 1,
|
|
297
|
+
action: `${this.BASE_URL}/menublock/${attrs.callSid}?${queryUrl}`,
|
|
298
|
+
method: 'POST',
|
|
299
|
+
language: attrs.TTS_VOICE_LANGUAGE
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await this._addPromptElements(gather, msg, attrs, sessionInfo);
|
|
303
|
+
|
|
304
|
+
if (noInputNoMatch?.queryNoInput) {
|
|
305
|
+
response.redirect({},
|
|
306
|
+
`${this.BASE_URL}/handle/${attrs.callSid}/no_input?${noInputNoMatch.queryNoInput}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return response.toString();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async _convertDtmfForm(msg, attrs, sessionInfo) {
|
|
313
|
+
const response = new twiml.VoiceResponse();
|
|
314
|
+
const noInputNoMatch = this._getNoInputNoMatchActions(msg);
|
|
315
|
+
const intentQuery = this._buildIntentQuery(attrs);
|
|
316
|
+
|
|
317
|
+
const gatherOptions = {
|
|
318
|
+
input: 'dtmf',
|
|
319
|
+
timeout: Math.round(attrs.noInputTimeout / 1000),
|
|
320
|
+
action: `${this.BASE_URL}/menublock/${attrs.callSid}?${intentQuery}`,
|
|
321
|
+
method: 'POST',
|
|
322
|
+
language: attrs.TTS_VOICE_LANGUAGE
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Apply DTMF-specific settings
|
|
326
|
+
this._applyDtmfSettings(msg, gatherOptions, attrs);
|
|
327
|
+
|
|
328
|
+
const gather = response.gather(gatherOptions);
|
|
329
|
+
await this._addPromptElements(gather, msg, attrs, sessionInfo);
|
|
330
|
+
|
|
331
|
+
if (noInputNoMatch?.queryNoInput) {
|
|
332
|
+
response.redirect({ method: 'POST' },
|
|
333
|
+
`${this.BASE_URL}/handle/${attrs.callSid}/no_input?${noInputNoMatch.queryNoInput}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return response.toString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async _convertBlindTransfer(msg, attrs, sessionInfo) {
|
|
340
|
+
const response = new twiml.VoiceResponse();
|
|
341
|
+
await this._addPromptElements(response, msg, attrs, sessionInfo);
|
|
342
|
+
|
|
343
|
+
const lastCommand = msg.attributes.commands.slice(-1)[0];
|
|
344
|
+
const settings = lastCommand?.settings;
|
|
345
|
+
|
|
346
|
+
if (!settings?.transferTo) {
|
|
347
|
+
return response.toString();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const dial = response.dial();
|
|
351
|
+
let queryUrl = this._buildIntentQuery(attrs);
|
|
352
|
+
|
|
353
|
+
if (settings.trueIntent) {
|
|
354
|
+
queryUrl += `&button_success=${settings.trueIntent.substring(1)}`;
|
|
355
|
+
}
|
|
356
|
+
if (settings.falseIntent) {
|
|
357
|
+
queryUrl += `&button_failure=${settings.falseIntent.substring(1)}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const isPhoneNumber = /^.*\d.*$/.test(settings.transferTo);
|
|
361
|
+
if (isPhoneNumber) {
|
|
362
|
+
dial.number({
|
|
363
|
+
statusCallbackEvent: 'initiated ringing answered completed',
|
|
364
|
+
statusCallback: `${this.BASE_URL}/event/${attrs.callSid}/transfer?${queryUrl}`,
|
|
365
|
+
statusCallbackMethod: 'POST'
|
|
366
|
+
}, settings.transferTo);
|
|
367
|
+
} else {
|
|
368
|
+
dial.sip({}, `sip:${settings.transferTo}${attrs.uriTransferParameters || ''}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return response.toString();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ==================== Prompt Elements ====================
|
|
375
|
+
|
|
376
|
+
async _addPromptElements(element, msg, attrs, sessionInfo, bargeIn = false) {
|
|
377
|
+
const commands = msg.attributes?.commands || [];
|
|
378
|
+
|
|
379
|
+
for (const command of commands) {
|
|
380
|
+
if (command.type === 'message') {
|
|
381
|
+
await this._addMessageElement(element, command, attrs, sessionInfo, bargeIn);
|
|
382
|
+
} else if (command.type === 'wait' && command.time > 0) {
|
|
383
|
+
element.pause({ length: command.time / 1000 });
|
|
384
|
+
this.lastCallSidVerb[attrs.callSid] = 'pause';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async _addMessageElement(element, command, attrs, sessionInfo, bargeIn = false) {
|
|
390
|
+
const { message } = command;
|
|
391
|
+
|
|
392
|
+
if (message.type === 'text') {
|
|
393
|
+
const text = utils.markdownToTwilioSpeech(message.text);
|
|
394
|
+
|
|
395
|
+
if (this.voiceProvider !== VOICE_PROVIDER.TWILIO) {
|
|
396
|
+
const audioUrl = await this._generateTTS(text, attrs, sessionInfo);
|
|
397
|
+
if (audioUrl) {
|
|
398
|
+
const playOptions = {};
|
|
399
|
+
if (bargeIn) {
|
|
400
|
+
playOptions.bargeIn = true;
|
|
401
|
+
}
|
|
402
|
+
element.play(playOptions, audioUrl);
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
const sayOptions = { voice: attrs.TTS_VOICE_NAME, language: attrs.TTS_VOICE_LANGUAGE };
|
|
406
|
+
if (bargeIn) {
|
|
407
|
+
sayOptions.bargeIn = true;
|
|
408
|
+
}
|
|
409
|
+
element.say(sayOptions, text);
|
|
410
|
+
this.lastCallSidVerb[attrs.callSid] = 'say';
|
|
411
|
+
}
|
|
412
|
+
} else if (message.type === 'frame' && message.metadata?.src) {
|
|
413
|
+
|
|
414
|
+
const playOptions = { loop: 0 };
|
|
415
|
+
if (bargeIn) {
|
|
416
|
+
playOptions.bargeIn = true;
|
|
417
|
+
}
|
|
418
|
+
element.play(playOptions, message.metadata.src);
|
|
419
|
+
this.lastCallSidVerb[attrs.callSid] = 'play';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ==================== TTS Generation ====================
|
|
424
|
+
|
|
425
|
+
async _generateTTS(text, attrs, sessionInfo) {
|
|
426
|
+
try {
|
|
427
|
+
const audioData = await this._callTTSProvider(text, attrs, sessionInfo);
|
|
428
|
+
|
|
429
|
+
if (!audioData) {
|
|
430
|
+
throw new SttError('TTS_FAILED', 'TTS returned no audio data');
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const fileUrl = await this.uploadService.upload(attrs.callSid, audioData, sessionInfo.user);
|
|
434
|
+
logger.debug('[TiledeskTwilioTranslator] TTS audio URL:', fileUrl);
|
|
435
|
+
return fileUrl;
|
|
436
|
+
} catch (error) {
|
|
437
|
+
logger.error('[TiledeskTwilioTranslator] TTS generation error:', error);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async _callTTSProvider(text, attrs, sessionInfo) {
|
|
443
|
+
const providers = {
|
|
444
|
+
[VOICE_PROVIDER.OPENAI]: () => {
|
|
445
|
+
const key = sessionInfo.integrations.find(i => i.type === VOICE_PROVIDER.OPENAI)?.key;
|
|
446
|
+
return this.aiService.textToSpeech(text, attrs.TTS_VOICE_NAME, attrs.TTS_MODEL, key);
|
|
447
|
+
},
|
|
448
|
+
[VOICE_PROVIDER.ELEVENLABS]: () => {
|
|
449
|
+
const key = sessionInfo.integrations.find(i => i.type === VOICE_PROVIDER.ELEVENLABS)?.key;
|
|
450
|
+
return this.aiService.textToSpeechElevenLabs(
|
|
451
|
+
text, attrs.TTS_VOICE_NAME, attrs.TTS_MODEL, attrs.TTS_VOICE_LANGUAGE, key
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const provider = providers[this.voiceProvider];
|
|
457
|
+
if (!provider) {
|
|
458
|
+
throw new SttError('TTS_FAILED', `Unsupported voice provider: ${this.voiceProvider}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return provider();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ==================== Helpers ====================
|
|
465
|
+
|
|
466
|
+
_buildIntentQuery(attrs) {
|
|
467
|
+
return `intentName=${querystring.encode(attrs.intentName)}&previousIntentTimestamp=${Date.now()}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_getNoInputNoMatchActions(msg) {
|
|
471
|
+
const settings = msg.attributes?.commands?.find(cmd => cmd.type === SETTING_MESSAGE)?.settings;
|
|
472
|
+
if (!settings) return null;
|
|
473
|
+
|
|
474
|
+
const { noInputIntent, noMatchIntent } = settings;
|
|
475
|
+
return {
|
|
476
|
+
queryNoInput: noInputIntent ? `button_action=${noInputIntent.substring(1)}` : '',
|
|
477
|
+
queryNoMatch: noMatchIntent ? `button_action=${noMatchIntent.substring(1)}` : ''
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
_getButtonsFromMessage(msg) {
|
|
482
|
+
const command = msg.attributes?.commands?.find(cmd =>
|
|
483
|
+
cmd.type === 'message' && cmd.message?.attributes?.attachment?.buttons
|
|
484
|
+
);
|
|
485
|
+
const buttons = command?.message?.attributes?.attachment?.buttons || [];
|
|
486
|
+
return buttons.filter(b => b.type === 'action');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_applyDtmfSettings(msg, gatherOptions, attrs) {
|
|
490
|
+
const settings = msg.attributes?.commands?.find(cmd => cmd.type === SETTING_MESSAGE)?.settings;
|
|
491
|
+
if (!settings) return;
|
|
492
|
+
|
|
493
|
+
if (settings.maxDigits) {
|
|
494
|
+
gatherOptions.numDigits = settings.maxDigits;
|
|
495
|
+
attrs.maxDigits = settings.maxDigits;
|
|
496
|
+
}
|
|
497
|
+
if (settings.terminators) {
|
|
498
|
+
gatherOptions.finishOnKey = settings.terminators;
|
|
499
|
+
attrs.terminators = settings.terminators;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Legacy method for backwards compatibility
|
|
504
|
+
toTiledesk(vxmlMessage) {
|
|
505
|
+
logger.debug('[TiledeskTwilioTranslator] toTiledesk:', vxmlMessage);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
module.exports = { TiledeskTwilioTranslator };
|