@tiledesk/tiledesk-voice-twilio-connector 0.1.28 → 0.2.0-rc6
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 +515 -0
- package/index.js +7 -1562
- package/package.json +23 -21
- package/src/app.js +154 -0
- package/src/config/index.js +32 -0
- package/src/controllers/VoiceController.js +493 -0
- package/src/middlewares/httpLogger.js +43 -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 +139 -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 +129 -0
- package/src/services/UploadService.js +78 -0
- package/src/services/channels/TiledeskChannel.js +268 -0
- package/{tiledesk → src/services/channels}/VoiceChannel.js +20 -59
- package/src/services/clients/TiledeskSubscriptionClient.js +78 -0
- package/src/services/index.js +45 -0
- package/src/services/translators/TiledeskTwilioTranslator.js +514 -0
- 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/TiledeskTwilioTranslator.js +0 -707
- 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,143 @@
|
|
|
1
|
+
const logger = require('../utils/logger');
|
|
2
|
+
const { CHANNEL_NAME, VOICE_LANGUAGE, VOICE_NAME } = require('../utils/constants');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service responsible for managing voice call session lifecycle.
|
|
6
|
+
* Handles session creation, retrieval, and cleanup.
|
|
7
|
+
*/
|
|
8
|
+
class SessionService {
|
|
9
|
+
constructor({ voiceChannel, integrationService, tdChannel, config }) {
|
|
10
|
+
if (!voiceChannel) throw new Error('[SessionService] voiceChannel is required');
|
|
11
|
+
if (!integrationService) throw new Error('[SessionService] integrationService is required');
|
|
12
|
+
if (!tdChannel) throw new Error('[SessionService] tdChannel is required');
|
|
13
|
+
if (!config) throw new Error('[SessionService] config is required');
|
|
14
|
+
|
|
15
|
+
this.voiceChannel = voiceChannel;
|
|
16
|
+
this.integrationService = integrationService;
|
|
17
|
+
this.tdChannel = tdChannel;
|
|
18
|
+
this.config = config;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retrieve integration keys (OpenAI, ElevenLabs) for a project.
|
|
23
|
+
* @param {string} projectId - The project ID
|
|
24
|
+
* @param {string} token - Authorization token
|
|
25
|
+
* @returns {Promise<Array>} Array of integration objects
|
|
26
|
+
*/
|
|
27
|
+
async getIntegrationKeys(projectId, token) {
|
|
28
|
+
const integrations = [];
|
|
29
|
+
|
|
30
|
+
// Retrieve OpenAI key with fallback chain
|
|
31
|
+
const openaiKey = await this._getOpenAIKey(projectId, token);
|
|
32
|
+
if (openaiKey) {
|
|
33
|
+
integrations.push(openaiKey);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Retrieve ElevenLabs key
|
|
37
|
+
const elevenLabsKey = await this._getElevenLabsKey(projectId, token);
|
|
38
|
+
if (elevenLabsKey) {
|
|
39
|
+
integrations.push(elevenLabsKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return integrations;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get OpenAI key with fallback: Integrations -> KB Settings -> Public Key
|
|
47
|
+
*/
|
|
48
|
+
async _getOpenAIKey(projectId, token) {
|
|
49
|
+
try {
|
|
50
|
+
let key = await this.integrationService.getKeyFromIntegrations(projectId, 'openai', token);
|
|
51
|
+
|
|
52
|
+
if (!key) {
|
|
53
|
+
// logger.debug('[SessionService] OpenAI key not in Integrations, checking KB settings...');
|
|
54
|
+
key = await this.integrationService.getKeyFromKbSettings(projectId, token);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!key) {
|
|
58
|
+
// logger.debug('[SessionService] Using public GPT key');
|
|
59
|
+
return { type: 'openai', key: this.config.GPT_KEY, publicKey: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { type: 'openai', key, publicKey: false };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.error('[SessionService] Error retrieving OpenAI key:', error);
|
|
65
|
+
return { type: 'openai', key: this.config.GPT_KEY, publicKey: true };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get ElevenLabs key from integrations
|
|
71
|
+
*/
|
|
72
|
+
async _getElevenLabsKey(projectId, token) {
|
|
73
|
+
try {
|
|
74
|
+
const key = await this.integrationService.getKeyFromIntegrations(projectId, 'elevenlabs', token);
|
|
75
|
+
if (key) {
|
|
76
|
+
// logger.debug('[SessionService] ElevenLabs key found');
|
|
77
|
+
return { type: 'elevenlabs', key, publicKey: false };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error('[SessionService] Error retrieving ElevenLabs key:', error);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create and store a new call session.
|
|
88
|
+
* @param {Object} params - Session parameters
|
|
89
|
+
* @returns {Promise<Object>} The created session data
|
|
90
|
+
*/
|
|
91
|
+
async createSession({ callSid, from, to, projectId, user, conversationId, integrations }) {
|
|
92
|
+
const sessionData = {
|
|
93
|
+
from,
|
|
94
|
+
to,
|
|
95
|
+
callSid,
|
|
96
|
+
project_id: projectId,
|
|
97
|
+
user,
|
|
98
|
+
conversation_id: conversationId,
|
|
99
|
+
integrations
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
await this.voiceChannel.setSessionForCallId(callSid, sessionData);
|
|
103
|
+
// logger.debug(`[SessionService] Session created for callSid: ${callSid}`);
|
|
104
|
+
|
|
105
|
+
return sessionData;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Retrieve session context for a call.
|
|
110
|
+
* @param {string} callSid - The call session ID
|
|
111
|
+
* @returns {Promise<Object>} Session context with vxmlAttributes
|
|
112
|
+
*/
|
|
113
|
+
async getSessionContext(callSid) {
|
|
114
|
+
const sessionInfo = await this.voiceChannel.getSessionForCallId(callSid);
|
|
115
|
+
|
|
116
|
+
if (!sessionInfo) {
|
|
117
|
+
throw new Error(`[SessionService] No session found for callSid: ${callSid}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { project_id, from, conversation_id, user } = sessionInfo;
|
|
121
|
+
|
|
122
|
+
const vxmlAttributes = {
|
|
123
|
+
TTS_VOICE_LANGUAGE: VOICE_LANGUAGE,
|
|
124
|
+
TTS_VOICE_NAME: VOICE_NAME,
|
|
125
|
+
callSid
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return { sessionInfo, project_id, from, conversation_id, user, vxmlAttributes };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Clean up session data for a completed call.
|
|
133
|
+
* @param {string} callSid - The call session ID
|
|
134
|
+
* @param {string} conversationId - The conversation ID
|
|
135
|
+
*/
|
|
136
|
+
async cleanupSession(callSid, conversationId) {
|
|
137
|
+
await this.voiceChannel.deleteCallKeys(callSid);
|
|
138
|
+
await this.tdChannel.clearQueue(conversationId);
|
|
139
|
+
// logger.debug(`[SessionService] Session cleaned up for callSid: ${callSid}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { SessionService };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const logger = require('../utils/logger');
|
|
2
|
+
const { CHANNEL_NAME, VOICE_PROVIDER } = require('../utils/constants');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service responsible for Speech-to-Text operations.
|
|
6
|
+
* Encapsulates provider-specific logic for OpenAI and ElevenLabs.
|
|
7
|
+
*/
|
|
8
|
+
class SpeechService {
|
|
9
|
+
constructor({ aiService, voiceChannel }) {
|
|
10
|
+
if (!aiService) throw new Error('[SpeechService] aiService is required');
|
|
11
|
+
if (!voiceChannel) throw new Error('[SpeechService] voiceChannel is required');
|
|
12
|
+
|
|
13
|
+
this.aiService = aiService;
|
|
14
|
+
this.voiceChannel = voiceChannel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert audio recording to text using configured provider.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} audioFileUrl - URL of the audio recording
|
|
21
|
+
* @param {string} callSid - The call session ID
|
|
22
|
+
* @param {Object} sessionInfo - Session information including integrations
|
|
23
|
+
* @param {Object} settings - Project settings
|
|
24
|
+
* @returns {Promise<Object|null>} Tiledesk message object or null if STT failed
|
|
25
|
+
*/
|
|
26
|
+
async transcribeAudio(audioFileUrl, callSid, sessionInfo, settings) {
|
|
27
|
+
const attributes = await this.voiceChannel.getSettingsForCallId(callSid);
|
|
28
|
+
// logger.debug(`[SpeechService] Transcribing audio: ${audioFileUrl}, provider: ${attributes.VOICE_PROVIDER}`);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const text = await this._performSTT(audioFileUrl, attributes, sessionInfo, settings);
|
|
32
|
+
|
|
33
|
+
if (!text) {
|
|
34
|
+
// logger.debug('[SpeechService] STT returned empty text');
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return this._buildTextMessage(text, sessionInfo.from);
|
|
39
|
+
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error('[SpeechService] Transcription error:', error);
|
|
42
|
+
|
|
43
|
+
if (error.code === 'QUOTA_EXCEEDED') {
|
|
44
|
+
return this._buildCloseMessage(sessionInfo.from, 'quota_exceeded');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Perform STT based on configured provider.
|
|
53
|
+
*/
|
|
54
|
+
async _performSTT(audioFileUrl, attributes, sessionInfo, settings) {
|
|
55
|
+
const provider = attributes.VOICE_PROVIDER;
|
|
56
|
+
|
|
57
|
+
switch (provider) {
|
|
58
|
+
case VOICE_PROVIDER.OPENAI:
|
|
59
|
+
return this._transcribeWithOpenAI(audioFileUrl, attributes, sessionInfo, settings);
|
|
60
|
+
|
|
61
|
+
case VOICE_PROVIDER.ELEVENLABS:
|
|
62
|
+
return this._transcribeWithElevenLabs(audioFileUrl, attributes, sessionInfo);
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(`[SpeechService] Unsupported provider: ${provider}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Transcribe audio using OpenAI Whisper.
|
|
71
|
+
*/
|
|
72
|
+
async _transcribeWithOpenAI(audioFileUrl, attributes, sessionInfo, settings) {
|
|
73
|
+
const integration = sessionInfo.integrations.find(i => i.type === VOICE_PROVIDER.OPENAI);
|
|
74
|
+
const apiKey = integration?.key;
|
|
75
|
+
const isPublicKey = integration?.publicKey;
|
|
76
|
+
|
|
77
|
+
// Check quota for public keys
|
|
78
|
+
if (isPublicKey) {
|
|
79
|
+
const hasQuota = await this.aiService.checkQuoteAvailability(sessionInfo.project_id, settings.token);
|
|
80
|
+
// logger.debug(`[SpeechService] Quota check: ${hasQuota}`);
|
|
81
|
+
|
|
82
|
+
if (!hasQuota) {
|
|
83
|
+
const error = new Error('No quota available');
|
|
84
|
+
error.code = 'QUOTA_EXCEEDED';
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.aiService.speechToText(audioFileUrl, attributes.STT_MODEL, apiKey);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Transcribe audio using ElevenLabs.
|
|
94
|
+
*/
|
|
95
|
+
async _transcribeWithElevenLabs(audioFileUrl, attributes, sessionInfo) {
|
|
96
|
+
const integration = sessionInfo.integrations.find(i => i.type === VOICE_PROVIDER.ELEVENLABS);
|
|
97
|
+
const apiKey = integration?.key;
|
|
98
|
+
const language = attributes.TTS_LANGUAGE || 'en';
|
|
99
|
+
|
|
100
|
+
return this.aiService.speechToTextElevenLabs(audioFileUrl, attributes.STT_MODEL, language, apiKey);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a standard text message.
|
|
105
|
+
*/
|
|
106
|
+
_buildTextMessage(text, senderFullname) {
|
|
107
|
+
return {
|
|
108
|
+
text,
|
|
109
|
+
senderFullname,
|
|
110
|
+
type: 'text',
|
|
111
|
+
channel: { name: CHANNEL_NAME }
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a close/event message.
|
|
117
|
+
*/
|
|
118
|
+
_buildCloseMessage(senderFullname, event) {
|
|
119
|
+
return {
|
|
120
|
+
text: '/close',
|
|
121
|
+
senderFullname,
|
|
122
|
+
type: 'text',
|
|
123
|
+
channel: { name: CHANNEL_NAME },
|
|
124
|
+
attributes: {
|
|
125
|
+
subtype: 'info',
|
|
126
|
+
action: `close${JSON.stringify({ event })}`,
|
|
127
|
+
payload: { catchEvent: event },
|
|
128
|
+
timestamp: Date.now()
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { SpeechService };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const { CHANNEL_NAME } = require('../utils/constants');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Factory for building Tiledesk message objects.
|
|
5
|
+
* Centralizes message construction to ensure consistency.
|
|
6
|
+
*/
|
|
7
|
+
class TiledeskMessageBuilder {
|
|
8
|
+
/**
|
|
9
|
+
* Build a start message for initiating a conversation.
|
|
10
|
+
* @param {string} from - Sender identifier
|
|
11
|
+
* @param {string} departmentId - Department ID
|
|
12
|
+
* @param {Object} payload - Additional payload data
|
|
13
|
+
* @returns {Object} Tiledesk message object
|
|
14
|
+
*/
|
|
15
|
+
static buildStartMessage(from, departmentId, payload = {}) {
|
|
16
|
+
return {
|
|
17
|
+
text: '/start',
|
|
18
|
+
senderFullname: from,
|
|
19
|
+
type: 'text',
|
|
20
|
+
attributes: {
|
|
21
|
+
subtype: 'info',
|
|
22
|
+
payload
|
|
23
|
+
},
|
|
24
|
+
channel: { name: CHANNEL_NAME },
|
|
25
|
+
departmentid: departmentId
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a standard text message.
|
|
31
|
+
* @param {string} text - Message text
|
|
32
|
+
* @param {string} from - Sender identifier
|
|
33
|
+
* @param {Object} attributes - Optional attributes
|
|
34
|
+
* @returns {Object} Tiledesk message object
|
|
35
|
+
*/
|
|
36
|
+
static buildTextMessage(text, from, attributes = {}) {
|
|
37
|
+
return {
|
|
38
|
+
text,
|
|
39
|
+
senderFullname: from,
|
|
40
|
+
type: 'text',
|
|
41
|
+
channel: { name: CHANNEL_NAME },
|
|
42
|
+
attributes
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build an event message (e.g., no_input, close).
|
|
48
|
+
* @param {string} event - Event name
|
|
49
|
+
* @param {string} from - Sender identifier
|
|
50
|
+
* @param {Object} options - Event options
|
|
51
|
+
* @returns {Object} Tiledesk message object
|
|
52
|
+
*/
|
|
53
|
+
static buildEventMessage(event, from, { action = '', payload = {} } = {}) {
|
|
54
|
+
return {
|
|
55
|
+
text: `/${event}`,
|
|
56
|
+
senderFullname: from,
|
|
57
|
+
type: 'text',
|
|
58
|
+
channel: { name: CHANNEL_NAME },
|
|
59
|
+
attributes: {
|
|
60
|
+
type: 'info',
|
|
61
|
+
action,
|
|
62
|
+
payload
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a close message.
|
|
69
|
+
* @param {string} from - Sender identifier
|
|
70
|
+
* @param {string} event - The close event type
|
|
71
|
+
* @returns {Object} Tiledesk message object
|
|
72
|
+
*/
|
|
73
|
+
static buildCloseMessage(from, event) {
|
|
74
|
+
return {
|
|
75
|
+
text: '/close',
|
|
76
|
+
senderFullname: from,
|
|
77
|
+
type: 'text',
|
|
78
|
+
channel: { name: CHANNEL_NAME },
|
|
79
|
+
attributes: {
|
|
80
|
+
subtype: 'info',
|
|
81
|
+
action: `close${JSON.stringify({ event })}`,
|
|
82
|
+
payload: { catchEvent: event },
|
|
83
|
+
timestamp: Date.now()
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a transfer event message.
|
|
90
|
+
* @param {string} from - Sender identifier
|
|
91
|
+
* @param {string} event - Event name
|
|
92
|
+
* @param {Object} options - Transfer options
|
|
93
|
+
* @returns {Object} Tiledesk message object
|
|
94
|
+
*/
|
|
95
|
+
static buildTransferMessage(from, event, { action, intentName, timestamp }) {
|
|
96
|
+
return {
|
|
97
|
+
text: `/${event}`,
|
|
98
|
+
senderFullname: from,
|
|
99
|
+
type: 'text',
|
|
100
|
+
channel: { name: CHANNEL_NAME },
|
|
101
|
+
attributes: {
|
|
102
|
+
subtype: 'info',
|
|
103
|
+
action,
|
|
104
|
+
payload: {
|
|
105
|
+
event,
|
|
106
|
+
lastBlock: intentName,
|
|
107
|
+
lastTimestamp: timestamp
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build a no-input fallback message.
|
|
115
|
+
* @param {string} from - Sender identifier
|
|
116
|
+
* @param {string} buttonAction - The fallback action
|
|
117
|
+
* @param {Object} payload - Event payload
|
|
118
|
+
* @returns {Object} Tiledesk message object
|
|
119
|
+
*/
|
|
120
|
+
static buildNoInputMessage(from, buttonAction, payload = {}) {
|
|
121
|
+
return {
|
|
122
|
+
text: '/no_input',
|
|
123
|
+
senderFullname: from,
|
|
124
|
+
type: 'text',
|
|
125
|
+
channel: { name: CHANNEL_NAME },
|
|
126
|
+
attributes: {
|
|
127
|
+
type: 'info',
|
|
128
|
+
action: buttonAction,
|
|
129
|
+
payload
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { TiledeskMessageBuilder };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { twiml } = require('twilio');
|
|
2
|
+
const logger = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service responsible for Twilio API operations.
|
|
6
|
+
* Encapsulates call management and redirects.
|
|
7
|
+
*/
|
|
8
|
+
class TwilioService {
|
|
9
|
+
constructor({ db, config }) {
|
|
10
|
+
if (!db) throw new Error('[TwilioService] db is required');
|
|
11
|
+
if (!config) throw new Error('[TwilioService] config is required');
|
|
12
|
+
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this._twilioClients = new Map(); // Cache clients per account
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get or create a Twilio client for the given credentials.
|
|
20
|
+
* @param {string} accountSid - Twilio account SID
|
|
21
|
+
* @param {string} authToken - Twilio auth token
|
|
22
|
+
* @returns {Object} Twilio client instance
|
|
23
|
+
*/
|
|
24
|
+
_getClient(accountSid, authToken) {
|
|
25
|
+
if (!this._twilioClients.has(accountSid)) {
|
|
26
|
+
const twilio = require('twilio')(accountSid, authToken);
|
|
27
|
+
this._twilioClients.set(accountSid, twilio);
|
|
28
|
+
}
|
|
29
|
+
return this._twilioClients.get(accountSid);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redirect an active call to a new URL.
|
|
34
|
+
* @param {string} callSid - The call SID to redirect
|
|
35
|
+
* @param {string} url - The URL to redirect to
|
|
36
|
+
* @param {Object} settings - Project settings containing Twilio credentials
|
|
37
|
+
* @returns {Promise<boolean>} True if redirect succeeded
|
|
38
|
+
*/
|
|
39
|
+
async redirectCall(callSid, url, settings) {
|
|
40
|
+
try {
|
|
41
|
+
const client = this._getClient(settings.account_sid, settings.auth_token);
|
|
42
|
+
|
|
43
|
+
await client.calls(callSid).update({
|
|
44
|
+
url,
|
|
45
|
+
method: 'POST'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// logger.debug(`[TwilioService] Call ${callSid} redirected to ${url}`);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.error(`[TwilioService] Error redirecting call ${callSid}:`, error);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a nextblock redirect URL with query parameters.
|
|
58
|
+
* @param {string} callSid - The call SID
|
|
59
|
+
* @param {string} intentName - The intent name
|
|
60
|
+
* @returns {string} The complete redirect URL
|
|
61
|
+
*/
|
|
62
|
+
buildNextBlockUrl(callSid, intentName) {
|
|
63
|
+
const querystring = require('querystring');
|
|
64
|
+
const queryParams = `?intentName=${querystring.encode(intentName)}&previousIntentTimestamp=${Date.now()}`;
|
|
65
|
+
return `${this.config.BASE_URL}/nextblock/${callSid}${queryParams}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle play verb redirect logic.
|
|
70
|
+
* Redirects call to nextblock when a 'play' verb completes.
|
|
71
|
+
* @param {Object} tiledeskMessage - The Tiledesk message
|
|
72
|
+
* @param {string} projectId - The project ID
|
|
73
|
+
* @param {string} contentKey - The settings key
|
|
74
|
+
* @param {Object} lastCallSidVerb - Map of callSid to last verb
|
|
75
|
+
* @returns {Promise<boolean>} True if redirect was performed
|
|
76
|
+
*/
|
|
77
|
+
async handlePlayRedirect(tiledeskMessage, projectId, contentKey, lastCallSidVerb) {
|
|
78
|
+
const flowAttrs = tiledeskMessage.attributes?.flowAttributes;
|
|
79
|
+
const callSid = flowAttrs?.CallSid;
|
|
80
|
+
|
|
81
|
+
if (!callSid || lastCallSidVerb[callSid] !== 'play_loop') {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const settings = await this.db.get(contentKey);
|
|
86
|
+
if (!settings) {
|
|
87
|
+
logger.warn(`[TwilioService] No settings found for ${contentKey}`);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const intentName = tiledeskMessage.attributes?.intentName || '';
|
|
92
|
+
const redirectUrl = this.buildNextBlockUrl(callSid, intentName);
|
|
93
|
+
|
|
94
|
+
return this.redirectCall(callSid, redirectUrl, settings);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
generateRedirectTwiML(callSid, intentName) {
|
|
98
|
+
const response = new twiml.VoiceResponse();
|
|
99
|
+
response.redirect({ method: 'POST' }, this.buildNextBlockUrl(callSid, intentName));
|
|
100
|
+
return response.toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get call information.
|
|
105
|
+
* @param {string} callSid - The call SID
|
|
106
|
+
* @param {Object} settings - Project settings
|
|
107
|
+
* @returns {Promise<Object|null>} Call info or null
|
|
108
|
+
*/
|
|
109
|
+
async getCallInfo(callSid, settings) {
|
|
110
|
+
try {
|
|
111
|
+
const client = this._getClient(settings.account_sid, settings.auth_token);
|
|
112
|
+
const call = await client.calls(callSid).fetch();
|
|
113
|
+
return call;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.error(`[TwilioService] Error fetching call ${callSid}:`, error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Clear cached Twilio client for an account.
|
|
122
|
+
* @param {string} accountSid - The account SID to clear
|
|
123
|
+
*/
|
|
124
|
+
clearClient(accountSid) {
|
|
125
|
+
this._twilioClients.delete(accountSid);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { TwilioService };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const winston = require('../utils/logger');
|
|
2
|
+
const axios = require("axios").default;
|
|
3
|
+
const FormData = require('form-data');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { ServiceError } = require('../utils/errors');
|
|
8
|
+
|
|
9
|
+
class UploadService {
|
|
10
|
+
|
|
11
|
+
constructor(config) {
|
|
12
|
+
|
|
13
|
+
if (!config) {
|
|
14
|
+
throw new Error("[UploadService] config is mandatory");
|
|
15
|
+
}
|
|
16
|
+
if (!config.API_URL) {
|
|
17
|
+
throw new Error("[UploadService] config.API_URL is mandatory");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (config.user) {
|
|
21
|
+
this.user = config.user
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.API_URL = config.API_URL;
|
|
25
|
+
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async upload(id, file, user) {
|
|
29
|
+
// logger.debug(`[UploadService] upload for id ${id} and user ${user._id}`);
|
|
30
|
+
|
|
31
|
+
const tempFilePath = path.join(os.tmpdir(), `speech_${user._id}_${id}.wav`);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Step 2: Save file locally (temporarily)
|
|
35
|
+
fs.writeFileSync(tempFilePath, file);
|
|
36
|
+
|
|
37
|
+
// Step 3: Upload file to server
|
|
38
|
+
const formData = new FormData();
|
|
39
|
+
formData.append('file', fs.createReadStream(tempFilePath), {
|
|
40
|
+
filename: `audiofile_${user._id}_${id}.wav`,
|
|
41
|
+
contentType: 'audio/mpeg'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const response = await axios({
|
|
45
|
+
url: this.API_URL + "/files/users",
|
|
46
|
+
headers: {
|
|
47
|
+
...formData.getHeaders(),
|
|
48
|
+
"Authorization": user.token
|
|
49
|
+
},
|
|
50
|
+
data: formData,
|
|
51
|
+
method: 'POST'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (response.data) {
|
|
55
|
+
let fileUrl = this.API_URL + "/files?path=" + response.data.filename;
|
|
56
|
+
return fileUrl;
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error("No data in response");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
} catch (err) {
|
|
62
|
+
winston.error('[UploadService] upload error', err);
|
|
63
|
+
throw new ServiceError('UPLOADSERVICE_FAILED', 'UploadService /files/users API failed with err:', err);
|
|
64
|
+
} finally {
|
|
65
|
+
// Step 4: Clean up temporary file
|
|
66
|
+
if (fs.existsSync(tempFilePath)) {
|
|
67
|
+
try {
|
|
68
|
+
fs.unlinkSync(tempFilePath);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
winston.error("[UploadService] Error deleting temp file:", e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { UploadService };
|