@peopl-health/nexus 2.4.13 → 2.5.1
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 +20 -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 +166 -138
- 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
|
+
|
|
@@ -42,8 +42,7 @@ class NexusMessaging {
|
|
|
42
42
|
this.pendingResponses = new Map();
|
|
43
43
|
this.batchingConfig = {
|
|
44
44
|
enabled: config.messageBatching?.enabled ?? false,
|
|
45
|
-
baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000
|
|
46
|
-
randomVariation: config.messageBatching?.randomVariation ?? 5000
|
|
45
|
+
baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000
|
|
47
46
|
};
|
|
48
47
|
}
|
|
49
48
|
|
|
@@ -373,6 +372,22 @@ class NexusMessaging {
|
|
|
373
372
|
} else if (messageData.flow) {
|
|
374
373
|
return await this.handleFlow(messageData);
|
|
375
374
|
} else {
|
|
375
|
+
if (chatId && this.provider && typeof this.provider.sendTypingIndicator === 'function') {
|
|
376
|
+
const messageId = messageData.id || messageData.MessageSid || messageData.message_id;
|
|
377
|
+
if (messageId) {
|
|
378
|
+
this.provider.sendTypingIndicator(messageId)
|
|
379
|
+
.then(() => logger.debug('[processIncomingMessage] Typing indicator sent successfully', { messageId }))
|
|
380
|
+
.catch(err => logger.warn('[processIncomingMessage] Typing indicator failed', {
|
|
381
|
+
error: err.message,
|
|
382
|
+
messageId
|
|
383
|
+
}));
|
|
384
|
+
} else {
|
|
385
|
+
logger.warn('[processIncomingMessage] No message ID found for typing indicator', {
|
|
386
|
+
messageData: JSON.stringify(messageData).substring(0, 200)
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
376
391
|
// For regular messages and media, use batching if enabled
|
|
377
392
|
logger.info('Batching config:', this.batchingConfig);
|
|
378
393
|
if (this.batchingConfig.enabled && chatId) {
|
|
@@ -635,17 +650,12 @@ class NexusMessaging {
|
|
|
635
650
|
* Handle message with batching - waits for additional messages before processing
|
|
636
651
|
*/
|
|
637
652
|
async _handleWithBatching(messageData, chatId) {
|
|
638
|
-
// Clear existing timeout if there is one
|
|
639
653
|
if (this.pendingResponses.has(chatId)) {
|
|
640
654
|
clearTimeout(this.pendingResponses.get(chatId));
|
|
641
655
|
logger.info(`Received additional message from ${chatId}, resetting wait timer`);
|
|
642
656
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const waitTime = this.batchingConfig.baseWaitTime +
|
|
646
|
-
Math.floor(Math.random() * this.batchingConfig.randomVariation);
|
|
647
|
-
|
|
648
|
-
// Set new timeout
|
|
657
|
+
|
|
658
|
+
const waitTime = this.batchingConfig.baseWaitTime;
|
|
649
659
|
const timeoutId = setTimeout(async () => {
|
|
650
660
|
try {
|
|
651
661
|
this.pendingResponses.delete(chatId);
|
|
@@ -685,7 +695,7 @@ class NexusMessaging {
|
|
|
685
695
|
this.events.emit('messages:batched', { chatId, response: botResponse });
|
|
686
696
|
|
|
687
697
|
} catch (error) {
|
|
688
|
-
logger.error('Error in batched message handling:', error);
|
|
698
|
+
logger.error('Error in batched message handling:', { error: error.message });
|
|
689
699
|
}
|
|
690
700
|
}
|
|
691
701
|
|
|
@@ -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)) {
|
|
@@ -5,6 +5,7 @@ const runtimeConfig = require('../config/runtimeConfig');
|
|
|
5
5
|
const { BaseAssistant } = require('../assistants/BaseAssistant');
|
|
6
6
|
const { createProvider } = require('../providers/createProvider');
|
|
7
7
|
|
|
8
|
+
const { Message } = require('../models/messageModel.js');
|
|
8
9
|
const { Thread } = require('../models/threadModel.js');
|
|
9
10
|
const { PredictionMetrics } = require('../models/predictionMetricsModel');
|
|
10
11
|
const { insertMessage } = require('../models/messageModel');
|
|
@@ -328,161 +329,188 @@ const addInsAssistant = withTracing(
|
|
|
328
329
|
})
|
|
329
330
|
);
|
|
330
331
|
|
|
332
|
+
const startTypingIndicator = async (provider, code) => {
|
|
333
|
+
try {
|
|
334
|
+
const lastMessage = await Message.findOne({
|
|
335
|
+
numero: code,
|
|
336
|
+
from_me: false,
|
|
337
|
+
message_id: { $exists: true, $ne: null }
|
|
338
|
+
}).sort({ createdAt: -1 });
|
|
339
|
+
|
|
340
|
+
if (!lastMessage?.message_id) return null;
|
|
341
|
+
|
|
342
|
+
return setInterval(() =>
|
|
343
|
+
provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
|
|
344
|
+
logger.debug('[startTypingIndicator] Interval failed', { error: err.message })
|
|
345
|
+
), 25000
|
|
346
|
+
);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logger.debug('[startTypingIndicator] Failed to start', { error: err.message });
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
331
353
|
const replyAssistantCore = async (code, message_ = null, thread_ = null, runOptions = {}) => {
|
|
332
354
|
const timings = {};
|
|
333
355
|
const startTotal = Date.now();
|
|
334
356
|
|
|
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
357
|
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
358
|
+
const typingInterval = await startTypingIndicator(provider, code);
|
|
364
359
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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);
|
|
360
|
+
try {
|
|
361
|
+
const { result: thread, duration: getThreadMs } = await withTracing(
|
|
362
|
+
getThread,
|
|
363
|
+
'get_thread_operation',
|
|
364
|
+
(threadCode) => ({
|
|
365
|
+
'thread.code': threadCode,
|
|
366
|
+
'operation.type': 'thread_retrieval',
|
|
367
|
+
'thread.provided': !!thread_
|
|
368
|
+
}),
|
|
369
|
+
{ returnTiming: true }
|
|
370
|
+
)(code);
|
|
371
|
+
timings.get_thread_ms = getThreadMs;
|
|
372
|
+
|
|
373
|
+
if (!thread_ && !thread) return null;
|
|
374
|
+
const finalThread = thread_ || thread;
|
|
375
|
+
|
|
376
|
+
const { result: patientReply, duration: getMessagesMs } = await withTracing(
|
|
377
|
+
getLastMessages,
|
|
378
|
+
'get_last_messages',
|
|
379
|
+
(code) => ({ 'thread.code': code }),
|
|
380
|
+
{ returnTiming: true }
|
|
381
|
+
)(code);
|
|
382
|
+
timings.get_messages_ms = getMessagesMs;
|
|
383
|
+
|
|
384
|
+
if (!patientReply) {
|
|
385
|
+
logger.info('[replyAssistantCore] No relevant data found for this assistant.');
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
411
388
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
'
|
|
419
|
-
'pdf.url_count': urls.length
|
|
389
|
+
logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
|
|
390
|
+
const { result: processResult, duration: processMessagesMs } = await withTracing(
|
|
391
|
+
processThreadMessage,
|
|
392
|
+
'process_thread_messages',
|
|
393
|
+
(code, patientReply, provider) => ({
|
|
394
|
+
'messages.count': patientReply.length,
|
|
395
|
+
'thread.code': code
|
|
420
396
|
}),
|
|
421
397
|
{ returnTiming: true }
|
|
422
|
-
)(
|
|
423
|
-
timings.pdf_combination_ms = pdfCombinationMs;
|
|
424
|
-
const { pdfBuffer, processedFiles } = pdfResult;
|
|
425
|
-
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
398
|
+
)(code, patientReply, provider);
|
|
426
399
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
400
|
+
const { results: processResults, timings: processTimings } = processResult;
|
|
401
|
+
timings.process_messages_ms = processMessagesMs;
|
|
402
|
+
|
|
403
|
+
logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
|
|
404
|
+
|
|
405
|
+
if (processTimings) {
|
|
406
|
+
timings.process_messages_breakdown = {
|
|
407
|
+
download_ms: processTimings.download_ms || 0,
|
|
408
|
+
image_analysis_ms: processTimings.image_analysis_ms || 0,
|
|
409
|
+
audio_transcription_ms: processTimings.audio_transcription_ms || 0,
|
|
410
|
+
url_generation_ms: processTimings.url_generation_ms || 0,
|
|
411
|
+
total_media_ms: processTimings.total_media_ms || 0
|
|
412
|
+
};
|
|
433
413
|
}
|
|
414
|
+
|
|
415
|
+
const patientMsg = processResults.some(r => r.isPatient);
|
|
416
|
+
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
417
|
+
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
418
|
+
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
434
419
|
|
|
435
|
-
if (
|
|
436
|
-
|
|
420
|
+
if (allMessagesToAdd.length > 0) {
|
|
421
|
+
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
422
|
+
await withThreadRecovery(
|
|
423
|
+
async (thread = finalThread) => {
|
|
424
|
+
const threadId = thread.getConversationId();
|
|
425
|
+
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
426
|
+
},
|
|
427
|
+
finalThread,
|
|
428
|
+
process.env.VARIANT || 'assistants'
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
433
|
+
await cleanupFiles(allTempFiles);
|
|
434
|
+
|
|
435
|
+
if (urls.length > 0) {
|
|
436
|
+
logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
|
|
437
|
+
const { result: pdfResult, duration: pdfCombinationMs } = await withTracing(
|
|
438
|
+
combineImagesToPDF,
|
|
439
|
+
'combine_images_to_pdf',
|
|
440
|
+
({ code }) => ({
|
|
441
|
+
'pdf.thread_code': code,
|
|
442
|
+
'pdf.url_count': urls.length
|
|
443
|
+
}),
|
|
444
|
+
{ returnTiming: true }
|
|
445
|
+
)({ code });
|
|
446
|
+
timings.pdf_combination_ms = pdfCombinationMs;
|
|
447
|
+
const { pdfBuffer, processedFiles } = pdfResult;
|
|
448
|
+
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
449
|
+
|
|
450
|
+
if (pdfBuffer) {
|
|
451
|
+
const key = `${code}-${Date.now()}-combined.pdf`;
|
|
452
|
+
const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
453
|
+
if (bucket) {
|
|
454
|
+
await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (processedFiles && processedFiles.length) {
|
|
459
|
+
cleanupFiles(processedFiles);
|
|
460
|
+
}
|
|
437
461
|
}
|
|
438
|
-
}
|
|
439
462
|
|
|
440
|
-
|
|
463
|
+
if (!patientMsg || finalThread.stopped) return null;
|
|
441
464
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
});
|
|
465
|
+
const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
|
|
466
|
+
const { result: runResult, duration: runAssistantMs } = await withTracing(
|
|
467
|
+
runAssistantWithRetries,
|
|
468
|
+
'run_assistant_with_retries',
|
|
469
|
+
(thread, assistant, runConfig, patientReply) => ({
|
|
470
|
+
'assistant.id': thread.getAssistantId(),
|
|
471
|
+
'assistant.max_retries': DEFAULT_MAX_RETRIES,
|
|
472
|
+
'assistant.has_patient_reply': !!patientReply
|
|
473
|
+
}),
|
|
474
|
+
{ returnTiming: true }
|
|
475
|
+
)(finalThread, assistant, runOptions, patientReply);
|
|
476
|
+
timings.run_assistant_ms = runAssistantMs;
|
|
477
|
+
timings.total_ms = Date.now() - startTotal;
|
|
472
478
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
479
|
+
const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
|
|
480
|
+
|
|
481
|
+
logger.info('[Assistant Reply Complete]', {
|
|
482
|
+
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
483
|
+
messageCount: patientReply.length,
|
|
484
|
+
hasMedia: urls.length > 0,
|
|
485
|
+
retries,
|
|
486
|
+
totalMs: timings.total_ms,
|
|
487
|
+
toolsExecuted: tools_executed?.length || 0
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (output && predictionTimeMs) {
|
|
491
|
+
logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
|
|
492
|
+
timing_breakdown: timings,
|
|
493
|
+
has_breakdown: !!timings.process_messages_breakdown
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await PredictionMetrics.create({
|
|
497
|
+
message_id: `${code}-${Date.now()}`,
|
|
498
|
+
numero: code,
|
|
499
|
+
assistant_id: finalThread.getAssistantId(),
|
|
500
|
+
thread_id: finalThread.getConversationId(),
|
|
501
|
+
prediction_time_ms: predictionTimeMs,
|
|
502
|
+
retry_count: retries,
|
|
503
|
+
completed: completed,
|
|
504
|
+
timing_breakdown: timings
|
|
505
|
+
}).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
|
|
506
|
+
}
|
|
484
507
|
|
|
485
|
-
|
|
508
|
+
return { output, tools_executed };
|
|
509
|
+
} finally {
|
|
510
|
+
if (typingInterval) {
|
|
511
|
+
clearInterval(typingInterval);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
486
514
|
};
|
|
487
515
|
|
|
488
516
|
const replyAssistant = withTracing(
|
|
@@ -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
|
|