@peopl-health/nexus 2.7.1 → 2.8.0

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.
@@ -1,5 +1,6 @@
1
1
  const { MessageProvider } = require('../core/MessageProvider');
2
2
  const { logger } = require('../utils/logger');
3
+ const { calculateDelay } = require('../utils/scheduleUtils');
3
4
 
4
5
  /**
5
6
  * Baileys WhatsApp messaging provider
@@ -150,7 +151,7 @@ class BaileysProvider extends MessageProvider {
150
151
 
151
152
  async sendScheduledMessage(scheduledMessage) {
152
153
  const { sendTime, timeZone, __nexusSend } = scheduledMessage;
153
- const delay = this.calculateDelay(sendTime, timeZone);
154
+ const delay = calculateDelay(sendTime, timeZone);
154
155
 
155
156
  const sender = typeof __nexusSend === 'function'
156
157
  ? __nexusSend
@@ -181,11 +182,6 @@ class BaileysProvider extends MessageProvider {
181
182
  return { scheduled: true, delay };
182
183
  }
183
184
 
184
- calculateDelay(sendTime) {
185
- const now = new Date();
186
- const targetTime = new Date(sendTime);
187
- return Math.max(0, targetTime.getTime() - now.getTime());
188
- }
189
185
 
190
186
  getConnectionStatus() {
191
187
  return this.waSocket?.user ? true : false;
@@ -3,10 +3,11 @@ const axios = require('axios');
3
3
  const runtimeConfig = require('../config/runtimeConfig');
4
4
  const { uploadMediaToS3, getFileExtension } = require('../helpers/mediaHelper');
5
5
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
6
- const { sanitizeMediaFilename } = require('../utils/sanitizer');
6
+ const { sanitizeMediaFilename } = require('../utils/inputSanitizer');
7
7
  const { generatePresignedUrl } = require('../config/awsConfig');
8
8
  const { validateMedia, getMediaType } = require('../utils/mediaValidator');
9
9
  const { logger } = require('../utils/logger');
10
+ const { calculateDelay } = require('../utils/scheduleUtils');
10
11
  const { v4: uuidv4 } = require('uuid');
11
12
 
12
13
  /**
@@ -220,7 +221,7 @@ class TwilioProvider extends MessageProvider {
220
221
 
221
222
  async sendScheduledMessage(scheduledMessage) {
222
223
  const { sendTime, timeZone, __nexusSend } = scheduledMessage;
223
- const delay = this.calculateDelay(sendTime, timeZone);
224
+ const delay = calculateDelay(sendTime, timeZone);
224
225
 
225
226
  const sender = typeof __nexusSend === 'function'
226
227
  ? __nexusSend
@@ -423,11 +424,6 @@ class TwilioProvider extends MessageProvider {
423
424
  }
424
425
  }
425
426
 
426
- calculateDelay(sendTime) {
427
- const now = new Date();
428
- const targetTime = new Date(sendTime);
429
- return Math.max(0, targetTime.getTime() - now.getTime());
430
- }
431
427
 
432
428
  /**
433
429
  * Split a message into chunks at sentence boundaries, respecting Twilio's character limit
@@ -46,8 +46,8 @@ const reportBugController = async (req, res) => {
46
46
 
47
47
  if (!reporter) return res.status(400).json({ success: false, error: 'Reporter username is required' });
48
48
  if (!whatsapp_id) return res.status(400).json({ success: false, error: 'WhatsApp ID is required' });
49
- if (!severity || !['low', 'medium', 'high'].includes(severity)) {
50
- return res.status(400).json({ success: false, error: 'Severity must be low, medium, or high' });
49
+ if (!severity || !['low', 'medium', 'high', 'critical'].includes(severity)) {
50
+ return res.status(400).json({ success: false, error: 'Severity must be low, medium, high, or critical' });
51
51
  }
52
52
 
53
53
  logBugReportToAirtable(reporter, whatsapp_id, description, severity, messages).catch(err =>
@@ -43,9 +43,14 @@ class NexusMessaging {
43
43
  };
44
44
  // Message processing with check-after strategy
45
45
  this.processingLocks = new Map(); // Per-chat locks to prevent parallel processing
46
+ this.activeRequests = new Map(); // Track active AI requests per chat
47
+ this.abandonedRuns = new Set(); // Track runs that should be ignored
46
48
  this.batchingConfig = {
47
49
  enabled: config.messageBatching?.enabled ?? true, // Enabled by default with check-after
48
- checkDelayMs: config.messageBatching?.checkDelayMs ?? 100 // Delay before checking for new messages
50
+ abortOnNewMessage: config.messageBatching?.abortOnNewMessage ?? true, // Abort ongoing AI calls when new messages arrive
51
+ immediateRestart: config.messageBatching?.immediateRestart ?? true, // Start new processing immediately without waiting
52
+ batchWindowMs: config.messageBatching?.batchWindowMs ?? 2000, // Wait up to 2s for message bursts
53
+ maxBatchWait: config.messageBatching?.maxBatchWait ?? 5000 // Maximum time to wait for batching
49
54
  };
50
55
  }
51
56
 
@@ -348,6 +353,7 @@ class NexusMessaging {
348
353
  if (this.messageStorage) {
349
354
  await this.messageStorage.saveMessage({
350
355
  ...messageData,
356
+ messageId: messageData.id,
351
357
  timestamp: new Date(),
352
358
  fromMe: false,
353
359
  origin: 'patient'
@@ -651,49 +657,83 @@ class NexusMessaging {
651
657
  * Handle message with check-after strategy - process immediately, check for new messages after
652
658
  */
653
659
  async _handleWithCheckAfter(chatId) {
654
- // If already processing this chat, just return (message is saved, will be picked up)
660
+ const typingInterval = await this._startTypingRefresh(chatId);
661
+
655
662
  if (this.processingLocks.has(chatId)) {
656
663
  logger.info(`[CheckAfter] Already processing ${chatId}, new message will be included`);
664
+
665
+ if (this.batchingConfig.abortOnNewMessage && this.activeRequests.has(chatId)) {
666
+ const runId = this.activeRequests.get(chatId);
667
+ this.abandonedRuns.add(runId);
668
+ logger.info(`[CheckAfter] Marked run ${runId} as abandoned for ${chatId}`);
669
+
670
+ if (this.batchingConfig.immediateRestart) {
671
+ this.processingLocks.delete(chatId);
672
+ this.activeRequests.delete(chatId);
673
+
674
+ logger.info(`[CheckAfter] Starting immediate reprocessing for ${chatId}`);
675
+ await this._processWithLock(chatId, null);
676
+ }
677
+ }
657
678
  return;
658
679
  }
659
680
 
660
- await this._processWithLock(chatId);
681
+ await this._processWithLock(chatId, typingInterval);
661
682
  }
