@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.
@@ -9,8 +9,59 @@ app.use(express.json());
9
9
  class GeneralAssistant extends BaseAssistant {
10
10
  constructor(options = {}) {
11
11
  super(options);
12
- // You can add custom tools or setup here
13
- // Example: this.registerTool('toolName', schema, handler);
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
- // Calculate wait time with random variation
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(
@@ -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
- 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);
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
- 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
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
- )({ code });
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
- 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
- }
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 (processedFiles && processedFiles.length) {
436
- cleanupFiles(processedFiles);
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
- if (!patientMsg || finalThread.stopped) return null;
463
+ if (!patientMsg || finalThread.stopped) return null;
441
464
 
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
- });
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
- 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
- }
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
- return { output, tools_executed };
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "2.4.13",
3
+ "version": "2.5.1",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",