@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.
Files changed (50) hide show
  1. package/LICENSE +179 -0
  2. package/README.md +44 -0
  3. package/index.js +7 -1562
  4. package/package.json +23 -22
  5. package/src/app.js +146 -0
  6. package/src/config/index.js +32 -0
  7. package/src/controllers/VoiceController.js +488 -0
  8. package/src/controllers/VoiceController.original.js +811 -0
  9. package/src/middlewares/httpLogger.js +31 -0
  10. package/src/models/KeyValueStore.js +78 -0
  11. package/src/routes/manageApp.js +298 -0
  12. package/src/routes/voice.js +22 -0
  13. package/src/services/AiService.js +219 -0
  14. package/src/services/AiService.sdk.js +367 -0
  15. package/src/services/IntegrationService.js +74 -0
  16. package/src/services/MessageService.js +133 -0
  17. package/src/services/README_SDK.md +107 -0
  18. package/src/services/SessionService.js +143 -0
  19. package/src/services/SpeechService.js +134 -0
  20. package/src/services/TiledeskMessageBuilder.js +135 -0
  21. package/src/services/TwilioService.js +122 -0
  22. package/src/services/UploadService.js +78 -0
  23. package/src/services/channels/TiledeskChannel.js +269 -0
  24. package/{tiledesk → src/services/channels}/VoiceChannel.js +17 -56
  25. package/src/services/clients/TiledeskSubscriptionClient.js +78 -0
  26. package/src/services/index.js +45 -0
  27. package/src/services/translators/TiledeskTwilioTranslator.js +509 -0
  28. package/{tiledesk/TiledeskTwilioTranslator.js → src/services/translators/TiledeskTwilioTranslator.original.js} +119 -212
  29. package/src/utils/fileUtils.js +24 -0
  30. package/src/utils/logger.js +32 -0
  31. package/{tiledesk → src/utils}/utils-message.js +6 -21
  32. package/logs/app.log +0 -3082
  33. package/routes/manageApp.js +0 -419
  34. package/tiledesk/KVBaseMongo.js +0 -101
  35. package/tiledesk/TiledeskChannel.js +0 -363
  36. package/tiledesk/TiledeskSubscriptionClient.js +0 -135
  37. package/tiledesk/fileUtils.js +0 -55
  38. package/tiledesk/services/AiService.js +0 -230
  39. package/tiledesk/services/IntegrationService.js +0 -81
  40. package/tiledesk/services/UploadService.js +0 -88
  41. /package/{winston.js → src/config/logger.js} +0 -0
  42. /package/{tiledesk → src}/services/voiceEventEmitter.js +0 -0
  43. /package/{template → src/template}/configure.html +0 -0
  44. /package/{template → src/template}/css/configure.css +0 -0
  45. /package/{template → src/template}/css/error.css +0 -0
  46. /package/{template → src/template}/css/style.css +0 -0
  47. /package/{template → src/template}/error.html +0 -0
  48. /package/{tiledesk → src/utils}/constants.js +0 -0
  49. /package/{tiledesk → src/utils}/errors.js +0 -0
  50. /package/{tiledesk → src/utils}/utils.js +0 -0