662
683
 
663
684
  /**
664
685
  * Process messages with per-chat lock and check-after logic
665
686
  */
666
- async _processWithLock(chatId) {
687
+ async _processWithLock(chatId, existingTypingInterval = null) {
667
688
  this.processingLocks.set(chatId, true);
668
- let typingInterval = null;
689
+ let typingInterval = existingTypingInterval;
690
+ let runId = null;
669
691
 
670
692
  try {
671
- typingInterval = await this._startTypingRefresh(chatId);
672
- logger.info(`[CheckAfter] Processing messages for ${chatId}`);
693
+ if (!typingInterval) {
694
+ typingInterval = await this._startTypingRefresh(chatId);
695
+ }
696
+
697
+ const startTime = Date.now();
698
+ let messageCount = await this._getUnprocessedMessageCount(chatId);
699
+ let lastCount = messageCount;
700
+
701
+ while (Date.now() - startTime < this.batchingConfig.batchWindowMs) {
702
+ await new Promise(resolve => setTimeout(resolve, 500));
703
+ const newCount = await this._getUnprocessedMessageCount(chatId);
704
+
705
+ if (newCount > lastCount) {
706
+ lastCount = newCount;
707
+ logger.info(`[Batching] New message detected for ${chatId}, extending wait`);
708
+ }
709
+
710
+ if (Date.now() - startTime >= this.batchingConfig.maxBatchWait) {
711
+ logger.info(`[Batching] Max wait reached for ${chatId}`);
712
+ break;
713
+ }
714
+ }
715
+
716
+ logger.info(`[CheckAfter] Processing ${lastCount} messages for ${chatId} after batching`);
673
717
 
674
- // Process with assistant
675
- const result = await replyAssistant(chatId);
718
+ runId = `run_${Date.now()}_${Math.random().toString(36).substring(7)}`;
719
+ this.activeRequests.set(chatId, runId);
720
+
721
+ const result = await replyAssistant(chatId, null, null, { runId });
722
+
723
+ if (this.abandonedRuns.has(runId)) {
724
+ logger.info(`[CheckAfter] Discarding abandoned run ${runId} for ${chatId}`);
725
+ this.abandonedRuns.delete(runId);
726
+ return;
727
+ }
728
+
676
729
  const botResponse = typeof result === 'string' ? result : result?.output;
677
730
  const tools_executed = typeof result === 'object' ? result?.tools_executed : undefined;
678
731
 
679
- // Small delay to catch very recent DB writes
680
- await new Promise(resolve => setTimeout(resolve, this.batchingConfig.checkDelayMs));
681
-
682
- // Check for new unprocessed messages
683
- const hasNewMessages = await Message.exists({
684
- numero: chatId,
685
- processed: false,
686
- from_me: false
687
- });
688
-
689
- if (hasNewMessages) {
690
- logger.info(`[CheckAfter] New messages detected for ${chatId}, discarding response and reprocessing`);
691
- if (typingInterval) clearInterval(typingInterval);
692
- // Recursively process with new messages
693
- return await this._processWithLock(chatId);
732
+ if (typingInterval) {
733
+ clearInterval(typingInterval);
734
+ typingInterval = null;
694
735
  }
695
736
 
696
- // No new messages - send response and mark processed
697
737
  if (botResponse) {
698
738
  await this.sendMessage({
699
739
  code: chatId,
@@ -711,9 +751,25 @@ class NexusMessaging {
711
751
  } finally {
712
752
  if (typingInterval) clearInterval(typingInterval);
713
753
  this.processingLocks.delete(chatId);
754
+ this.activeRequests.delete(chatId);
755
+ if (this.abandonedRuns.size > 100) {
756
+ this.abandonedRuns.clear();
757
+ }
714
758
  }
715
759
  }
716
760
 
761
+ /**
762
+ * Get count of unprocessed messages for a chat
763
+ */
764
+ async _getUnprocessedMessageCount(chatId) {
765
+ const { Message } = require('../models/messageModel');
766
+ return await Message.countDocuments({
767
+ numero: chatId,
768
+ processed: false,
769
+ from_me: false
770
+ });
771
+ }
772
+
717
773
  /**
718
774
  * Start typing indicator refresh interval
719
775
  */
@@ -725,10 +781,16 @@ class NexusMessaging {
725
781
  const lastMessage = await Message.findOne({
726
782
  numero: chatId,
727
783
  from_me: false,
728
- message_id: { $exists: true, $ne: null }
784
+ message_id: { $exists: true, $ne: null, $not: /^pending-/ },
785
+ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
729
786
  }).sort({ createdAt: -1 });
730
787
 
731
- if (!lastMessage?.message_id) return null;
788
+ if (!lastMessage?.message_id) {
789
+ logger.debug(`[_startTypingRefresh] No valid message for typing indicator: ${chatId}`);
790
+ return null;
791
+ }
792
+
793
+ logger.debug(`[_startTypingRefresh] Starting typing indicator for message: ${lastMessage.message_id}`);
732
794
 
733
795
  return setInterval(() =>
734
796
  this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
@@ -7,7 +7,7 @@ const sharp = require('sharp');
7
7
 
8
8
  const { downloadFileFromS3 } = require('../config/awsConfig.js');
9
9
  const { Message } = require('../models/messageModel.js');
10
- const { sanitizeFilename } = require('../utils/sanitizer.js');
10
+ const { sanitizeFilename } = require('../utils/inputSanitizer.js');
11
11
  const { logger } = require('../utils/logger');
12
12
 
13
13
  async function convertPdfToImages(pdfName, existingPdfPath = null) {
@@ -1,7 +1,7 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const AWS = require('../config/awsConfig.js');
4
- const { sanitizeMediaFilename } = require('../utils/sanitizer.js');
4
+ const { sanitizeMediaFilename } = require('../utils/inputSanitizer.js');
5
5
  const { logger } = require('../utils/logger');
6
6
 
7
7
  async function uploadMediaToS3(buffer, messageID, titleFile, bucketName, contentType, messageType) {
@@ -10,13 +10,13 @@ async function uploadMediaToS3(buffer, messageID, titleFile, bucketName, content
10
10
  const fileName = sanitizedTitle
11
11
  ? `${messageType}/${messageID}_${sanitizedTitle}.${extension}`
12
12
  : `${messageType}/${messageID}.${extension}`;
13
- logger.info(titleFile, messageType);
13
+ logger.info('[uploadMediaToS3] Media file prepared for upload', { fileName, messageType });
14
14
 
15
15
  try {
16
16
  await AWS.uploadBufferToS3(buffer, bucketName, fileName, contentType);
17
17
  return fileName;
18
18
  } catch (error) {
19
- logger.error('Failed to upload media to S3:', error.stack);
19
+ logger.error('[uploadMediaToS3] Failed to upload media to S3:', error.stack);
20
20
  throw error;
21
21
  }
22
22
  }
@@ -2,19 +2,31 @@ const moment = require('moment-timezone');
2
2
  const { Message } = require('../models/messageModel.js');
3
3
  const { logger } = require('../utils/logger');
4
4
 
5
- const updateMessageRecord = async (reply, thread) => {
5
+ const updateMessageRecord = async (reply, thread, processedContent = null) => {
6
6
  const threadId = thread.getConversationId();
7
7
 
8
+ const updateData = {
9
+ assistant_id: thread.getAssistantId(),
10
+ thread_id: threadId,
11
+ processed: true
12
+ };
13
+
14
+ if (processedContent && reply.media) {
15
+ updateData.media = {
16
+ ...reply.media,
17
+ metadata: {
18
+ ...(reply.media.metadata || {}),
19
+ processed_content: processedContent
20
+ }
21
+ };
22
+ }
23
+
8
24
  await Message.updateOne(
9
25
  { message_id: reply.message_id, timestamp: reply.timestamp },
10
- { $set: {
11
- assistant_id: thread.getAssistantId(),
12
- thread_id: threadId,
13
- processed: true
14
- } }
26
+ { $set: updateData }
15
27
  );
16
28
 
17
- logger.info(`[updateMessageRecord] Record updated - ID: ${reply.message_id}, Thread: ${threadId}, Processed: true`);
29
+ logger.info(`[updateMessageRecord] Record updated - ID: ${reply.message_id}, Thread: ${threadId}, Processed: true, MediaContentUpdated: ${!!(processedContent && reply.media)}`);
18
30
  };
19
31
 
20
32
 
@@ -81,7 +93,12 @@ function formatMessage(reply) {
81
93
  .locale('es')
82
94
  .format('dddd, D [de] MMMM [de] YYYY [a las] h:mm A');
83
95
 
84
- return `[${mexicoCityTime}] ${reply.body}`;
96
+ let messageContent = reply.body;
97
+ if (reply.media?.metadata?.processed_content) {
98
+ messageContent = reply.media.metadata.processed_content;
99
+ }
100
+
101
+ return `[${mexicoCityTime}] ${messageContent}`;
85
102
  } catch (error) {
86
103
  logger.error(`[formatMessage] Error for message ID: ${reply.message_id}:`, error?.message || String(error));
87
104
  return null;
@@ -1,27 +1,28 @@
1
1
  const { Message } = require('../models/messageModel');
2
2
  const { logger } = require('../utils/logger');
3
+ const { handle24HourWindowError } = require('./templateRecoveryHelper');
3
4
 
4
5
  /**
5
6
  * Update message delivery status in the database based on Twilio status callback data
6
7
  */
7
8
  async function updateMessageStatus(messageSid, status, errorCode = null, errorMessage = null) {
8
9
  try {
9
- const statusInfo = {
10
- status,
11
- updatedAt: new Date()
10
+ const updateData = {
11
+ 'statusInfo.status': status,
12
+ 'statusInfo.updatedAt': new Date()
12
13
  };
13
14
 
14
15
  if (errorCode) {
15
- statusInfo.errorCode = errorCode;
16
+ updateData['statusInfo.errorCode'] = errorCode;
16
17
  }
17
18
 
18
19
  if (errorMessage) {
19
- statusInfo.errorMessage = errorMessage;
20
+ updateData['statusInfo.errorMessage'] = errorMessage;
20
21
  }
21
22
 
22
23
  const updated = await Message.findOneAndUpdate(
23
24
  { message_id: messageSid },
24
- { $set: { statusInfo } },
25
+ { $set: updateData },
25
26
  { new: true }
26
27
  );
27
28
 
@@ -62,12 +63,20 @@ async function handleStatusCallback(twilioStatusData) {
62
63
  return null;
63
64
  }
64
65
 
65
- return await updateMessageStatus(
66
+ const updated = await updateMessageStatus(
66
67
  MessageSid,
67
68
  MessageStatus.toLowerCase(),
68
69
  ErrorCode || null,
69
70
  ErrorMessage || null
70
71
  );
72
+
73
+ if ((ErrorCode === 63016 || ErrorCode === '63016') && updated) {
74
+ handle24HourWindowError(updated, MessageSid).catch(err =>
75
+ logger.error('[MessageStatus] Recovery error', { messageSid: MessageSid, error: err.message })
76
+ );
77
+ }
78
+
79
+ return updated;
71
80
  }
72
81
 
73
82
  /**
@@ -2,8 +2,7 @@ const fs = require('fs');
2
2
  const { generatePresignedUrl } = require('../config/awsConfig.js');
3
3
  const { analyzeImage } = require('./llmsHelper.js');
4
4
  const { cleanupFiles, downloadMediaAndCreateFile } = require('./filesHelper.js');
5
- const { formatMessage } = require('./messageHelper.js');
6
- const { sanitizeLogMetadata } = require('../utils/sanitizer.js');
5
+ const { sanitizeLogMetadata } = require('../utils/inputSanitizer.js');
7
6
  const { withTracing } = require('../utils/tracingDecorator.js');
8
7
 
9
8
  /**
@@ -35,23 +34,19 @@ const logger = {
35
34
 
36
35
  /**
37
36
  * Dedicated message processing utilities
38
- * Handles text messages, media files, audio transcription, and thread operations
39
37
  */
40
38
  const processTextMessage = (reply) => {
41
- const formattedMessage = formatMessage(reply);
42
39
  logger.info('processTextMessage', {
43
40
  message_id: reply.message_id,
44
41
  timestamp: reply.timestamp,
45
42
  from_me: reply.from_me,
46
43
  body: reply.body,
47
- hasContent: !!formattedMessage,
48
- formattedMessage
44
+ hasContent: !!reply.body
49
45
  });
50
- logger.debug('processTextMessage_content', { formattedMessage });
51
46
 
52
47
  const messagesChat = [];
53
- if (formattedMessage) {
54
- messagesChat.push({ type: 'text', text: formattedMessage });
48
+ if (reply.body) {
49
+ messagesChat.push({ type: 'text', text: reply.body });
55
50
  }
56
51
 
57
52
  return messagesChat;
@@ -121,8 +116,6 @@ const processImageFileCore = async (fileName, reply) => {
121
116
  ...timings
122
117
  });
123
118
 
124
- logger.debug('processImageFile_analysis', { imageAnalysis });
125
-
126
119
  } catch (error) {
127
120
  logger.error('processImageFile', error, {
128
121
  message_id: reply.message_id,
@@ -12,12 +12,6 @@ async function generateQRBuffer(text) {
12
12
  }
13
13
  }
14
14
 
15
- function bufferToBase64(buffer) {
16
- return buffer.toString('base64');
17
- }
18
-
19
-
20
15
  module.exports = {
21
- generateQRBuffer,
22
- bufferToBase64
16
+ generateQRBuffer
23
17
  };
@@ -0,0 +1,72 @@
1
+ const { logger } = require('../utils/logger');
2
+ const { getDefaultInstance } = require('../core/NexusMessaging');
3
+ const { Template, configureNexusProvider: configureTemplateProvider } = require('../templates/templateStructure');
4
+ const { sendMessage } = require('../core/NexusMessaging');
5
+ const { Message } = require('../models/messageModel');
6
+
7
+ /**
8
+ * Handle 24-hour window error by creating template and sending when approved
9
+ */
10
+ async function handle24HourWindowError(message, messageSid) {
11
+ try {
12
+ if (!message?.body || !message?.numero) return;
13
+
14
+ const messaging = getDefaultInstance();
15
+ const provider = messaging?.getProvider();
16
+ if (!provider?.createTemplate) return;
17
+
18
+ configureTemplateProvider(provider);
19
+
20
+ const templateName = `auto_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
21
+ const template = new Template(templateName, 'UTILITY', 'es');
22
+ template.setBody(message.body, []);
23
+ const twilioContent = await template.save();
24
+
25
+ // Submit for approval
26
+ const approvalName = `${templateName}_${Date.now()}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
27
+ await provider.submitForApproval(twilioContent.sid, approvalName, 'UTILITY');
28
+
29
+ await Message.updateOne(
30
+ { message_id: messageSid },
31
+ { $set: { 'statusInfo.recoveryTemplateSid': twilioContent.sid } }
32
+ );
33
+
34
+ logger.info('[TemplateRecovery] Template created', { messageSid, templateSid: twilioContent.sid });
35
+
36
+ const checkApproval = async (attempt = 0, maxAttempts = 40) => {
37
+ if (attempt >= maxAttempts) {
38
+ logger.warn('[TemplateRecovery] Max attempts reached, template not approved yet', { messageSid, templateSid: twilioContent.sid });
39
+ return;
40
+ }
41
+
42
+ setTimeout(async () => {
43
+ try {
44
+ const status = await provider.checkApprovalStatus(twilioContent.sid);
45
+ const approvalStatus = status?.approvalRequest?.status?.toUpperCase();
46
+
47
+ if (approvalStatus === 'APPROVED') {
48
+ await sendMessage({ code: message.numero, contentSid: twilioContent.sid, variables: {} });
49
+ logger.info('[TemplateRecovery] Template sent', { messageSid, templateSid: twilioContent.sid });
50
+ } else if (approvalStatus === 'REJECTED') {
51
+ logger.warn('[TemplateRecovery] Template rejected', { messageSid, templateSid: twilioContent.sid });
52
+ } else {
53
+ checkApproval(attempt + 1, maxAttempts);
54
+ }
55
+ } catch (err) {
56
+ logger.error('[TemplateRecovery] Error checking approval', { error: err.message, attempt });
57
+ // Retry on error (but count as attempt)
58
+ if (attempt + 1 < maxAttempts) {
59
+ checkApproval(attempt + 1, maxAttempts);
60
+ }
61
+ }
62
+ }, 15 * 60 * 1000);
63
+ };
64
+
65
+ checkApproval(0);
66
+
67
+ } catch (error) {
68
+ logger.error('[TemplateRecovery] Error', { messageSid, error: error.message });
69
+ }
70
+ }
71
+
72
+ module.exports = { handle24HourWindowError };
@@ -1,4 +1,3 @@
1
- const { Message } = require('../models/messageModel.js');
2
1
  const { isRecentMessage } = require('./messageHelper.js');
3
2
  const axios = require('axios');
4
3
  const { v4: uuidv4 } = require('uuid');
@@ -24,53 +23,86 @@ function convertTwilioToInternalFormat(twilioMessage) {
24
23
  }
25
24
 
26
25
 
27
- async function downloadMediaFromTwilio(mediaUrl, logger) {
28
- try {
29
- const authHeader = `Basic ${Buffer.from(
30
- `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
31
- ).toString('base64')}`;
32
-
33
- logger.info('[TwilioMedia] Starting download', {
34
- url: mediaUrl,
35
- hasAccountSid: !!process.env.TWILIO_ACCOUNT_SID,
36
- hasAuthToken: !!process.env.TWILIO_AUTH_TOKEN,
37
- accountSidLength: process.env.TWILIO_ACCOUNT_SID?.length || 0,
38
- authHeaderSample: authHeader.substring(0, 20) + '...'
39
- });
40
-
41
- const response = await axios({
42
- method: 'GET',
43
- url: mediaUrl,
44
- responseType: 'arraybuffer',
45
- timeout: 30000,
46
- headers: {
47
- 'Authorization': authHeader
26
+ async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
27
+ logger.info('[TwilioMedia] Starting download', {
28
+ url: mediaUrl,
29
+ maxRetries,
30
+ hasAccountSid: !!process.env.TWILIO_ACCOUNT_SID,
31
+ hasAuthToken: !!process.env.TWILIO_AUTH_TOKEN,
32
+ hasCredentials: !!(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN)
33
+ });
34
+
35
+ const authorization = `Basic ${Buffer.from(
36
+ `${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
37
+ ).toString('base64')}`;
38
+
39
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
40
+ try {
41
+ logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries });
42
+
43
+ const response = await axios.get(mediaUrl, {
44
+ headers: {
45
+ 'Authorization': authorization,
46
+ 'User-Agent': 'Nexus-Media-Processor/1.0'
47
+ },
48
+ responseType: 'arraybuffer',
49
+ timeout: 30000
50
+ });
51
+
52
+ logger.info('[TwilioMedia] Download successful', {
53
+ status: response.status,
54
+ contentType: response.headers['content-type'],
55
+ contentLength: response.headers['content-length'],
56
+ dataSize: response.data?.length || 0,
57
+ attempt
58
+ });
59
+
60
+ return Buffer.from(response.data);
61
+ } catch (error) {
62
+ const is404 = error.response?.status === 404;
63
+ const isLastAttempt = attempt === maxRetries;
64
+
65
+ if (is404 && !isLastAttempt) {
66
+ const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
67
+ logger.info('[TwilioMedia] Media not ready, retrying after delay', {
68
+ attempt,
69
+ maxRetries,
70
+ delayMs: delay,
71
+ mediaId: mediaUrl.split('/').pop()
72
+ });
73
+ await new Promise(resolve => setTimeout(resolve, delay));
74
+ continue;
48
75
  }
49
- });
50
-
51
- logger.info('[TwilioMedia] Download successful', {
52
- status: response.status,
53
- contentType: response.headers['content-type'],
54
- contentLength: response.headers['content-length'],
55
- dataSize: response.data?.length || 0
56
- });
57
-
58
- return Buffer.from(response.data);
59
- } catch (error) {
60
- logger.error('[TwilioMedia] Download failed', {
61
- message: error.message,
62
- status: error.response?.status,
63
- statusText: error.response?.statusText,
64
- responseHeaders: error.response?.headers,
65
- responseData: error.response?.data ? error.response.data.toString().substring(0, 500) : null,
66
- url: mediaUrl,
67
- hasCredentials: !!(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN)
68
- });
69
- throw error;
76
+
77
+ // Check if truly expired (after all retries)
78
+ const isMediaExpired = is404 && mediaUrl.includes('/Media/');
79
+
80
+ if (isMediaExpired && isLastAttempt) {
81
+ logger.info('[TwilioMedia] Media expired (24h limit), skipping download', {
82
+ mediaId: mediaUrl.split('/').pop(),
83
+ status: 404,
84
+ attemptsUsed: attempt
85
+ });
86
+ return null;
87
+ }
88
+
89
+ logger.error('[TwilioMedia] Download failed', {
90
+ message: error.message,
91
+ status: error.response?.status,
92
+ statusText: error.response?.statusText,
93
+ attempt,
94
+ maxRetries,
95
+ responseHeaders: error.response?.headers,
96
+ responseData: error.response?.data ? error.response.data.toString().substring(0, 500) : null,
97
+ url: mediaUrl,
98
+ hasCredentials: !!(process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN)
99
+ });
100
+
101
+ throw error;
102
+ }
70
103
  }
71
104
  }
72
105
 
73
-
74
106
  function getMediaTypeFromContentType(contentType) {
75
107
  if (contentType.startsWith('image/')) return 'imageMessage';
76
108
  if (contentType.startsWith('audio/')) return 'audioMessage';
@@ -89,16 +121,6 @@ function extractTitle(message, mediaType) {
89
121
  }
90
122
 
91
123
 
92
- async function getLastMessages(chatId, n) {
93
- const messages = await Message.find({ numero: chatId })
94
- .sort({ createdAt: -1 })
95
- .limit(n)
96
- .select('timestamp numero nombre_whatsapp body');
97
-
98
- return messages.map(msg => `[${msg.timestamp}] ${msg.body}`);
99
- }
100
-
101
-
102
124
  async function downloadMedia(twilioMessage, logger) {
103
125
  try {
104
126
  const mediaUrl = twilioMessage.MediaUrl0;
@@ -145,7 +167,6 @@ module.exports = {
145
167
  getMediaTypeFromContentType,
146
168
  extractTitle,
147
169
  isRecentMessage,
148
- getLastMessages,
149
170
  downloadMedia,
150
171
  ensureWhatsAppFormat
151
172
  };
@@ -82,6 +82,11 @@ async function processTwilioMediaMessage(twilioMessage, bucketName) {
82
82
  continue;
83
83
  }
84
84
 
85
+ if (!mediaBuffer) {
86
+ logger.info('[TwilioMedia] Skipping expired media', { index: i, mediaUrl });
87
+ continue;
88
+ }
89
+
85
90
  const validationResult = validateMedia(mediaBuffer, contentType);
86
91
  if (!validationResult.valid) {
87
92
  logger.warn('[TwilioMedia] Media validation warning', { index: i, message: validationResult.message });
@@ -1,12 +1,6 @@
1
- const moment = require('moment-timezone');
2
1
  const { logger } = require('../utils/logger');
3
2
 
4
3
 
5
- function delay(ms) {
6
- return new Promise(resolve => setTimeout(resolve, ms));
7
- }
8
-
9
-
10
4
  function formatCode(codeBase) {
11
5
  logger.info(`formatCode ${codeBase}`);
12
6
 
@@ -37,40 +31,6 @@ function formatCode(codeBase) {
37
31
  }
38
32
 
39
33
 
40
- function calculateDelay(sendTime, timeZone) {
41
- if (sendTime !== undefined && timeZone !== undefined) {
42
- const sendMoment = moment.tz(sendTime, timeZone);
43
-
44
- if (!sendMoment.isValid()) {
45
- return { error: 'Invalid time format' };
46
- }
47
-
48
- // Get the current time and calculate the difference
49
- const now = moment().tz(timeZone);
50
- const randomDelay = Math.floor(Math.random() * 15001) + 15000;
51
- const delay = sendMoment.diff(now) + randomDelay;
52
-
53
- // Log the calculated details for debugging
54
- logger.info(
55
- 'Scheduled Time:', sendMoment.format(),
56
- 'Current Time:', now.format(),
57
- 'Delay (minutes):', delay / 60000,
58
- 'Remaining Seconds:', delay % 60000
59
- );
60
-
61
- if (delay <= 0) {
62
- return 2500;
63
- }
64
-
65
- return delay;
66
- } else {
67
- return 2500;
68
- }
69
- }
70
-
71
-
72
34
  module.exports = {
73
- delay,
74
- formatCode,
75
- calculateDelay
35
+ formatCode
76
36
  };
package/lib/index.d.ts CHANGED
@@ -283,7 +283,6 @@ declare module '@peopl-health/nexus' {
283
283
 
284
284
  // Utility Functions
285
285
  export function createLogger(config?: any): any;
286
- export function delay(ms: number): Promise<void>;
287
286
  export function formatCode(codeBase: string): string;
288
287
  export function calculateDelay(sendTime: string | Date, timeZone?: string): number;
289
288
  export function ensureWhatsAppFormat(phoneNumber: any): string | null;
@@ -1,11 +1,11 @@
1
- const { ConversationManager } = require('./ConversationManager');
2
- const { getLastNMessages } = require('../helpers/messageHelper');
1
+ const { MemoryManager } = require('./MemoryManager');
2
+ const { getLastNMessages, formatMessage } = require('../helpers/messageHelper');
3
3
  const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
4
- const { getRecordByFilter } = require('./airtableService');
4
+ const { getRecordByFilter } = require('../services/airtableService');
5
5
  const { Follow_Up_ID } = require('../config/airtableConfig');
6
6
  const { logger } = require('../utils/logger');
7
7
 
8
- class DefaultConversationManager extends ConversationManager {
8
+ class DefaultMemoryManager extends MemoryManager {
9
9
  constructor(options = {}) {
10
10
  super(options);
11
11
  this.maxHistoricalMessages = parseInt(process.env.MAX_HISTORICAL_MESSAGES || '50', 10);
@@ -18,19 +18,21 @@ class DefaultConversationManager extends ConversationManager {
18
18
  const allMessages = await getLastNMessages(thread.code, this.maxHistoricalMessages);
19
19
  const additionalMessages = config.additionalMessages || [];
20
20
 
21
- // New conversation - no history yet
22
21
  if (!allMessages?.length) {
23
22
  return additionalMessages;
24
23
  }
25
24
 
26
- const messageContext = allMessages.reverse().map(msg => ({
27
- role: msg.origin === 'patient' ? 'user' : 'assistant',
28
- content: msg.body || msg.content || ''
29
- }));
25
+ const messageContext = allMessages.reverse().map(msg => {
26
+ const formattedContent = formatMessage(msg);
27
+ return {
28
+ role: msg.origin === 'patient' ? 'user' : 'assistant',
29
+ content: formattedContent || msg.body || msg.content || ''
30
+ };
31
+ });
30
32
 
31
33
  return [...additionalMessages, ...messageContext];
32
34
  } catch (error) {
33
- logger.error('[DefaultConversationManager] Context building failed', {
35
+ logger.error('[DefaultMemoryManager] Context building failed', {
34
36
  threadCode: thread.code,
35
37
  error: error.message
36
38
  });
@@ -50,7 +52,7 @@ class DefaultConversationManager extends ConversationManager {
50
52
  responseId: response.id
51
53
  });
52
54
  } catch (error) {
53
- logger.error('[DefaultConversationManager] Response processing failed', {
55
+ logger.error('[DefaultMemoryManager] Response processing failed', {
54
56
  threadCode: thread.code,
55
57
  error: error.message
56
58
  });
@@ -63,7 +65,7 @@ class DefaultConversationManager extends ConversationManager {
63
65
  try {
64
66
  return await handlePendingFunctionCallsUtil(assistant, conversationMessages, toolMetadata);
65
67
  } catch (error) {
66
- logger.error('[DefaultConversationManager] Function call handling failed', { error: error.message });
68
+ logger.error('[DefaultMemoryManager] Function call handling failed', { error: error.message });
67
69
  return { outputs: [], toolsExecuted: [] };
68
70
  }
69
71
  }
@@ -119,7 +121,7 @@ class DefaultConversationManager extends ConversationManager {
119
121
 
120
122
  return { ...clinicalContext, symptoms };
121
123
  } catch (error) {
122
- logger.error('[DefaultConversationManager] Error fetching clinical context', { error: error.message });
124
+ logger.error('[DefaultMemoryManager] Error fetching clinical context', { error: error.message });
123
125
  return null;
124
126
  }
125
127
  }
@@ -198,10 +200,10 @@ class DefaultConversationManager extends ConversationManager {
198
200
  lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente'
199
201
  };
200
202
  } catch (error) {
201
- logger.error('[DefaultConversationManager] Error getting clinical data', { error: error.message });
203
+ logger.error('[DefaultMemoryManager] Error getting clinical data', { error: error.message });
202
204
  return null;
203
205
  }
204
206
  }
205
207
  }
206
208
 
207
- module.exports = { DefaultConversationManager };
209
+ module.exports = { DefaultMemoryManager };
@@ -1,6 +1,6 @@
1
1
  const { logger } = require('../utils/logger');
2
2
 
3
- class ConversationManager {
3
+ class MemoryManager {
4
4
  constructor(options = {}) {
5
5
  this.memorySystem = options.memorySystem || null;
6
6
  }
@@ -36,8 +36,8 @@ class ConversationManager {
36
36
  }
37
37
 
38
38
  _logActivity(action, metadata = {}) {
39
- logger.info(`[ConversationManager] ${action}`, metadata);
39
+ logger.info(`[MemoryManager] ${action}`, metadata);
40
40
  }
41
41
  }
42
42
 
43
- module.exports = { ConversationManager };
43
+ module.exports = { MemoryManager };
@@ -124,6 +124,9 @@ async function insertMessage(values) {
124
124
  updatedAt: values.delivery_status_updated_at || null
125
125
  };
126
126
  }
127
+ if (messageData.tools_executed === undefined) {
128
+ messageData.tools_executed = [];
129
+ }
127
130
 
128
131
  await Message.findOneAndUpdate(
129
132
  { message_id: values.message_id, body: values.body },
@@ -4,8 +4,9 @@ const { retryWithBackoff } = require('../utils/retryHelper');
4
4
  const {
5
5
  handleFunctionCalls: handleFunctionCallsUtil,
6
6
  } = require('./OpenAIResponsesProviderTools');
7
- const { DefaultConversationManager } = require('../services/DefaultConversationManager');
7
+ const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
8
8
  const { logger } = require('../utils/logger');
9
+ const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
9
10
 
10
11
  const CONVERSATION_PREFIX = 'conv_';
11
12
  const RESPONSE_PREFIX = 'resp_';
@@ -41,7 +42,7 @@ class OpenAIResponsesProvider {
41
42
  };
42
43
 
43
44
  this.variant = 'responses';
44
- this.conversationManager = conversationManager || new DefaultConversationManager();
45
+ this.conversationManager = conversationManager || new DefaultMemoryManager();
45
46
 
46
47
  this.responses = this.client.responses;
47
48
  this.conversations = this.client.conversations;
@@ -196,13 +197,20 @@ class OpenAIResponsesProvider {
196
197
  }
197
198
  });
198
199
 
200
+ logger.info('[OpenAIResponsesProvider] Context built', {
201
+ conversationId,
202
+ assistantId,
203
+ lastContext: context[-1] || null
204
+ });
205
+
199
206
  const filter = thread.code ? { code: thread.code, active: true } : null;
200
207
 
201
208
  // Get clinical context for prompt variables
202
209
  const clinicalData = await this.conversationManager.getClinicalData(thread.code);
203
210
  const promptVariables = clinicalData ? {
204
211
  clinical_context: clinicalData.clinicalContext || '',
205
- last_symptoms: clinicalData.lastSymptoms || ''
212
+ last_symptoms: clinicalData.lastSymptoms || '',
213
+ current_date: getCurrentMexicoDateTime(),
206
214
  } : null;
207
215
 
208
216
  // Execute with built context
@@ -287,10 +295,7 @@ class OpenAIResponsesProvider {
287
295
  const makeAPICall = (inputData) => retryWithBackoff(() =>
288
296
  this.client.responses.create({
289
297
  prompt: promptConfig,
290
- model: model || this.defaults.responseModel,
291
- instructions: additionalInstructions || instructions,
292
298
  input: inputData,
293
- metadata, top_p: topP, temperature, max_output_tokens: maxOutputTokens,
294
299
  truncation: truncationStrategy,
295
300
  }), { providerName: PROVIDER_NAME });
296
301
 
@@ -1,6 +1,6 @@
1
1
  const { OpenAIAssistantsProvider } = require('./OpenAIAssistantsProvider');
2
2
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
3
- const { DefaultConversationManager } = require('../services/DefaultConversationManager');
3
+ const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
4
4
  const { logger } = require('../utils/logger');
5
5
 
6
6
  const PROVIDER_VARIANTS = {
@@ -18,7 +18,7 @@ function createProvider(config = {}) {
18
18
  .toLowerCase();
19
19
 
20
20
  // Create conversation manager if not provided
21
- const conversationManager = config.conversationManager || new DefaultConversationManager({
21
+ const conversationManager = config.conversationManager || new DefaultMemoryManager({
22
22
  memorySystem: config.memorySystem
23
23
  });
24
24
 
@@ -39,6 +39,8 @@ const messageRouteDefinitions = {
39
39
  'GET /last': 'getLastInteractionController',
40
40
  'GET /scheduled-status': 'checkScheduledMessageStatusController',
41
41
  'GET /status': 'checkMessageStatusController',
42
+ 'GET /status/:messageSid': 'getMessageStatusController',
43
+ 'POST /status-callback': 'messageStatusCallbackController',
42
44
  'POST /quality': 'addQualityVoteController',
43
45
  'GET /quality/:message_id': 'getQualityVotesByMessageController',
44
46
  'GET /quality/:message_id/voter/:voter_username': 'getQualityVoteByMessageAndVoterController',
@@ -97,6 +99,7 @@ const conversationController = require('../controllers/conversationController');
97
99
  const interactionController = require('../controllers/interactionController');
98
100
  const mediaController = require('../controllers/mediaController');
99
101
  const messageController = require('../controllers/messageController');
102
+ const messageStatusController = require('../controllers/messageStatusController');
100
103
  const patientController = require('../controllers/patientController');
101
104
  const qualityMessageController = require('../controllers/qualityMessageController');
102
105
  const templateController = require('../controllers/templateController');
@@ -144,6 +147,8 @@ const builtInControllers = {
144
147
  getLastInteractionController: messageController.getLastInteractionController,
145
148
  checkScheduledMessageStatusController: messageController.checkScheduledMessageStatusController,
146
149
  checkMessageStatusController: messageController.checkMessageStatusController,
150
+ getMessageStatusController: messageStatusController.getMessageStatusController,
151
+ messageStatusCallbackController: messageStatusController.messageStatusCallbackController,
147
152
  addQualityVoteController: qualityMessageController.addQualityVoteController,
148
153
  getQualityVotesByMessageController: qualityMessageController.getQualityVotesByMessageController,
149
154
  getQualityVoteByMessageAndVoterController: qualityMessageController.getQualityVoteByMessageAndVoterController,
@@ -10,10 +10,12 @@ const { Historial_Clinico_ID } = require('../config/airtableConfig');
10
10
  const { getCurRow, runAssistantWithRetries } = require('../helpers/assistantHelper.js');
11
11
  const { getThread } = require('../helpers/threadHelper.js');
12
12
  const { processThreadMessage } = require('../helpers/processHelper.js');
13
- const { getLastMessages, updateMessageRecord } = require('../helpers/messageHelper.js');
13
+ const { getLastNMessages, updateMessageRecord } = require('../helpers/messageHelper.js');
14
14
  const { combineImagesToPDF, cleanupFiles } = require('../helpers/filesHelper.js');
15
15
  const { getAssistantById } = require('./assistantResolver');
16
+
16
17
  const { logger } = require('../utils/logger');
18
+ const { sanitizeOutput } = require('../utils/outputSanitizer');
17
19
 
18
20
  const createAssistantCore = async (code, assistant_id, messages = [], force = false) => {
19
21
  const findThread = await Thread.findOne({ code: code });
@@ -156,18 +158,18 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
156
158
  const finalThread = thread;
157
159
 
158
160
  const messagesStart = Date.now();
159
- const patientReply = await getLastMessages(code);
161
+ const lastMessage = await getLastNMessages(code, 1);
160
162
  timings.get_messages_ms = Date.now() - messagesStart;
161
163
 
162
- if (!patientReply) {
164
+ if (!lastMessage || lastMessage.length === 0 || lastMessage[0].from_me) {
163
165
  logger.info('[replyAssistantCore] No relevant data found for this assistant.');
164
166
  return null;
165
167
  }
166
168
 
167
169
  const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
168
- logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
170
+ logger.info(`[replyAssistantCore] Processing ${lastMessage.length} messages in parallel`);
169
171
  const processStart = Date.now();
170
- const processResult = await processThreadMessage(code, patientReply, provider);
172
+ const processResult = await processThreadMessage(code, lastMessage, provider);
171
173
 
172
174
  const { results: processResults, timings: processTimings } = processResult;
173
175
  timings.process_messages_ms = Date.now() - processStart;
@@ -186,10 +188,17 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
186
188
 
187
189
  const patientMsg = processResults.some(r => r.isPatient);
188
190
  const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
189
- const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
190
191
  const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
191
192
 
192
- await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
193
+ await Promise.all(processResults.map(r => {
194
+ const processedContent = r.messages && r.messages.length > 0
195
+ ? r.messages
196
+ .filter(msg => msg.content.text !== r.reply?.body)
197
+ .map(msg => msg.content.text)
198
+ .join(' ')
199
+ : null;
200
+ return updateMessageRecord(r.reply, finalThread, processedContent);
201
+ }));
193
202
  await cleanupFiles(allTempFiles);
194
203
 
195
204
  if (urls.length > 0) {
@@ -217,15 +226,24 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
217
226
 
218
227
  const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
219
228
  const runStart = Date.now();
220
- const runResult = await runAssistantWithRetries(finalThread, assistant, runOptions, patientReply);
229
+ const runResult = await runAssistantWithRetries(finalThread, assistant, runOptions, lastMessage);
221
230
  timings.run_assistant_ms = Date.now() - runStart;
222
231
  timings.total_ms = Date.now() - startTotal;
223
232
 
224
- const { output, completed, retries, predictionTimeMs, tools_executed } = runResult;
233
+ const { output: rawOutput, completed, retries, predictionTimeMs, tools_executed } = runResult;
234
+
235
+ const output = sanitizeOutput(rawOutput);
236
+ if (rawOutput !== output) {
237
+ logger.debug('[replyAssistantCore] Output sanitized', {
238
+ originalLength: rawOutput?.length || 0,
239
+ sanitizedLength: output?.length || 0,
240
+ removedContent: rawOutput?.length ? 'brackets_removed' : 'none'
241
+ });
242
+ }
225
243
 
226
244
  logger.info('[Assistant Reply Complete]', {
227
245
  code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
228
- messageCount: patientReply.length,
246
+ messageCount: lastMessage.length,
229
247
  hasMedia: urls.length > 0,
230
248
  retries,
231
249
  totalMs: timings.total_ms,
@@ -21,19 +21,9 @@ const fetchConversationData = async (filter, skip, limit) => {
21
21
 
22
22
  const pipeline = [
23
23
  { $match: filterConditions },
24
- { $sort: { numero: 1, createdAt: -1 } },
25
- { $group: {
26
- _id: '$numero',
27
- latestMessage: { $first: {
28
- _id: '$_id',
29
- body: '$body',
30
- createdAt: '$createdAt',
31
- media: '$media',
32
- nombre_whatsapp: '$nombre_whatsapp',
33
- from_me: '$from_me'
34
- } },
35
- messageCount: { $sum: 1 }
36
- } },
24
+ { $project: { numero: 1, body: 1, createdAt: 1, timestamp: 1, media: 1, nombre_whatsapp: 1, from_me: 1 } },
25
+ { $sort: { createdAt: 1, timestamp: 1 } },
26
+ { $group: { _id: '$numero', latestMessage: { $last: '$$ROOT' }, messageCount: { $sum: 1 } } },
37
27
  { $match: { _id: { $nin: [null, ''] } } },
38
28
  ...(filter === 'no-response' ? [{ $match: { 'latestMessage.from_me': false } }] : []),
39
29
  {
@@ -18,9 +18,17 @@ const dateAndTimeFromStart = (startTime) => {
18
18
  };
19
19
  };
20
20
 
21
+ const getCurrentMexicoDateTime = () => {
22
+ return moment()
23
+ .tz('America/Mexico_City')
24
+ .locale('es')
25
+ .format('dddd, D [de] MMMM [de] YYYY [a las] h:mm A');
26
+ };
27
+
21
28
  module.exports = {
22
29
  ISO_DATE,
23
30
  parseStartTime,
24
31
  addDays,
25
- dateAndTimeFromStart
32
+ dateAndTimeFromStart,
33
+ getCurrentMexicoDateTime
26
34
  };
@@ -1,9 +1,11 @@
1
1
  const { MessageParser } = require('./messageParser');
2
2
  const { logger } = require('./logger');
3
3
  const { retryWithBackoff } = require('./retryHelper');
4
+ const { calculateDelay } = require('./scheduleUtils');
4
5
 
5
6
  module.exports = {
6
7
  MessageParser,
7
8
  logger,
8
9
  retryWithBackoff,
10
+ calculateDelay,
9
11
  };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Sanitize AI response output by removing unwanted content
3
+ */
4
+
5
+ function removeBracketContent(text) {
6
+ if (!text || typeof text !== 'string') return text;
7
+ return text.replace(/\[([^\]]*)\]/g, '').trim();
8
+ }
9
+
10
+ function sanitizeOutput(text) {
11
+ if (!text || typeof text !== 'string') return text;
12
+
13
+ let sanitized = text;
14
+ sanitized = removeBracketContent(sanitized);
15
+ sanitized = sanitized.replace(/\s+/g, ' ').trim();
16
+
17
+ return sanitized;
18
+ }
19
+
20
+ module.exports = {
21
+ sanitizeOutput,
22
+ removeBracketContent
23
+ };
@@ -0,0 +1,57 @@
1
+ const moment = require('moment-timezone');
2
+ const { logger } = require('./logger');
3
+
4
+ /**
5
+ * Calculate delay in milliseconds until a scheduled time
6
+ */
7
+ function calculateDelay(sendTime, timeZone) {
8
+ if (!sendTime) {
9
+ return 2500;
10
+ }
11
+
12
+ try {
13
+ if (timeZone && typeof sendTime === 'string') {
14
+ const sendMoment = moment.tz(sendTime, timeZone);
15
+
16
+ if (!sendMoment.isValid()) {
17
+ logger.warn('[calculateDelay] Invalid time format', { sendTime, timeZone });
18
+ return 2500;
19
+ }
20
+
21
+ const now = moment().tz(timeZone);
22
+ const randomDelay = Math.floor(Math.random() * 5001) + 10000;
23
+ const delay = sendMoment.diff(now) + randomDelay;
24
+
25
+ logger.debug('[calculateDelay] Timezone calculation', {
26
+ scheduledTime: sendMoment.format(),
27
+ currentTime: now.format(),
28
+ delayMinutes: delay / 60000,
29
+ timeZone
30
+ });
31
+
32
+ return Math.max(0, delay);
33
+ }
34
+
35
+ const now = new Date();
36
+ const targetTime = new Date(sendTime);
37
+
38
+ if (isNaN(targetTime.getTime())) {
39
+ logger.warn('[calculateDelay] Invalid date format', { sendTime });
40
+ return 2500;
41
+ }
42
+
43
+ return Math.max(0, targetTime.getTime() - now.getTime());
44
+
45
+ } catch (error) {
46
+ logger.error('[calculateDelay] Error calculating delay', {
47
+ error: error.message,
48
+ sendTime,
49
+ timeZone
50
+ });
51
+ return 2500;
52
+ }
53
+ }
54
+
55
+ module.exports = {
56
+ calculateDelay
57
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -107,7 +107,7 @@
107
107
  "baileys": "^6.4.0",
108
108
  "express": "^4.22.1",
109
109
  "openai": "6.7.0",
110
- "twilio": "5.6.0"
110
+ "twilio": "5.11.2"
111
111
  },
112
112
  "engines": {
113
113
  "node": ">=20.0.0"
File without changes