@peopl-health/nexus 2.7.0 → 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.
- package/lib/adapters/BaileysProvider.js +2 -6
- package/lib/adapters/TwilioProvider.js +3 -7
- package/lib/controllers/bugReportController.js +2 -2
- package/lib/core/NexusMessaging.js +89 -27
- package/lib/helpers/filesHelper.js +1 -1
- package/lib/helpers/mediaHelper.js +3 -3
- package/lib/helpers/messageHelper.js +25 -8
- package/lib/helpers/messageStatusHelper.js +16 -7
- package/lib/helpers/processHelper.js +4 -11
- package/lib/helpers/qrHelper.js +1 -7
- package/lib/helpers/templateRecoveryHelper.js +72 -0
- package/lib/helpers/twilioHelper.js +76 -55
- package/lib/helpers/twilioMediaProcessor.js +5 -0
- package/lib/helpers/whatsappHelper.js +1 -41
- package/lib/index.d.ts +0 -1
- package/lib/{services/DefaultConversationManager.js → memory/DefaultMemoryManager.js} +17 -15
- package/lib/{services/ConversationManager.js → memory/MemoryManager.js} +3 -3
- package/lib/models/messageModel.js +3 -0
- package/lib/providers/OpenAIResponsesProvider.js +11 -6
- package/lib/providers/createProvider.js +2 -2
- package/lib/routes/index.js +5 -0
- package/lib/services/assistantServiceCore.js +28 -10
- package/lib/services/conversationService.js +3 -13
- package/lib/utils/dateUtils.js +9 -1
- package/lib/utils/index.js +2 -0
- package/lib/utils/outputSanitizer.js +23 -0
- package/lib/utils/scheduleUtils.js +57 -0
- package/package.json +3 -3
- /package/lib/utils/{sanitizer.js → inputSanitizer.js} +0 -0
|
@@ -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 =
|
|
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/
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
689
|
+
let typingInterval = existingTypingInterval;
|
|
690
|
+
let runId = null;
|
|
669
691
|
|
|
670
692
|
try {
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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)
|
|
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/
|
|
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/
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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: !!
|
|
48
|
-
formattedMessage
|
|
44
|
+
hasContent: !!reply.body
|
|
49
45
|
});
|
|
50
|
-
logger.debug('processTextMessage_content', { formattedMessage });
|
|
51
46
|
|
|
52
47
|
const messagesChat = [];
|
|
53
|
-
if (
|
|
54
|
-
messagesChat.push({ type: 'text', text:
|
|
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,
|
package/lib/helpers/qrHelper.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 {
|
|
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('
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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('[
|
|
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('[
|
|
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('[
|
|
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('[
|
|
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('[
|
|
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 = {
|
|
209
|
+
module.exports = { DefaultMemoryManager };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
2
|
|
|
3
|
-
class
|
|
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(`[
|
|
39
|
+
logger.info(`[MemoryManager] ${action}`, metadata);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
module.exports = {
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
21
|
+
const conversationManager = config.conversationManager || new DefaultMemoryManager({
|
|
22
22
|
memorySystem: config.memorySystem
|
|
23
23
|
});
|
|
24
24
|
|
package/lib/routes/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
161
|
+
const lastMessage = await getLastNMessages(code, 1);
|
|
160
162
|
timings.get_messages_ms = Date.now() - messagesStart;
|
|
161
163
|
|
|
162
|
-
if (!
|
|
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 ${
|
|
170
|
+
logger.info(`[replyAssistantCore] Processing ${lastMessage.length} messages in parallel`);
|
|
169
171
|
const processStart = Date.now();
|
|
170
|
-
const processResult = await processThreadMessage(code,
|
|
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 =>
|
|
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,
|
|
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:
|
|
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
|
-
{ $
|
|
25
|
-
{ $
|
|
26
|
-
|
|
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
|
{
|
package/lib/utils/dateUtils.js
CHANGED
|
@@ -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
|
};
|
package/lib/utils/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Core messaging and assistant library for WhatsApp communication platforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"whatsapp",
|
|
@@ -105,9 +105,9 @@
|
|
|
105
105
|
"peerDependencies": {
|
|
106
106
|
"@anthropic-ai/sdk": "^0.32.0",
|
|
107
107
|
"baileys": "^6.4.0",
|
|
108
|
-
"express": "4.22.1",
|
|
108
|
+
"express": "^4.22.1",
|
|
109
109
|
"openai": "6.7.0",
|
|
110
|
-
"twilio": "5.
|
|
110
|
+
"twilio": "5.11.2"
|
|
111
111
|
},
|
|
112
112
|
"engines": {
|
|
113
113
|
"node": ">=20.0.0"
|
|
File without changes
|