@peopl-health/nexus 2.4.13 → 2.5.1-fix
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/examples/basic-usage.js +81 -3
- package/lib/adapters/TwilioProvider.js +78 -2
- package/lib/controllers/messageStatusController.js +92 -0
- package/lib/core/NexusMessaging.js +47 -10
- package/lib/helpers/messageStatusHelper.js +97 -0
- package/lib/models/messageModel.js +26 -1
- package/lib/routes/index.js +1 -1
- package/lib/services/assistantService.js +49 -412
- package/lib/services/assistantServiceCore.js +215 -0
- package/lib/services/twilioService.js +5 -0
- package/lib/storage/MongoStorage.js +7 -1
- package/package.json +1 -1
package/examples/basic-usage.js
CHANGED
|
@@ -9,8 +9,59 @@ app.use(express.json());
|
|
|
9
9
|
class GeneralAssistant extends BaseAssistant {
|
|
10
10
|
constructor(options = {}) {
|
|
11
11
|
super(options);
|
|
12
|
-
//
|
|
13
|
-
|
|
12
|
+
// Register the reportUnresolvedRequests tool for testing
|
|
13
|
+
this.registerTool({
|
|
14
|
+
name: 'reportUnresolvedRequests',
|
|
15
|
+
definition: {
|
|
16
|
+
description: 'Identifica preguntas o síntomas que no se resolvieron completamente o que fueron escaladas al equipo médico.',
|
|
17
|
+
strict: false,
|
|
18
|
+
parameters: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: [
|
|
21
|
+
'unresolvedQuestion',
|
|
22
|
+
'escalationDetails'
|
|
23
|
+
],
|
|
24
|
+
properties: {
|
|
25
|
+
unresolvedQuestion: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'La pregunta o síntoma que quedó sin resolver.'
|
|
28
|
+
},
|
|
29
|
+
escalationDetails: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Detalles sobre por qué se escaló o a quién fue escalada (equipo médico, etc.).'
|
|
32
|
+
},
|
|
33
|
+
resolutionAttempted: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
description: 'Indica si se intentó ofrecer una resolución antes de escalar.'
|
|
36
|
+
},
|
|
37
|
+
triggerConditions: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
description: 'Condiciones que activan la función cuando se detecta una pregunta o síntoma no resuelto en su totalidad.',
|
|
40
|
+
properties: {
|
|
41
|
+
responseAnalysis: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Análisis de la respuesta para determinar si es suficientemente específica o resuelve la pregunta.',
|
|
44
|
+
default: 'La respuesta contiene palabras como \'depende\', \'consulte a su equipo médico\', \'varía según el caso\', \'haremos las consultas al equipo médico de PEOPL\', \'puedo comunicarme con el equipo médico de PEOPL para ti\', \'Voy a escalar la consulta al equipo médico\' o no proporciona un plan de acción claro.'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
required: [
|
|
48
|
+
'responseAnalysis'
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
additionalProperties: false
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
handler: async (args) => {
|
|
56
|
+
// Handler for testing - logs the unresolved request information
|
|
57
|
+
console.log('🔍 [reportUnresolvedRequests] Tool called with:', JSON.stringify(args, null, 2));
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
message: 'Unresolved request reported successfully',
|
|
61
|
+
data: args
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
});
|
|
14
65
|
}
|
|
15
66
|
}
|
|
16
67
|
|
|
@@ -35,7 +86,7 @@ async function startServer() {
|
|
|
35
86
|
providerConfig: {
|
|
36
87
|
accountSid: process.env.TWILIO_ACCOUNT_SID,
|
|
37
88
|
authToken: process.env.TWILIO_AUTH_TOKEN,
|
|
38
|
-
whatsappNumber: process.env.TWILIO_WHATSAPP_NUMBER
|
|
89
|
+
whatsappNumber: process.env.TWILIO_WHATSAPP_NUMBER,
|
|
39
90
|
},
|
|
40
91
|
|
|
41
92
|
// Media configuration for AWS S3 upload
|
|
@@ -56,6 +107,8 @@ async function startServer() {
|
|
|
56
107
|
// Register the general assistant with its OpenAI assistant ID
|
|
57
108
|
'asst_O6mAXAhf0xyVj3t4DHRs26uT': GeneralAssistant,
|
|
58
109
|
'pmpt_68f844cd975481979c080431bde74f6e0adf01a52110813b': GeneralAssistant,
|
|
110
|
+
'pmpt_68f8059adc7881938477ddbfb6e0d1970c2ed91d30c64be6': GeneralAssistant,
|
|
111
|
+
'pmpt_68f649ce39ec81908fade27434c167e6014528b0dc5469c6': GeneralAssistant,
|
|
59
112
|
}
|
|
60
113
|
}
|
|
61
114
|
});
|
|
@@ -75,6 +128,25 @@ async function startServer() {
|
|
|
75
128
|
}
|
|
76
129
|
});
|
|
77
130
|
|
|
131
|
+
// Add status callback endpoint for Twilio message status updates
|
|
132
|
+
// Twilio sends form-encoded data, so we need urlencoded middleware
|
|
133
|
+
const { handleStatusCallback } = require('../lib/helpers/messageStatusHelper');
|
|
134
|
+
app.use('/twilio/status_callback', express.urlencoded({ extended: true }));
|
|
135
|
+
app.post('/twilio/status_callback', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const updated = await handleStatusCallback(req.body);
|
|
138
|
+
if (updated) {
|
|
139
|
+
console.log('✅ Status updated:', req.body.MessageSid, req.body.MessageStatus);
|
|
140
|
+
res.status(200).json({ success: true, message: 'Status updated' });
|
|
141
|
+
} else {
|
|
142
|
+
res.status(200).json({ success: false, message: 'Message not found' });
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('Status callback error:', error);
|
|
146
|
+
res.status(500).json({ success: false, error: error.message });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
78
150
|
// Custom endpoint example
|
|
79
151
|
app.get('/status', (req, res) => {
|
|
80
152
|
res.json({
|
|
@@ -107,6 +179,7 @@ async function startServer() {
|
|
|
107
179
|
console.log('Available endpoints:');
|
|
108
180
|
console.log('- POST /webhook - Webhook for incoming messages');
|
|
109
181
|
console.log('- POST /twilio/webhook - Twilio webhook for incoming messages');
|
|
182
|
+
console.log('- POST /twilio/status_callback - Twilio status callback for message delivery status');
|
|
110
183
|
console.log('- GET /status - Connection status');
|
|
111
184
|
console.log('- GET /airtable-test - Test Airtable connection');
|
|
112
185
|
console.log('- GET /media-test - Test AWS media upload configuration');
|
|
@@ -117,6 +190,11 @@ async function startServer() {
|
|
|
117
190
|
console.log(' - Multiple messages from same sender will be batched');
|
|
118
191
|
console.log(' - Use test-batching.js to test the functionality');
|
|
119
192
|
console.log('');
|
|
193
|
+
console.log('🧪 Assistant Testing:');
|
|
194
|
+
console.log(' - Use test-assistant.js to test assistant functionality');
|
|
195
|
+
console.log(' - Run: node examples/test-assistant.js');
|
|
196
|
+
console.log(' - Tests basic messages, responses, batching, and retry logic');
|
|
197
|
+
console.log('');
|
|
120
198
|
console.log('📸 Media Upload Setup:');
|
|
121
199
|
console.log(' Add these to your .env file for media upload:');
|
|
122
200
|
console.log(' - AWS_ACCESS_KEY_ID=your_access_key');
|
|
@@ -18,6 +18,7 @@ class TwilioProvider extends MessageProvider {
|
|
|
18
18
|
this.accountSid = config.accountSid;
|
|
19
19
|
this.authToken = config.authToken;
|
|
20
20
|
this.whatsappNumber = config.whatsappNumber;
|
|
21
|
+
this.statusCallbackUrl = config.statusCallbackUrl || null;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
async initialize() {
|
|
@@ -61,6 +62,17 @@ class TwilioProvider extends MessageProvider {
|
|
|
61
62
|
to: formattedCode
|
|
62
63
|
};
|
|
63
64
|
|
|
65
|
+
if (this.statusCallbackUrl) {
|
|
66
|
+
messageParams.statusCallback = this.statusCallbackUrl;
|
|
67
|
+
messageParams.statusCallbackMethod = messageData.statusCallbackMethod || 'POST';
|
|
68
|
+
logger.info('[TwilioProvider] Message will use status callback', {
|
|
69
|
+
callbackUrl: this.statusCallbackUrl,
|
|
70
|
+
method: messageParams.statusCallbackMethod
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
logger.debug('[TwilioProvider] No status callback URL configured');
|
|
74
|
+
}
|
|
75
|
+
|
|
64
76
|
// Handle template messages
|
|
65
77
|
if (contentSid) {
|
|
66
78
|
const renderedMessage = await this.renderTemplate(contentSid, variables);
|
|
@@ -118,17 +130,23 @@ class TwilioProvider extends MessageProvider {
|
|
|
118
130
|
provider: 'twilio',
|
|
119
131
|
timestamp: new Date(),
|
|
120
132
|
fromMe: true,
|
|
121
|
-
processed: messageData.processed !== undefined ? messageData.processed : false
|
|
133
|
+
processed: messageData.processed !== undefined ? messageData.processed : false,
|
|
134
|
+
statusInfo: {
|
|
135
|
+
status: result.status ? result.status.toLowerCase() : null,
|
|
136
|
+
updatedAt: result.dateCreated || new Date()
|
|
137
|
+
}
|
|
122
138
|
});
|
|
123
139
|
logger.info('[TwilioProvider] Message persisted successfully', { messageId: result.sid });
|
|
124
140
|
} catch (storageError) {
|
|
125
141
|
logger.error('TwilioProvider storage failed:', storageError);
|
|
126
142
|
}
|
|
127
143
|
}
|
|
144
|
+
|
|
128
145
|
return {
|
|
129
146
|
success: true,
|
|
130
147
|
messageId: result.sid,
|
|
131
148
|
provider: 'twilio',
|
|
149
|
+
status: result.status,
|
|
132
150
|
result
|
|
133
151
|
};
|
|
134
152
|
} catch (error) {
|
|
@@ -136,6 +154,41 @@ class TwilioProvider extends MessageProvider {
|
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
async sendTypingIndicator(messageId) {
|
|
158
|
+
try {
|
|
159
|
+
const response = await axios.post(
|
|
160
|
+
'https://messaging.twilio.com/v2/Indicators/Typing.json',
|
|
161
|
+
new URLSearchParams({
|
|
162
|
+
messageId: messageId,
|
|
163
|
+
channel: 'whatsapp'
|
|
164
|
+
}),
|
|
165
|
+
{
|
|
166
|
+
auth: {
|
|
167
|
+
username: this.accountSid,
|
|
168
|
+
password: this.authToken
|
|
169
|
+
},
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
logger.info('[TwilioProvider] Typing indicator sent successfully', {
|
|
176
|
+
messageId,
|
|
177
|
+
success: response.data?.success,
|
|
178
|
+
status: response.status
|
|
179
|
+
});
|
|
180
|
+
return { success: true };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.error('[TwilioProvider] Failed to send typing indicator', {
|
|
183
|
+
error: error.message,
|
|
184
|
+
messageId,
|
|
185
|
+
status: error.response?.status,
|
|
186
|
+
data: error.response?.data
|
|
187
|
+
});
|
|
188
|
+
return { success: false };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
139
192
|
async sendScheduledMessage(scheduledMessage) {
|
|
140
193
|
const { sendTime, timeZone, __nexusSend } = scheduledMessage;
|
|
141
194
|
const delay = this.calculateDelay(sendTime, timeZone);
|
|
@@ -215,7 +268,6 @@ class TwilioProvider extends MessageProvider {
|
|
|
215
268
|
const payload = scheduledMessage.toObject ? scheduledMessage.toObject() : { ...scheduledMessage };
|
|
216
269
|
delete payload.__nexusSend;
|
|
217
270
|
|
|
218
|
-
// Map message field to body for consistency (scheduled messages use 'message' field)
|
|
219
271
|
if (payload.message && !payload.body) {
|
|
220
272
|
payload.body = payload.message;
|
|
221
273
|
}
|
|
@@ -681,6 +733,30 @@ class TwilioProvider extends MessageProvider {
|
|
|
681
733
|
}
|
|
682
734
|
}
|
|
683
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Check the status of a sent message using its SID
|
|
738
|
+
*/
|
|
739
|
+
async getMessageStatus(messageSid) {
|
|
740
|
+
if (!this.isConnected || !this.twilioClient) {
|
|
741
|
+
throw new Error('Twilio provider not initialized');
|
|
742
|
+
}
|
|
743
|
+
if (!messageSid) {
|
|
744
|
+
throw new Error('Message SID is required');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
const message = await this.twilioClient.messages(messageSid).fetch();
|
|
749
|
+
// Returns the complete Twilio message object with all status information
|
|
750
|
+
// Status-related fields: status, errorCode, errorMessage, dateCreated, dateSent, dateUpdated
|
|
751
|
+
return message;
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (error.status === 404) {
|
|
754
|
+
throw new Error(`Message with SID ${messageSid} not found`);
|
|
755
|
+
}
|
|
756
|
+
throw new Error(`Failed to fetch message status: ${error.message}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
684
760
|
}
|
|
685
761
|
|
|
686
762
|
module.exports = { TwilioProvider };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { handleStatusCallback, getMessageStatus } = require('../helpers/messageStatusHelper');
|
|
2
|
+
const { logger } = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handle Twilio status callback webhook
|
|
6
|
+
* POST /api/message/status-callback
|
|
7
|
+
*/
|
|
8
|
+
async function messageStatusCallbackController(req, res) {
|
|
9
|
+
try {
|
|
10
|
+
logger.info('[MessageStatusController] Received status callback', {
|
|
11
|
+
method: req.method,
|
|
12
|
+
path: req.path,
|
|
13
|
+
headers: req.headers['content-type'],
|
|
14
|
+
bodyKeys: Object.keys(req.body || {}),
|
|
15
|
+
body: req.body
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const updated = await handleStatusCallback(req.body);
|
|
19
|
+
|
|
20
|
+
if (updated) {
|
|
21
|
+
logger.info('[MessageStatusController] Status updated successfully', {
|
|
22
|
+
messageSid: req.body.MessageSid,
|
|
23
|
+
status: req.body.MessageStatus
|
|
24
|
+
});
|
|
25
|
+
res.status(200).json({
|
|
26
|
+
success: true,
|
|
27
|
+
message: 'Status updated',
|
|
28
|
+
messageSid: req.body.MessageSid,
|
|
29
|
+
status: req.body.MessageStatus
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
res.status(200).json({
|
|
33
|
+
success: false,
|
|
34
|
+
message: 'Message not found or invalid data',
|
|
35
|
+
messageSid: req.body.MessageSid
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logger.error('[MessageStatusController] Error handling status callback', {
|
|
40
|
+
error: error.message,
|
|
41
|
+
body: req.body
|
|
42
|
+
});
|
|
43
|
+
res.status(500).json({
|
|
44
|
+
success: false,
|
|
45
|
+
error: 'Failed to process status callback'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get message status from database
|
|
52
|
+
* GET /api/message/status/:messageSid
|
|
53
|
+
*/
|
|
54
|
+
async function getMessageStatusController(req, res) {
|
|
55
|
+
try {
|
|
56
|
+
const { messageSid } = req.params;
|
|
57
|
+
|
|
58
|
+
const message = await getMessageStatus(messageSid);
|
|
59
|
+
|
|
60
|
+
if (message) {
|
|
61
|
+
res.status(200).json({
|
|
62
|
+
success: true,
|
|
63
|
+
message: {
|
|
64
|
+
messageSid: message.message_id,
|
|
65
|
+
statusInfo: message.statusInfo || null,
|
|
66
|
+
numero: message.numero,
|
|
67
|
+
body: message.body
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
res.status(404).json({
|
|
72
|
+
success: false,
|
|
73
|
+
error: 'Message not found'
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.error('[MessageStatusController] Error fetching message status', {
|
|
78
|
+
error: error.message,
|
|
79
|
+
messageSid: req.params.messageSid
|
|
80
|
+
});
|
|
81
|
+
res.status(500).json({
|
|
82
|
+
success: false,
|
|
83
|
+
error: 'Failed to fetch message status'
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
messageStatusCallbackController,
|
|
90
|
+
getMessageStatusController
|
|
91
|
+
};
|
|
92
|
+
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { airtable, getBase } = require('../config/airtableConfig');
|
|
2
|
+
const { Message } = require('../models/messageModel');
|
|
2
3
|
const { addMsgAssistant, replyAssistant } = require('../services/assistantService');
|
|
3
4
|
const { createProvider } = require('../adapters/registry');
|
|
4
5
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
@@ -42,8 +43,7 @@ class NexusMessaging {
|
|
|
42
43
|
this.pendingResponses = new Map();
|
|
43
44
|
this.batchingConfig = {
|
|
44
45
|
enabled: config.messageBatching?.enabled ?? false,
|
|
45
|
-
baseWaitTime: config.messageBatching?.baseWaitTime ??
|
|
46
|
-
randomVariation: config.messageBatching?.randomVariation ?? 5000
|
|
46
|
+
baseWaitTime: config.messageBatching?.baseWaitTime ?? 10000
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -373,6 +373,17 @@ class NexusMessaging {
|
|
|
373
373
|
} else if (messageData.flow) {
|
|
374
374
|
return await this.handleFlow(messageData);
|
|
375
375
|
} else {
|
|
376
|
+
if (chatId && this.provider && typeof this.provider.sendTypingIndicator === 'function') {
|
|
377
|
+
const messageId = messageData.id || messageData.MessageSid || messageData.message_id;
|
|
378
|
+
if (messageId) {
|
|
379
|
+
setTimeout(() => {
|
|
380
|
+
this.provider.sendTypingIndicator(messageId).catch(err =>
|
|
381
|
+
logger.debug('[processIncomingMessage] Typing indicator failed', { error: err.message })
|
|
382
|
+
);
|
|
383
|
+
}, 3000);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
376
387
|
// For regular messages and media, use batching if enabled
|
|
377
388
|
logger.info('Batching config:', this.batchingConfig);
|
|
378
389
|
if (this.batchingConfig.enabled && chatId) {
|
|
@@ -635,17 +646,12 @@ class NexusMessaging {
|
|
|
635
646
|
* Handle message with batching - waits for additional messages before processing
|
|
636
647
|
*/
|
|
637
648
|
async _handleWithBatching(messageData, chatId) {
|
|
638
|
-
// Clear existing timeout if there is one
|
|
639
649
|
if (this.pendingResponses.has(chatId)) {
|
|
640
650
|
clearTimeout(this.pendingResponses.get(chatId));
|
|
641
651
|
logger.info(`Received additional message from ${chatId}, resetting wait timer`);
|
|
642
652
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const waitTime = this.batchingConfig.baseWaitTime +
|
|
646
|
-
Math.floor(Math.random() * this.batchingConfig.randomVariation);
|
|
647
|
-
|
|
648
|
-
// Set new timeout
|
|
653
|
+
|
|
654
|
+
const waitTime = this.batchingConfig.baseWaitTime;
|
|
649
655
|
const timeoutId = setTimeout(async () => {
|
|
650
656
|
try {
|
|
651
657
|
this.pendingResponses.delete(chatId);
|
|
@@ -659,13 +665,40 @@ class NexusMessaging {
|
|
|
659
665
|
logger.info(`Waiting ${Math.round(waitTime/1000)} seconds for more messages from ${chatId}`);
|
|
660
666
|
}
|
|
661
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Start typing indicator refresh interval
|
|
670
|
+
*/
|
|
671
|
+
async _startTypingRefresh(chatId) {
|
|
672
|
+
if (!this.provider || typeof this.provider.sendTypingIndicator !== 'function') {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const lastMessage = await Message.findOne({
|
|
677
|
+
numero: chatId,
|
|
678
|
+
from_me: false,
|
|
679
|
+
message_id: { $exists: true, $ne: null }
|
|
680
|
+
}).sort({ createdAt: -1 });
|
|
681
|
+
|
|
682
|
+
if (!lastMessage?.message_id) return null;
|
|
683
|
+
|
|
684
|
+
return setInterval(() =>
|
|
685
|
+
this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
|
|
686
|
+
logger.debug('[_startTypingRefresh] Failed', { error: err.message })
|
|
687
|
+
), 20000
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
662
691
|
/**
|
|
663
692
|
* Process all batched messages for a chat
|
|
664
693
|
*/
|
|
665
694
|
async _handleBatchedMessages(chatId) {
|
|
695
|
+
let typingInterval = null;
|
|
696
|
+
|
|
666
697
|
try {
|
|
667
698
|
logger.info(`Processing batched messages from ${chatId} (including media if any)`);
|
|
668
699
|
|
|
700
|
+
typingInterval = await this._startTypingRefresh(chatId);
|
|
701
|
+
|
|
669
702
|
// Get assistant response
|
|
670
703
|
const result = await replyAssistant(chatId);
|
|
671
704
|
const botResponse = typeof result === 'string' ? result : result?.output;
|
|
@@ -685,7 +718,11 @@ class NexusMessaging {
|
|
|
685
718
|
this.events.emit('messages:batched', { chatId, response: botResponse });
|
|
686
719
|
|
|
687
720
|
} catch (error) {
|
|
688
|
-
logger.error('Error in batched message handling:', error);
|
|
721
|
+
logger.error('Error in batched message handling:', { error: error.message });
|
|
722
|
+
} finally {
|
|
723
|
+
if (typingInterval) {
|
|
724
|
+
clearInterval(typingInterval);
|
|
725
|
+
}
|
|
689
726
|
}
|
|
690
727
|
}
|
|
691
728
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { Message } = require('../models/messageModel');
|
|
2
|
+
const { logger } = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Update message delivery status in the database based on Twilio status callback data
|
|
6
|
+
*/
|
|
7
|
+
async function updateMessageStatus(messageSid, status, errorCode = null, errorMessage = null) {
|
|
8
|
+
try {
|
|
9
|
+
const statusInfo = {
|
|
10
|
+
status,
|
|
11
|
+
updatedAt: new Date()
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (errorCode) {
|
|
15
|
+
statusInfo.errorCode = errorCode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (errorMessage) {
|
|
19
|
+
statusInfo.errorMessage = errorMessage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const updated = await Message.findOneAndUpdate(
|
|
23
|
+
{ message_id: messageSid },
|
|
24
|
+
{ $set: { statusInfo } },
|
|
25
|
+
{ new: true }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (updated) {
|
|
29
|
+
logger.info('[MessageStatus] Updated message status', {
|
|
30
|
+
messageSid,
|
|
31
|
+
status,
|
|
32
|
+
errorCode,
|
|
33
|
+
errorMessage
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
logger.warn('[MessageStatus] Message not found for status update', { messageSid });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return updated;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error('[MessageStatus] Error updating message status', {
|
|
42
|
+
messageSid,
|
|
43
|
+
error: error.message
|
|
44
|
+
});
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handle Twilio status callback webhook
|
|
51
|
+
*/
|
|
52
|
+
async function handleStatusCallback(twilioStatusData) {
|
|
53
|
+
const {
|
|
54
|
+
MessageSid,
|
|
55
|
+
MessageStatus,
|
|
56
|
+
ErrorCode,
|
|
57
|
+
ErrorMessage
|
|
58
|
+
} = twilioStatusData;
|
|
59
|
+
|
|
60
|
+
if (!MessageSid || !MessageStatus) {
|
|
61
|
+
logger.warn('[MessageStatus] Invalid status callback data', twilioStatusData);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return await updateMessageStatus(
|
|
66
|
+
MessageSid,
|
|
67
|
+
MessageStatus.toLowerCase(),
|
|
68
|
+
ErrorCode || null,
|
|
69
|
+
ErrorMessage || null
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get message status from database
|
|
75
|
+
*/
|
|
76
|
+
async function getMessageStatus(messageSid) {
|
|
77
|
+
try {
|
|
78
|
+
const message = await Message.findOne({ message_id: messageSid })
|
|
79
|
+
.select('statusInfo message_id numero body')
|
|
80
|
+
.lean();
|
|
81
|
+
|
|
82
|
+
return message;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.error('[MessageStatus] Error fetching message status from DB', {
|
|
85
|
+
messageSid,
|
|
86
|
+
error: error.message
|
|
87
|
+
});
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
updateMessageStatus,
|
|
94
|
+
handleStatusCallback,
|
|
95
|
+
getMessageStatus
|
|
96
|
+
};
|
|
97
|
+
|
|
@@ -63,6 +63,25 @@ const messageSchema = new mongoose.Schema({
|
|
|
63
63
|
read: {
|
|
64
64
|
type: Boolean,
|
|
65
65
|
default: false
|
|
66
|
+
},
|
|
67
|
+
statusInfo: {
|
|
68
|
+
status: {
|
|
69
|
+
type: String,
|
|
70
|
+
enum: ['queued', 'sending', 'sent', 'delivered', 'undelivered', 'failed', 'read', null],
|
|
71
|
+
default: null
|
|
72
|
+
},
|
|
73
|
+
errorCode: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: null
|
|
76
|
+
},
|
|
77
|
+
errorMessage: {
|
|
78
|
+
type: String,
|
|
79
|
+
default: null
|
|
80
|
+
},
|
|
81
|
+
updatedAt: {
|
|
82
|
+
type: Date,
|
|
83
|
+
default: null
|
|
84
|
+
}
|
|
66
85
|
}
|
|
67
86
|
}, { timestamps: true });
|
|
68
87
|
|
|
@@ -118,7 +137,13 @@ async function insertMessage(values) {
|
|
|
118
137
|
clinical_context: clinical_context,
|
|
119
138
|
origin: values.origin,
|
|
120
139
|
tools_executed: values.tools_executed || [],
|
|
121
|
-
raw: values.raw || null
|
|
140
|
+
raw: values.raw || null,
|
|
141
|
+
statusInfo: values.statusInfo || (values.delivery_status ? {
|
|
142
|
+
status: values.delivery_status,
|
|
143
|
+
errorCode: values.delivery_error_code || null,
|
|
144
|
+
errorMessage: values.delivery_error_message || null,
|
|
145
|
+
updatedAt: values.delivery_status_updated_at || null
|
|
146
|
+
} : null)
|
|
122
147
|
};
|
|
123
148
|
|
|
124
149
|
await Message.findOneAndUpdate(
|
package/lib/routes/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Export route definitions for customer servers to import
|
|
2
2
|
// These are the route patterns without controller dependencies
|
|
3
|
+
const express = require('express');
|
|
3
4
|
|
|
4
5
|
const assistantRouteDefinitions = {
|
|
5
6
|
'POST /active': 'activeAssistantController',
|
|
@@ -65,7 +66,6 @@ const templateRouteDefinitions = {
|
|
|
65
66
|
|
|
66
67
|
// Helper function to create Express router from route definitions
|
|
67
68
|
const createRouter = (routeDefinitions, controllers) => {
|
|
68
|
-
const express = require('express');
|
|
69
69
|
const router = express.Router();
|
|
70
70
|
|
|
71
71
|
for (const [route, controllerName] of Object.entries(routeDefinitions)) {
|
|
@@ -1,31 +1,18 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const AWS = require('../config/awsConfig.js');
|
|
1
|
+
const { withTracing } = require('../utils/tracingDecorator.js');
|
|
3
2
|
const llmConfig = require('../config/llmConfig');
|
|
4
|
-
const runtimeConfig = require('../config/runtimeConfig');
|
|
5
3
|
const { BaseAssistant } = require('../assistants/BaseAssistant');
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const { runAssistantAndWait, runAssistantWithRetries } = require('../helpers/assistantHelper.js');
|
|
14
|
-
const { getThread, getThreadInfo } = require('../helpers/threadHelper.js');
|
|
15
|
-
const { withTracing } = require('../utils/tracingDecorator.js');
|
|
16
|
-
const { processThreadMessage } = require('../helpers/processHelper.js');
|
|
17
|
-
const { getLastMessages, updateMessageRecord } = require('../helpers/messageHelper.js');
|
|
18
|
-
const { withThreadRecovery } = require('../helpers/threadRecoveryHelper.js');
|
|
19
|
-
const { combineImagesToPDF, cleanupFiles } = require('../helpers/filesHelper.js');
|
|
20
|
-
const { logger } = require('../utils/logger');
|
|
21
|
-
|
|
22
|
-
const DEFAULT_MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '30', 10);
|
|
4
|
+
const {
|
|
5
|
+
createAssistantCore,
|
|
6
|
+
addMsgAssistantCore,
|
|
7
|
+
addInstructionCore,
|
|
8
|
+
replyAssistantCore,
|
|
9
|
+
switchAssistantCore
|
|
10
|
+
} = require('./assistantServiceCore');
|
|
23
11
|
|
|
24
12
|
let assistantConfig = null;
|
|
25
13
|
let assistantRegistry = {};
|
|
26
14
|
let customGetAssistantById = null;
|
|
27
15
|
|
|
28
|
-
|
|
29
16
|
const configureAssistants = (config) => {
|
|
30
17
|
if (!config) {
|
|
31
18
|
throw new Error('Assistant configuration is required');
|
|
@@ -98,224 +85,46 @@ const overrideGetAssistantById = (resolverFn) => {
|
|
|
98
85
|
|
|
99
86
|
const getAssistantById = (assistant_id, thread) => {
|
|
100
87
|
if (customGetAssistantById) {
|
|
101
|
-
|
|
102
|
-
if (inst) return inst;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!assistantConfig) {
|
|
106
|
-
assistantConfig = {};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const AssistantClass = assistantRegistry[assistant_id];
|
|
110
|
-
if (!AssistantClass) {
|
|
111
|
-
throw new Error(`Assistant '${assistant_id}' not found. Available assistants: ${Object.keys(assistantRegistry).join(', ')}`);
|
|
88
|
+
return customGetAssistantById(assistant_id, thread);
|
|
112
89
|
}
|
|
113
90
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (AssistantClass.prototype instanceof BaseAssistant) {
|
|
118
|
-
return new AssistantClass({
|
|
119
|
-
assistantId: assistant_id,
|
|
120
|
-
thread,
|
|
121
|
-
client: sharedClient,
|
|
122
|
-
provider
|
|
123
|
-
});
|
|
91
|
+
if (assistantRegistry[assistant_id]) {
|
|
92
|
+
const AssistantClass = assistantRegistry[assistant_id];
|
|
93
|
+
return new AssistantClass({ thread });
|
|
124
94
|
}
|
|
125
95
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
thread,
|
|
96
|
+
if (assistantConfig && assistantConfig[assistant_id]) {
|
|
97
|
+
const config = assistantConfig[assistant_id];
|
|
98
|
+
return new BaseAssistant({
|
|
99
|
+
...config,
|
|
131
100
|
assistantId: assistant_id,
|
|
132
|
-
|
|
133
|
-
provider
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const createAssistant = async (code, assistant_id, messages=[], force=false) => {
|
|
140
|
-
const findThread = await Thread.findOne({ code: code });
|
|
141
|
-
logger.info('[createAssistant] findThread', findThread);
|
|
142
|
-
if (findThread && findThread.getConversationId() && !force) {
|
|
143
|
-
logger.info('[createAssistant] Thread already exists');
|
|
144
|
-
const updateFields = { active: true, stopped: false };
|
|
145
|
-
Thread.setAssistantId(updateFields, assistant_id);
|
|
146
|
-
await Thread.updateOne({ code: code }, { $set: updateFields });
|
|
147
|
-
return findThread;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (force && findThread?.getConversationId()) {
|
|
151
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
152
|
-
await provider.deleteConversation(findThread.getConversationId());
|
|
153
|
-
logger.info('[createAssistant] Deleted old conversation, will create new one');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const curRow = await getCurRow(Historial_Clinico_ID, code);
|
|
157
|
-
logger.info('[createAssistant] curRow', curRow[0]);
|
|
158
|
-
const nombre = curRow?.[0]?.['name'] || null;
|
|
159
|
-
const patientId = curRow?.[0]?.['record_id'] || null;
|
|
160
|
-
|
|
161
|
-
const assistant = getAssistantById(assistant_id, null);
|
|
162
|
-
const initialThread = await assistant.create(code, curRow[0]);
|
|
163
|
-
|
|
164
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
165
|
-
for (const message of messages) {
|
|
166
|
-
await provider.addMessage({
|
|
167
|
-
threadId: initialThread.id,
|
|
168
|
-
role: 'assistant',
|
|
169
|
-
content: message
|
|
101
|
+
thread
|
|
170
102
|
});
|
|
171
103
|
}
|
|
172
|
-
|
|
173
|
-
const thread = {
|
|
174
|
-
code: code,
|
|
175
|
-
patient_id: patientId,
|
|
176
|
-
nombre: nombre,
|
|
177
|
-
active: true
|
|
178
|
-
};
|
|
179
|
-
Thread.setAssistantId(thread, assistant_id);
|
|
180
|
-
Thread.setConversationId(thread, initialThread.id);
|
|
181
|
-
|
|
182
|
-
const condition = { $or: [{ code: code }] };
|
|
183
|
-
const options = { new: true, upsert: true };
|
|
184
|
-
const updatedThread = await Thread.findOneAndUpdate(condition, {run_id: null, ...thread}, options);
|
|
185
|
-
logger.info('[createAssistant] Updated thread:', updatedThread);
|
|
186
|
-
|
|
187
|
-
return thread;
|
|
188
|
-
};
|
|
189
104
|
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
let thread = await Thread.findOne({ code: code });
|
|
193
|
-
logger.info(thread);
|
|
194
|
-
if (thread === null) return null;
|
|
195
|
-
|
|
196
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
197
|
-
|
|
198
|
-
await withThreadRecovery(
|
|
199
|
-
async (recoveredThread = thread) => {
|
|
200
|
-
thread = recoveredThread;
|
|
201
|
-
for (const message of inMessages) {
|
|
202
|
-
logger.info(message);
|
|
203
|
-
await provider.addMessage({
|
|
204
|
-
threadId: thread.getConversationId(),
|
|
205
|
-
role: role,
|
|
206
|
-
content: message
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// Save system message to database for frontend visibility
|
|
210
|
-
// Skip if message is already saved (e.g., from getConversationReplyController)
|
|
211
|
-
if (!skipSystemMessage) {
|
|
212
|
-
try {
|
|
213
|
-
const message_id = `system_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
214
|
-
await insertMessage({
|
|
215
|
-
nombre_whatsapp: 'System',
|
|
216
|
-
numero: code,
|
|
217
|
-
body: message,
|
|
218
|
-
timestamp: new Date(),
|
|
219
|
-
message_id: message_id,
|
|
220
|
-
is_group: false,
|
|
221
|
-
is_media: false,
|
|
222
|
-
from_me: true,
|
|
223
|
-
processed: true,
|
|
224
|
-
origin: 'system',
|
|
225
|
-
thread_id: thread.getConversationId(),
|
|
226
|
-
assistant_id: thread.getAssistantId(),
|
|
227
|
-
raw: { role: role }
|
|
228
|
-
});
|
|
229
|
-
} catch (err) {
|
|
230
|
-
// Don't throw - we don't want to break the flow if logging fails
|
|
231
|
-
logger.error('[addMsgAssistant] Error saving system message:', err);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
thread,
|
|
237
|
-
process.env.VARIANT || 'assistants'
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
if (!reply) return null;
|
|
241
|
-
|
|
242
|
-
let output, completed;
|
|
243
|
-
let retries = 0;
|
|
244
|
-
const maxRetries = DEFAULT_MAX_RETRIES;
|
|
245
|
-
const assistant = getAssistantById(thread.getAssistantId(), thread);
|
|
246
|
-
do {
|
|
247
|
-
({ output, completed } = await runAssistantAndWait({ thread, assistant }));
|
|
248
|
-
logger.info(`Attempt ${retries + 1}: completed=${completed}, output=${output || '(empty)'}`);
|
|
249
|
-
|
|
250
|
-
if (completed && output) break;
|
|
251
|
-
if (retries < maxRetries) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
252
|
-
retries++;
|
|
253
|
-
} while (retries <= maxRetries && (!completed || !output));
|
|
254
|
-
|
|
255
|
-
logger.info('THE ANS IS', output);
|
|
256
|
-
return output;
|
|
257
|
-
} catch (error) {
|
|
258
|
-
logger.info(error);
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
105
|
+
throw new Error(`Assistant with ID "${assistant_id}" not found`);
|
|
261
106
|
};
|
|
262
107
|
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const assistant = getAssistantById(thread.getAssistantId(), thread);
|
|
273
|
-
const { output, completed, retries } = await withTracing(
|
|
274
|
-
runAssistantWithRetries,
|
|
275
|
-
'run_assistant_with_retries',
|
|
276
|
-
(thread, assistant, runConfig, patientReply) => ({
|
|
277
|
-
'assistant.id': thread.getAssistantId(),
|
|
278
|
-
'assistant.max_retries': DEFAULT_MAX_RETRIES,
|
|
279
|
-
'assistant.has_patient_reply': !!patientReply
|
|
280
|
-
})
|
|
281
|
-
)(
|
|
282
|
-
thread,
|
|
283
|
-
assistant,
|
|
284
|
-
{
|
|
285
|
-
additionalInstructions: instruction,
|
|
286
|
-
additionalMessages: [
|
|
287
|
-
{ role: role, content: instruction }
|
|
288
|
-
]
|
|
289
|
-
},
|
|
290
|
-
null // no patientReply for instructions
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
// Save instruction to database for frontend visibility
|
|
294
|
-
try {
|
|
295
|
-
const message_id = `instruction_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
296
|
-
await insertMessage({
|
|
297
|
-
nombre_whatsapp: 'Instruction',
|
|
298
|
-
numero: code,
|
|
299
|
-
body: instruction,
|
|
300
|
-
timestamp: new Date(),
|
|
301
|
-
message_id: message_id,
|
|
302
|
-
is_group: false,
|
|
303
|
-
is_media: false,
|
|
304
|
-
from_me: true,
|
|
305
|
-
processed: true,
|
|
306
|
-
origin: 'instruction',
|
|
307
|
-
thread_id: thread.getConversationId(),
|
|
308
|
-
assistant_id: thread.getAssistantId(),
|
|
309
|
-
raw: { role: role }
|
|
310
|
-
});
|
|
311
|
-
} catch (err) {
|
|
312
|
-
// Don't throw - we don't want to break the flow if logging fails
|
|
313
|
-
logger.error('[addInstructionCore] Error saving instruction message:', err);
|
|
314
|
-
}
|
|
108
|
+
const createAssistant = withTracing(
|
|
109
|
+
(code, assistant_id) => createAssistantCore(code, assistant_id, getAssistantById),
|
|
110
|
+
'create_assistant',
|
|
111
|
+
(code, assistant_id) => ({
|
|
112
|
+
'assistant.thread_code': code,
|
|
113
|
+
'assistant.id': assistant_id,
|
|
114
|
+
'operation.type': 'create_assistant'
|
|
115
|
+
})
|
|
116
|
+
);
|
|
315
117
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
118
|
+
const addMsgAssistant = withTracing(
|
|
119
|
+
addMsgAssistantCore,
|
|
120
|
+
'add_message_assistant',
|
|
121
|
+
(code, message, role) => ({
|
|
122
|
+
'message.thread_code': code,
|
|
123
|
+
'message.content_length': message?.length || 0,
|
|
124
|
+
'message.role': role,
|
|
125
|
+
'operation.type': 'add_message'
|
|
126
|
+
})
|
|
127
|
+
);
|
|
319
128
|
|
|
320
129
|
const addInsAssistant = withTracing(
|
|
321
130
|
addInstructionCore,
|
|
@@ -328,199 +137,28 @@ const addInsAssistant = withTracing(
|
|
|
328
137
|
})
|
|
329
138
|
);
|
|
330
139
|
|
|
331
|
-
const replyAssistantCore = async (code, message_ = null, thread_ = null, runOptions = {}) => {
|
|
332
|
-
const timings = {};
|
|
333
|
-
const startTotal = Date.now();
|
|
334
|
-
|
|
335
|
-
const { result: thread, duration: getThreadMs } = await withTracing(
|
|
336
|
-
getThread,
|
|
337
|
-
'get_thread_operation',
|
|
338
|
-
(threadCode) => ({
|
|
339
|
-
'thread.code': threadCode,
|
|
340
|
-
'operation.type': 'thread_retrieval',
|
|
341
|
-
'thread.provided': !!thread_
|
|
342
|
-
}),
|
|
343
|
-
{ returnTiming: true }
|
|
344
|
-
)(code);
|
|
345
|
-
timings.get_thread_ms = getThreadMs;
|
|
346
|
-
|
|
347
|
-
if (!thread_ && !thread) return null;
|
|
348
|
-
const finalThread = thread_ || thread;
|
|
349
|
-
|
|
350
|
-
const { result: patientReply, duration: getMessagesMs } = await withTracing(
|
|
351
|
-
getLastMessages,
|
|
352
|
-
'get_last_messages',
|
|
353
|
-
(code) => ({ 'thread.code': code }),
|
|
354
|
-
{ returnTiming: true }
|
|
355
|
-
)(code);
|
|
356
|
-
timings.get_messages_ms = getMessagesMs;
|
|
357
|
-
|
|
358
|
-
if (!patientReply) {
|
|
359
|
-
logger.info('[replyAssistantCore] No relevant data found for this assistant.');
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
364
|
-
|
|
365
|
-
logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
|
|
366
|
-
|
|
367
|
-
const { result: processResult, duration: processMessagesMs } = await withTracing(
|
|
368
|
-
processThreadMessage,
|
|
369
|
-
'process_thread_messages',
|
|
370
|
-
(code, patientReply, provider) => ({
|
|
371
|
-
'messages.count': patientReply.length,
|
|
372
|
-
'thread.code': code
|
|
373
|
-
}),
|
|
374
|
-
{ returnTiming: true }
|
|
375
|
-
)(code, patientReply, provider);
|
|
376
|
-
|
|
377
|
-
const { results: processResults, timings: processTimings } = processResult;
|
|
378
|
-
timings.process_messages_ms = processMessagesMs;
|
|
379
|
-
|
|
380
|
-
logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
|
|
381
|
-
|
|
382
|
-
if (processTimings) {
|
|
383
|
-
timings.process_messages_breakdown = {
|
|
384
|
-
download_ms: processTimings.download_ms || 0,
|
|
385
|
-
image_analysis_ms: processTimings.image_analysis_ms || 0,
|
|
386
|
-
audio_transcription_ms: processTimings.audio_transcription_ms || 0,
|
|
387
|
-
url_generation_ms: processTimings.url_generation_ms || 0,
|
|
388
|
-
total_media_ms: processTimings.total_media_ms || 0
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const patientMsg = processResults.some(r => r.isPatient);
|
|
393
|
-
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
394
|
-
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
395
|
-
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
396
|
-
|
|
397
|
-
if (allMessagesToAdd.length > 0) {
|
|
398
|
-
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
399
|
-
await withThreadRecovery(
|
|
400
|
-
async (thread = finalThread) => {
|
|
401
|
-
const threadId = thread.getConversationId();
|
|
402
|
-
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
403
|
-
},
|
|
404
|
-
finalThread,
|
|
405
|
-
process.env.VARIANT || 'assistants'
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
410
|
-
await cleanupFiles(allTempFiles);
|
|
411
|
-
|
|
412
|
-
if (urls.length > 0) {
|
|
413
|
-
logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
|
|
414
|
-
const { result: pdfResult, duration: pdfCombinationMs } = await withTracing(
|
|
415
|
-
combineImagesToPDF,
|
|
416
|
-
'combine_images_to_pdf',
|
|
417
|
-
({ code }) => ({
|
|
418
|
-
'pdf.thread_code': code,
|
|
419
|
-
'pdf.url_count': urls.length
|
|
420
|
-
}),
|
|
421
|
-
{ returnTiming: true }
|
|
422
|
-
)({ code });
|
|
423
|
-
timings.pdf_combination_ms = pdfCombinationMs;
|
|
424
|
-
const { pdfBuffer, processedFiles } = pdfResult;
|
|
425
|
-
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
426
|
-
|
|
427
|
-
if (pdfBuffer) {
|
|
428
|
-
const key = `${code}-${Date.now()}-combined.pdf`;
|
|
429
|
-
const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
430
|
-
if (bucket) {
|
|
431
|
-
await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (processedFiles && processedFiles.length) {
|
|
436
|
-
cleanupFiles(processedFiles);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (!patientMsg || finalThread.stopped) return null;
|
|
441
|
-
|
|
442
|
-
const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
|
|
443
|
-
const { result: runResult, duration: runAssistantMs } = await withTracing(
|
|
444
|
-
runAssistantWithRetries,
|
|
445
|
-
'run_assistant_with_retries',
|
|
446
|
-
(thread, assistant, runConfig, patientReply) => ({
|
|
447
|
-
'assistant.id': thread.getAssistantId(),
|
|
448
|
-
'assistant.max_retries': DEFAULT_MAX_RETRIES,
|
|
449
|
-
'assistant.has_patient_reply': !!patientReply
|
|
450
|
-
}),
|
|
451
|
-
{ returnTiming: true }
|
|
452
|
-
)(finalThread, assistant, runOptions, patientReply);
|
|
453
|
-
timings.run_assistant_ms = runAssistantMs;
|
|
454
|
-
timings.total_ms = Date.now() - startTotal;
|
|
455
|
-
|
|
456
|
-
const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
|
|
457
|
-
|
|
458
|
-
logger.info('[Assistant Reply Complete]', {
|
|
459
|
-
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
460
|
-
messageCount: patientReply.length,
|
|
461
|
-
hasMedia: urls.length > 0,
|
|
462
|
-
retries,
|
|
463
|
-
totalMs: timings.total_ms,
|
|
464
|
-
toolsExecuted: tools_executed?.length || 0
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
if (output && predictionTimeMs) {
|
|
468
|
-
logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
|
|
469
|
-
timing_breakdown: timings,
|
|
470
|
-
has_breakdown: !!timings.process_messages_breakdown
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
await PredictionMetrics.create({
|
|
474
|
-
message_id: `${code}-${Date.now()}`,
|
|
475
|
-
numero: code,
|
|
476
|
-
assistant_id: finalThread.getAssistantId(),
|
|
477
|
-
thread_id: finalThread.getConversationId(),
|
|
478
|
-
prediction_time_ms: predictionTimeMs,
|
|
479
|
-
retry_count: retries,
|
|
480
|
-
completed: completed,
|
|
481
|
-
timing_breakdown: timings
|
|
482
|
-
}).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return { output, tools_executed };
|
|
486
|
-
};
|
|
487
|
-
|
|
488
140
|
const replyAssistant = withTracing(
|
|
489
|
-
replyAssistantCore,
|
|
141
|
+
(code, message_, thread_, runOptions) => replyAssistantCore(code, message_, thread_, runOptions, getAssistantById),
|
|
490
142
|
'assistant_reply',
|
|
491
143
|
(code, message_, thread_, runOptions) => ({
|
|
492
144
|
'assistant.thread_code': code,
|
|
493
145
|
'assistant.has_message': !!message_,
|
|
494
146
|
'assistant.has_custom_thread': !!thread_,
|
|
495
|
-
'assistant.
|
|
147
|
+
'assistant.has_run_options': !!runOptions && Object.keys(runOptions).length > 0
|
|
496
148
|
})
|
|
497
149
|
);
|
|
498
150
|
|
|
499
|
-
const switchAssistant =
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (variant === 'responses') {
|
|
509
|
-
updateFields.prompt_id = assistant_id;
|
|
510
|
-
} else {
|
|
511
|
-
updateFields.assistant_id = assistant_id;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
await Thread.updateOne({ code }, { $set: updateFields });
|
|
515
|
-
} catch (error) {
|
|
516
|
-
logger.info(error);
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
};
|
|
151
|
+
const switchAssistant = withTracing(
|
|
152
|
+
switchAssistantCore,
|
|
153
|
+
'switch_assistant',
|
|
154
|
+
(code, assistant_id) => ({
|
|
155
|
+
'assistant.thread_code': code,
|
|
156
|
+
'assistant.new_id': assistant_id,
|
|
157
|
+
'operation.type': 'switch_assistant'
|
|
158
|
+
})
|
|
159
|
+
);
|
|
520
160
|
|
|
521
161
|
module.exports = {
|
|
522
|
-
getThread,
|
|
523
|
-
getThreadInfo,
|
|
524
162
|
getAssistantById,
|
|
525
163
|
createAssistant,
|
|
526
164
|
replyAssistant,
|
|
@@ -529,6 +167,5 @@ module.exports = {
|
|
|
529
167
|
switchAssistant,
|
|
530
168
|
configureAssistants,
|
|
531
169
|
registerAssistant,
|
|
532
|
-
overrideGetAssistantById
|
|
533
|
-
runAssistantAndWait
|
|
170
|
+
overrideGetAssistantById
|
|
534
171
|
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const AWS = require('../config/awsConfig.js');
|
|
2
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
|
+
const { createProvider } = require('../providers/createProvider');
|
|
4
|
+
|
|
5
|
+
const { Thread } = require('../models/threadModel.js');
|
|
6
|
+
const { PredictionMetrics } = require('../models/predictionMetricsModel');
|
|
7
|
+
const { insertMessage } = require('../models/messageModel');
|
|
8
|
+
|
|
9
|
+
const { getCurRow, runAssistantWithRetries } = require('../helpers/assistantHelper.js');
|
|
10
|
+
const { getThread } = require('../helpers/threadHelper.js');
|
|
11
|
+
const { processThreadMessage } = require('../helpers/processHelper.js');
|
|
12
|
+
const { getLastMessages, updateMessageRecord } = require('../helpers/messageHelper.js');
|
|
13
|
+
const { withThreadRecovery } = require('../helpers/threadRecoveryHelper.js');
|
|
14
|
+
const { combineImagesToPDF, cleanupFiles } = require('../helpers/filesHelper.js');
|
|
15
|
+
const { logger } = require('../utils/logger');
|
|
16
|
+
|
|
17
|
+
const createAssistantCore = async (code, assistant_id, getAssistantById) => {
|
|
18
|
+
const thread = await getThread(code);
|
|
19
|
+
if (!thread) return null;
|
|
20
|
+
|
|
21
|
+
const assistant = getAssistantById(assistant_id, thread);
|
|
22
|
+
const curRow = await getCurRow(code);
|
|
23
|
+
const context = { curRow };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await assistant.create(code, context);
|
|
27
|
+
return { success: true, assistant_id };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
logger.error('[createAssistantCore] Error:', error);
|
|
30
|
+
return { success: false, error: error.message };
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const addMsgAssistantCore = async (code, message, role = 'user') => {
|
|
35
|
+
const thread = await getThread(code);
|
|
36
|
+
if (!thread) return null;
|
|
37
|
+
|
|
38
|
+
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
39
|
+
const threadId = thread.getConversationId();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await provider.addMessage({ threadId, messages: [{ role, content: message }] });
|
|
43
|
+
await insertMessage({ code, message, role });
|
|
44
|
+
return { success: true };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.error('[addMsgAssistantCore] Error:', error);
|
|
47
|
+
return { success: false, error: error.message };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const addInstructionCore = async (code, instruction, role = 'user') => {
|
|
52
|
+
const thread = await getThread(code);
|
|
53
|
+
if (!thread) return null;
|
|
54
|
+
|
|
55
|
+
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
56
|
+
const threadId = thread.getConversationId();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await provider.addMessage({ threadId, messages: [{ role, content: instruction }] });
|
|
60
|
+
return { success: true };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logger.error('[addInstructionCore] Error:', error);
|
|
63
|
+
return { success: false, error: error.message };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const replyAssistantCore = async (code, message_ = null, thread_ = null, runOptions = {}, getAssistantById) => {
|
|
68
|
+
const timings = {};
|
|
69
|
+
const startTotal = Date.now();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const thread = thread_ || await getThread(code);
|
|
73
|
+
timings.get_thread_ms = 0;
|
|
74
|
+
|
|
75
|
+
if (!thread) return null;
|
|
76
|
+
const finalThread = thread;
|
|
77
|
+
|
|
78
|
+
const patientReply = await getLastMessages(code);
|
|
79
|
+
timings.get_messages_ms = 0;
|
|
80
|
+
|
|
81
|
+
if (!patientReply) {
|
|
82
|
+
logger.info('[replyAssistantCore] No relevant data found for this assistant.');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
87
|
+
logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
|
|
88
|
+
const processResult = await processThreadMessage(code, patientReply, provider);
|
|
89
|
+
|
|
90
|
+
const { results: processResults, timings: processTimings } = processResult;
|
|
91
|
+
timings.process_messages_ms = 0;
|
|
92
|
+
|
|
93
|
+
logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
|
|
94
|
+
|
|
95
|
+
if (processTimings) {
|
|
96
|
+
timings.process_messages_breakdown = {
|
|
97
|
+
download_ms: processTimings.download_ms || 0,
|
|
98
|
+
image_analysis_ms: processTimings.image_analysis_ms || 0,
|
|
99
|
+
audio_transcription_ms: processTimings.audio_transcription_ms || 0,
|
|
100
|
+
url_generation_ms: processTimings.url_generation_ms || 0,
|
|
101
|
+
total_media_ms: processTimings.total_media_ms || 0
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const patientMsg = processResults.some(r => r.isPatient);
|
|
106
|
+
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
107
|
+
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
108
|
+
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
109
|
+
|
|
110
|
+
if (allMessagesToAdd.length > 0) {
|
|
111
|
+
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
112
|
+
await withThreadRecovery(
|
|
113
|
+
async (thread = finalThread) => {
|
|
114
|
+
const threadId = thread.getConversationId();
|
|
115
|
+
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
116
|
+
},
|
|
117
|
+
finalThread,
|
|
118
|
+
process.env.VARIANT || 'assistants'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
123
|
+
await cleanupFiles(allTempFiles);
|
|
124
|
+
|
|
125
|
+
if (urls.length > 0) {
|
|
126
|
+
logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
|
|
127
|
+
const pdfResult = await combineImagesToPDF({ code });
|
|
128
|
+
timings.pdf_combination_ms = 0;
|
|
129
|
+
const { pdfBuffer, processedFiles } = pdfResult;
|
|
130
|
+
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
131
|
+
|
|
132
|
+
if (pdfBuffer) {
|
|
133
|
+
const key = `${code}-${Date.now()}-combined.pdf`;
|
|
134
|
+
const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
135
|
+
if (bucket) {
|
|
136
|
+
await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (processedFiles && processedFiles.length) {
|
|
141
|
+
cleanupFiles(processedFiles);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!patientMsg || finalThread.stopped) return null;
|
|
146
|
+
|
|
147
|
+
const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
|
|
148
|
+
const runResult = await runAssistantWithRetries(finalThread, assistant, runOptions, patientReply);
|
|
149
|
+
timings.run_assistant_ms = 0;
|
|
150
|
+
timings.total_ms = Date.now() - startTotal;
|
|
151
|
+
|
|
152
|
+
const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
|
|
153
|
+
|
|
154
|
+
logger.info('[Assistant Reply Complete]', {
|
|
155
|
+
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
156
|
+
messageCount: patientReply.length,
|
|
157
|
+
hasMedia: urls.length > 0,
|
|
158
|
+
retries,
|
|
159
|
+
totalMs: timings.total_ms,
|
|
160
|
+
toolsExecuted: tools_executed?.length || 0
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (output && predictionTimeMs) {
|
|
164
|
+
logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
|
|
165
|
+
timing_breakdown: timings,
|
|
166
|
+
has_breakdown: !!timings.process_messages_breakdown
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await PredictionMetrics.create({
|
|
170
|
+
message_id: `${code}-${Date.now()}`,
|
|
171
|
+
numero: code,
|
|
172
|
+
assistant_id: finalThread.getAssistantId(),
|
|
173
|
+
thread_id: finalThread.getConversationId(),
|
|
174
|
+
prediction_time_ms: predictionTimeMs,
|
|
175
|
+
retry_count: retries,
|
|
176
|
+
completed: completed,
|
|
177
|
+
timing_breakdown: timings
|
|
178
|
+
}).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { output, tools_executed };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.error('[replyAssistantCore] Error:', { error: error.message });
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const switchAssistantCore = async (code, assistant_id) => {
|
|
189
|
+
try {
|
|
190
|
+
const thread = await Thread.findOne({ code });
|
|
191
|
+
if (!thread) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const updateFields = {
|
|
196
|
+
assistant_id,
|
|
197
|
+
stopped: false,
|
|
198
|
+
updatedAt: new Date()
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
await Thread.updateOne({ code }, { $set: updateFields });
|
|
202
|
+
return { success: true, assistant_id };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.info(error);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
createAssistantCore,
|
|
211
|
+
addMsgAssistantCore,
|
|
212
|
+
addInstructionCore,
|
|
213
|
+
replyAssistantCore,
|
|
214
|
+
switchAssistantCore
|
|
215
|
+
};
|
|
@@ -49,6 +49,11 @@ const TwilioService = {
|
|
|
49
49
|
return await nexusProvider.createTemplate(templateData);
|
|
50
50
|
},
|
|
51
51
|
|
|
52
|
+
async getMessageStatus(messageSid) {
|
|
53
|
+
checkTwilioSupport();
|
|
54
|
+
return await nexusProvider.getMessageStatus(messageSid);
|
|
55
|
+
},
|
|
56
|
+
|
|
52
57
|
// Add any other Twilio operations as needed
|
|
53
58
|
configureNexusProvider
|
|
54
59
|
};
|
|
@@ -187,7 +187,13 @@ class MongoStorage {
|
|
|
187
187
|
template_variables: messageData.variables ? JSON.stringify(messageData.variables) : null,
|
|
188
188
|
raw: messageData.raw || null,
|
|
189
189
|
origin,
|
|
190
|
-
tools_executed: messageData.tools_executed || []
|
|
190
|
+
tools_executed: messageData.tools_executed || [],
|
|
191
|
+
statusInfo: messageData.statusInfo || (messageData.delivery_status ? {
|
|
192
|
+
status: messageData.delivery_status,
|
|
193
|
+
errorCode: messageData.delivery_error_code || null,
|
|
194
|
+
errorMessage: messageData.delivery_error_message || null,
|
|
195
|
+
updatedAt: messageData.delivery_status_updated_at || null
|
|
196
|
+
} : null)
|
|
191
197
|
};
|
|
192
198
|
}
|
|
193
199
|
|