@@ -0,0 +1,488 @@
1
+ /**
2
+ * VoiceController - Refactored Version
3
+ *
4
+ * This controller handles HTTP requests/responses only.
5
+ * Business logic has been extracted to dedicated services:
6
+ * - SessionService: Session lifecycle management
7
+ * - MessageService: Queue polling and message retrieval
8
+ * - SpeechService: STT provider abstraction
9
+ * - TiledeskMessageBuilder: Message object factory
10
+ */
11
+
12
+ const { TiledeskChannel } = require('../services/channels/TiledeskChannel');
13
+ const { TiledeskTwilioTranslator } = require('../services/translators/TiledeskTwilioTranslator');
14
+ const { SessionService } = require('../services/SessionService');
15
+ const { MessageService } = require('../services/MessageService');
16
+ const { SpeechService } = require('../services/SpeechService');
17
+ const { TwilioService } = require('../services/TwilioService');
18
+ const { TiledeskMessageBuilder } = require('../services/TiledeskMessageBuilder');
19
+ const utilsMessage = require('../utils/utils-message');
20
+ const utils = require('../utils/utils');
21
+ const { TYPE_MESSAGE, CHANNEL_NAME, CALL_STATUS } = require('../utils/constants');
22
+ const logger = require('../utils/logger');
23
+
24
+ class VoiceController {
25
+ constructor(services) {
26
+ this.db = services.db;
27
+ this.config = services.config;
28
+
29
+ // Initialize channels
30
+ this.tdChannel = new TiledeskChannel({
31
+ API_URL: this.config.API_URL,
32
+ redis_client: services.redisClient
33
+ });
34
+
35
+ this.tdTranslator = new TiledeskTwilioTranslator({
36
+ BASE_URL: this.config.BASE_URL,
37
+ aiService: services.aiService,
38
+ uploadService: services.uploadService
39
+ });
40
+
41
+ // Initialize extracted services
42
+ this.sessionService = new SessionService({
43
+ voiceChannel: services.voiceChannel,
44
+ integrationService: services.integrationService,
45
+ tdChannel: this.tdChannel,
46
+ config: this.config
47
+ });
48
+
49
+ this.messageService = new MessageService({
50
+ voiceChannel: services.voiceChannel,
51
+ tdChannel: this.tdChannel,
52
+ config: this.config
53
+ });
54
+
55
+ this.speechService = new SpeechService({
56
+ aiService: services.aiService,
57
+ voiceChannel: services.voiceChannel
58
+ });
59
+
60
+ this.twilioService = new TwilioService({
61
+ db: this.db,
62
+ config: this.config
63
+ });
64
+ }
65
+
66
+ async index(req, res) {
67
+ res.send("Tiledesk Voice Connector");
68
+ }
69
+
70
+ async tiledesk(req, res) {
71
+ try {
72
+ const tiledeskMessage = req.body.payload;
73
+ const projectId = tiledeskMessage.id_project;
74
+
75
+ logger.debug(`(voice) Message received from Tiledesk in projectID: ${projectId} ---- text: ${tiledeskMessage.text}`);
76
+
77
+ if (!utilsMessage.messageType(TYPE_MESSAGE.INFO, tiledeskMessage) && !(tiledeskMessage.sender.indexOf(CHANNEL_NAME) > -1)) {
78
+ logger.debug(`> whook SAVE MESSAGE "${tiledeskMessage.text}" TO QUEUE at time ${new Date()}`);
79
+ }
80
+
81
+ if (await this.tdChannel.addMessageToQueue(tiledeskMessage)) {
82
+ await this._handlePlayRedirect(tiledeskMessage, projectId);
83
+ }
84
+
85
+ res.send("(voice) Message received from Voice Twilio Proxy");
86
+ } catch (error) {
87
+ logger.error("(voice) Error in tiledesk handler:", error);
88
+ res.status(500).send({ error: "Internal Server Error" });
89
+ }
90
+ }
91
+
92
+ async _handlePlayRedirect(tiledeskMessage, projectId) {
93
+ const contentKey = `${CHANNEL_NAME}-${projectId}`;
94
+ await this.twilioService.handlePlayRedirect(
95
+ tiledeskMessage,
96
+ projectId,
97
+ contentKey,
98
+ this.tdTranslator.lastCallSidVerb
99
+ );
100
+ }
101
+
102
+ async webhook(req, res) {
103
+ try {
104
+ const startCall = Date.now();
105
+ logger.debug('(voice) called POST /webhook/:id_project ' + new Date(), req.params);
106
+
107
+ const projectId = req.params.id_project;
108
+ const callSid = req.body.CallSid;
109
+ let { From: from, To: to } = req.body;
110
+
111
+ // Validate inputs
112
+ if ((!from || !to) && from !== "client:Anonymous") {
113
+ return res.status(404).send({ error: "Error: Missing from/to parameters" });
114
+ }
115
+
116
+ from = utils.getNumber(from);
117
+ to = to ? utils.getNumber(to) : "client:AnonymousReceiver";
118
+
119
+ // Get project settings
120
+ const CONTENT_KEY = `${CHANNEL_NAME}-${projectId}`;
121
+ const settings = await this.db.get(CONTENT_KEY);
122
+ if (!settings) {
123
+ return res.status(404).send({ error: "VOICE Channel not already connected" });
124
+ }
125
+
126
+ // Sign in user
127
+ const user = await this.tdChannel.signIn(from, settings);
128
+ if (!user) {
129
+ return res.status(401).send({ message: `Cannot sign in with caller phone: ${from}` });
130
+ }
131
+
132
+ // Parallel initialization
133
+ const [conversationId, integrations] = await Promise.all([
134
+ this.tdChannel.generateConversation(from, callSid, projectId),
135
+ this.sessionService.getIntegrationKeys(projectId, settings.token)
136
+ ]);
137
+
138
+ logger.debug(`(voice) conversation returned: ${conversationId}`);
139
+
140
+ // Create session
141
+ const sessionData = await this.sessionService.createSession({
142
+ callSid,
143
+ from,
144
+ to,
145
+ projectId,
146
+ user,
147
+ conversationId,
148
+ integrations
149
+ });
150
+
151
+ // Send start message
152
+ const tiledeskMessage = TiledeskMessageBuilder.buildStartMessage(from, settings.department_id, req.body);
153
+ const response = await this.tdChannel.send(tiledeskMessage, user.token, conversationId, projectId);
154
+ if (!response) {
155
+ return res.status(503).send({ message: "Bad response: Quota exceeded" });
156
+ }
157
+
158
+ // Get response from queue
159
+ const message = await this.messageService.getNextMessage(callSid, conversationId, from);
160
+ const { vxmlAttributes } = await this.sessionService.getSessionContext(callSid);
161
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionData);
162
+
163
+ logger.info(`Time to respond to /webhook/${projectId}: ${Date.now() - startCall}[ms]`);
164
+
165
+ res.set('Content-Type', 'text/xml');
166
+ res.status(200).send(messageToVXML);
167
+ } catch (error) {
168
+ logger.error("(voice) Error in webhook handler:", error);
169
+ res.status(500).send({ error: "Internal Server Error" });
170
+ }
171
+ }
172
+
173
+ async nextblock(req, res) {
174
+ try {
175
+ const startCall = Date.now();
176
+ const callSid = req.params.callSid;
177
+ const userText = req.body.SpeechResult || req.body.Digits;
178
+ const bargein = req.query.bargein === 'true' || req.query.bargein === true;
179
+
180
+ logger.verbose(`(voice) called POST /nextblock at ${new Date()} with text: ${userText}, bargein: ${bargein}`);
181
+
182
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
183
+ const { from, conversation_id, project_id, user } = sessionInfo;
184
+
185
+ const message = await this._handleUserInput(userText, callSid, from, conversation_id, project_id, user, bargein);
186
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
187
+
188
+ logger.info(`Time to respond to /nextblock/${callSid}: ${Date.now() - startCall}[ms]`);
189
+
190
+ res.set('Content-Type', 'application/xml');
191
+ res.status(200).send(messageToVXML);
192
+ } catch (error) {
193
+ logger.error("(voice) Error in nextblock handler:", error);
194
+ res.status(500).send({ error: "Internal Server Error" });
195
+ }
196
+ }
197
+
198
+ async speechresult(req, res) {
199
+ try {
200
+ const startCall = Date.now();
201
+ const callSid = req.params.callSid;
202
+ const userText = req.body.SpeechResult;
203
+
204
+ logger.verbose(`(voice) called POST /speechresult at ${new Date()} with text: ${userText}`);
205
+
206
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
207
+ const { from, conversation_id, project_id, user } = sessionInfo;
208
+
209
+ const message = await this._handleUserInput(userText, callSid, from, conversation_id, project_id, user);
210
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
211
+
212
+ logger.info(`Time to respond to /speechresult/${callSid}: ${Date.now() - startCall}[ms]`);
213
+
214
+ res.set('Content-Type', 'application/xml');
215
+ res.status(200).send(messageToVXML);
216
+ } catch (error) {
217
+ logger.error("(voice) Error in speechresult handler:", error);
218
+ res.status(500).send({ error: "Internal Server Error" });
219
+ }
220
+ }
221
+
222
+ async _handleUserInput(userText, callSid, from, conversationId, projectId, user, bargein = false) {
223
+ if (!userText) {
224
+ return this.messageService.getNextMessage(callSid, conversationId, from);
225
+ }
226
+
227
+ // If barge-in is enabled and user has provided input, clear the queue
228
+ // This ensures we get the fresh response after user interruption
229
+ if (bargein) {
230
+ logger.debug(`[VoiceController] Barge-in detected, clearing queue for conversation: ${conversationId}`);
231
+ await this.tdChannel.clearQueue(conversationId);
232
+ }
233
+
234
+ const tiledeskMessage = TiledeskMessageBuilder.buildTextMessage(userText, from);
235
+ await this.tdChannel.send(tiledeskMessage, user.token, conversationId, projectId);
236
+
237
+ return this.messageService.getNextMessage(callSid, conversationId, from);
238
+ }
239
+
240
+ async recordAction(req, res) {
241
+ try {
242
+ const startCall = Date.now();
243
+ const callSid = req.body.CallSid;
244
+
245
+ logger.verbose(`(voice) called POST record/action/:callSid at time ${new Date()}`);
246
+
247
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
248
+ const { from, conversation_id, project_id } = sessionInfo;
249
+
250
+ const message = await this.messageService.getNextMessage(callSid, conversation_id, from);
251
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
252
+
253
+ logger.info(`Time to respond to /record/action/${callSid}: ${Date.now() - startCall}[ms]`);
254
+
255
+ res.set('Content-Type', 'application/xml');
256
+ res.status(200).send(messageToVXML);
257
+ } catch (error) {
258
+ logger.error("(voice) Error in recordAction handler:", error);
259
+ res.status(500).send({ error: "Internal Server Error" });
260
+ }
261
+ }
262
+
263
+ async recordCallback(req, res) {
264
+ try {
265
+ const startCall = Date.now();
266
+ const callSid = req.params.callSid || req.body.CallSid;
267
+ const audioFileUrl = req.body.RecordingUrl;
268
+ const buttonAction = req.query.button_action ? `#${req.query.button_action}` : '';
269
+ const previousIntentName = req.query.intentName || '';
270
+
271
+ logger.verbose(`(voice) called POST record/callback/:callSid at time ${new Date()}`);
272
+
273
+ const { sessionInfo, project_id } = await this.sessionService.getSessionContext(callSid);
274
+ const { from, conversation_id, user } = sessionInfo;
275
+
276
+ // Get settings
277
+ const CONTENT_KEY = `${CHANNEL_NAME}-${project_id}`;
278
+ const settings = await this.db.get(CONTENT_KEY);
279
+ if (!settings) {
280
+ return res.status(404).send({ error: "VOICE Channel not already connected" });
281
+ }
282
+
283
+ // STT transcription
284
+ let tiledeskMessage = await this.speechService.transcribeAudio(audioFileUrl, callSid, sessionInfo, settings);
285
+
286
+ // Handle empty or no-input cases
287
+ if (!tiledeskMessage || !tiledeskMessage.text) {
288
+ tiledeskMessage = TiledeskMessageBuilder.buildNoInputMessage(from, buttonAction, {
289
+ event: 'no_input',
290
+ lastBlock: previousIntentName,
291
+ lastTimestamp: Date.now()
292
+ });
293
+ } else {
294
+ const normalizedText = utils.normalizeSTT(tiledeskMessage.text);
295
+ if (!normalizedText) {
296
+ tiledeskMessage = TiledeskMessageBuilder.buildNoInputMessage(from, buttonAction, {
297
+ event: 'no_input',
298
+ lastBlock: previousIntentName,
299
+ lastTimestamp: Date.now()
300
+ });
301
+ } else {
302
+ tiledeskMessage.text = normalizedText;
303
+ }
304
+ }
305
+
306
+ await this.tdChannel.send(tiledeskMessage, user.token, conversation_id, project_id);
307
+
308
+ logger.info(`Time to respond to /record/callback/${callSid}: ${Date.now() - startCall}[ms] with text ${tiledeskMessage.text}`);
309
+
310
+ res.status(200).send({ success: true, message: `Message sent to Tiledesk for callSid ${callSid}` });
311
+ } catch (error) {
312
+ logger.error("(voice) Error in recordCallback handler:", error);
313
+ res.status(500).send({ error: "Internal Server Error" });
314
+ }
315
+ }
316
+
317
+ async menublock(req, res) {
318
+ try {
319
+ const startCall = Date.now();
320
+ const callSid = req.params.callSid;
321
+ const buttonsMenu = req.query.menu_options;
322
+ const buttonNoMatch = req.query.button_action;
323
+ const menuChoice = req.body.Digits || '';
324
+
325
+ logger.verbose(`/menublock at: ${new Date()} with text: ${menuChoice}`);
326
+
327
+ // Parse menu selection
328
+ const { messageText, attributes } = this._parseMenuSelection(menuChoice, buttonsMenu, buttonNoMatch);
329
+
330
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
331
+ const { from, conversation_id, project_id, user } = sessionInfo;
332
+
333
+ // Send selection to Tiledesk
334
+ const tiledeskMessage = TiledeskMessageBuilder.buildTextMessage(messageText, from, attributes);
335
+ const response = await this.tdChannel.send(tiledeskMessage, user.token, conversation_id, project_id);
336
+ if (!response) {
337
+ return res.status(503).send({ message: "Bad response: Quota exceeded" });
338
+ }
339
+
340
+ const message = await this.messageService.getNextMessage(callSid, conversation_id, from);
341
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
342
+
343
+ logger.info(`Time to respond to /menublock/${callSid}: ${Date.now() - startCall}[ms]`);
344
+
345
+ res.set('Content-Type', 'application/xml');
346
+ res.status(200).send(messageToVXML);
347
+ } catch (error) {
348
+ logger.error("(voice) Error in menublock handler:", error);
349
+ res.status(500).send({ error: "Internal Server Error" });
350
+ }
351
+ }
352
+
353
+ _parseMenuSelection(menuChoice, buttonsMenu, buttonNoMatch) {
354
+ if (!buttonsMenu) {
355
+ return { messageText: menuChoice.toString(), attributes: {} };
356
+ }
357
+
358
+ let button = {};
359
+ buttonsMenu.split(';').some((option) => {
360
+ const [value, action] = option.split(':');
361
+ if (value === menuChoice) {
362
+ button = { value, action: `#${action}` };
363
+ return true;
364
+ }
365
+ });
366
+
367
+ if (Object.keys(button).length === 0) {
368
+ button = { value: menuChoice, action: `#${buttonNoMatch}` };
369
+ }
370
+
371
+ return {
372
+ messageText: button.value.toString(),
373
+ attributes: { action: button.action }
374
+ };
375
+ }
376
+
377
+ async handleEvent(req, res) {
378
+ try {
379
+ const event = req.params.event;
380
+ const callSid = req.params.callSid;
381
+ const buttonAction = `#${req.query.button_action}`;
382
+ const previousIntentName = req.query.intentName;
383
+
384
+ logger.debug(`(voice) called POST /handle event: ${event}`);
385
+
386
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
387
+ const { from, conversation_id, project_id, user } = sessionInfo;
388
+
389
+ const tiledeskMessage = TiledeskMessageBuilder.buildEventMessage(event, from, {
390
+ action: buttonAction,
391
+ payload: { event, lastBlock: previousIntentName, lastTimestamp: Date.now() }
392
+ });
393
+ await this.tdChannel.send(tiledeskMessage, user.token, conversation_id, project_id);
394
+
395
+ const message = await this.messageService.generateWaitMessage(callSid, from);
396
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
397
+
398
+ res.set('Content-Type', 'application/xml');
399
+ res.status(200).send(messageToVXML);
400
+ } catch (error) {
401
+ logger.error("(voice) Error in handleEvent handler:", error);
402
+ res.status(500).send({ error: "Internal Server Error" });
403
+ }
404
+ }
405
+
406
+ async event(req, res) {
407
+ try {
408
+ const event = req.params.event;
409
+ const callSid = req.params.callSid;
410
+ const currentIntentName = req.query.intentName;
411
+ const currentIntentTimestamp = req.query.previousIntentTimestamp;
412
+
413
+ logger.debug(`(voice) called POST /event: ${event}`);
414
+
415
+ const { vxmlAttributes, sessionInfo } = await this.sessionService.getSessionContext(callSid);
416
+ const { from, conversation_id, project_id, user } = sessionInfo;
417
+
418
+ if (event === 'transfer') {
419
+ await this._handleTransferEvent(req.body.CallStatus, from, conversation_id, project_id, user, {
420
+ event,
421
+ intentName: currentIntentName,
422
+ timestamp: currentIntentTimestamp,
423
+ buttonSuccess: req.query.button_success,
424
+ buttonFailure: req.query.button_failure
425
+ });
426
+ }
427
+
428
+ const message = await this.messageService.generateWaitMessage(callSid, from);
429
+ const messageToVXML = await this.tdTranslator.toVXML(message, callSid, vxmlAttributes, sessionInfo);
430
+
431
+ res.set('Content-Type', 'application/xml');
432
+ res.status(200).send(messageToVXML);
433
+ } catch (error) {
434
+ logger.error("(voice) Error in event handler:", error);
435
+ res.status(500).send({ error: "Internal Server Error" });
436
+ }
437
+ }
438
+
439
+ async _handleTransferEvent(callStatus, from, conversationId, projectId, user, options) {
440
+ const statusActions = {
441
+ [CALL_STATUS.COMPLETED]: `#${options.buttonSuccess}`,
442
+ [CALL_STATUS.FAILED]: `#${options.buttonFailure}`
443
+ };
444
+
445
+ const buttonAction = statusActions[callStatus];
446
+ if (!buttonAction) return;
447
+
448
+ const tiledeskMessage = TiledeskMessageBuilder.buildTransferMessage(from, options.event, {
449
+ action: buttonAction,
450
+ intentName: options.intentName,
451
+ timestamp: options.timestamp
452
+ });
453
+
454
+ await this.tdChannel.send(tiledeskMessage, user.token, conversationId, projectId);
455
+ }
456
+
457
+ async twilioStatus(req, res) {
458
+ try {
459
+ const event = req.body.CallStatus;
460
+ const callSid = req.body.CallSid;
461
+
462
+ logger.debug(`(voice) called POST twilio/status: ${event}`);
463
+
464
+ if (event === CALL_STATUS.COMPLETED) {
465
+ const { sessionInfo, conversation_id } = await this.sessionService.getSessionContext(callSid);
466
+ const { from, project_id, user } = sessionInfo;
467
+
468
+ const tiledeskMessage = TiledeskMessageBuilder.buildCloseMessage(from, event);
469
+ await this.tdChannel.send(tiledeskMessage, user.token, conversation_id, project_id);
470
+
471
+ await this.sessionService.cleanupSession(callSid, conversation_id);
472
+ }
473
+
474
+ res.status(200).send();
475
+ } catch (error) {
476
+ logger.error("(voice) Error in twilioStatus handler:", error);
477
+ res.status(500).send({ error: "Internal Server Error" });
478
+ }
479
+ }
480
+
481
+ async twilioFail(req, res) {
482
+ logger.debug('(voice) called POST twilio/fail', req.body);
483
+ res.set('Content-Type', 'application/xml');
484
+ res.status(200).send('<Response></Response>');
485
+ }
486
+ }
487
+
488
+ module.exports = VoiceController;