@peopl-health/nexus 3.0.1 → 3.0.3

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.
@@ -66,13 +66,12 @@ class GeneralAssistant extends BaseAssistant {
66
66
  }
67
67
 
68
68
  async function startServer() {
69
- // Initialize Nexus with all services and batching enabled
69
+ // Initialize Nexus with check-after processing (immediate response, checks for new messages after)
70
70
  const nexus = new Nexus({
71
71
  messaging: {
72
72
  messageBatching: {
73
- enabled: true,
74
- baseWaitTime: 10000, // 10 seconds base wait (shorter for testing)
75
- randomVariation: 5000 // 0-5 seconds random variation
73
+ enabled: true, // Enable check-after processing
74
+ checkDelayMs: 100 // Delay before checking for new messages (ms)
76
75
  }
77
76
  }
78
77
  });
@@ -41,11 +41,11 @@ class NexusMessaging {
41
41
  keyword: [],
42
42
  flow: []
43
43
  };
44
- // Message batching
45
- this.pendingResponses = new Map();
44
+ // Message processing with check-after strategy
45
+ this.processingLocks = new Map(); // Per-chat locks to prevent parallel processing
46
46
  this.batchingConfig = {
47
- enabled: config.messageBatching?.enabled ?? false,
48
- baseWaitTime: config.messageBatching?.baseWaitTime ?? 10000
47
+ enabled: config.messageBatching?.enabled ?? true, // Enabled by default with check-after
48
+ checkDelayMs: config.messageBatching?.checkDelayMs ?? 100 // Delay before checking for new messages
49
49
  };
50
50
  }
51
51
 
@@ -348,6 +348,7 @@ class NexusMessaging {
348
348
  if (this.messageStorage) {
349
349
  await this.messageStorage.saveMessage({
350
350
  ...messageData,
351
+ messageId: messageData.id,
351
352
  timestamp: new Date(),
352
353
  fromMe: false,
353
354
  origin: 'patient'
@@ -393,10 +394,9 @@ class NexusMessaging {
393
394
  } else if (messageData.flow) {
394
395
  return await this.handleFlow(messageData);
395
396
  } else {
396
- // For regular messages and media, use batching if enabled
397
- logger.info('Batching config:', this.batchingConfig);
397
+ // For regular messages and media, use check-after processing
398
398
  if (this.batchingConfig.enabled && chatId) {
399
- return await this._handleWithBatching(messageData, chatId);
399
+ return await this._handleWithCheckAfter(chatId);
400
400
  } else {
401
401
  if (messageData.media) {
402
402
  return await this.handleMedia(messageData);
@@ -649,77 +649,52 @@ class NexusMessaging {
649
649
  }
650
650
 
651
651
  /**
652
- * Handle message with batching - waits for additional messages before processing
652
+ * Handle message with check-after strategy - process immediately, check for new messages after
653
653
  */
654
- async _handleWithBatching(messageData, chatId) {
655
- const existing = this.pendingResponses.get(chatId);
656
- if (existing) {
657
- clearTimeout(existing.timeoutId);
658
- if (existing.typingInterval) {
659
- clearInterval(existing.typingInterval);
660
- }
661
- logger.info(`Received additional message from ${chatId}, resetting wait timer`);
662
- }
663
-
664
- // Start typing indicator refresh for batching period
654
+ async _handleWithCheckAfter(chatId) {
665
655
  const typingInterval = await this._startTypingRefresh(chatId);
666
-
667
- const waitTime = this.batchingConfig.baseWaitTime;
668
- const timeoutId = setTimeout(async () => {
669
- try {
670
- if (typingInterval) {
671
- clearInterval(typingInterval);
672
- }
673
- this.pendingResponses.delete(chatId);
674
- await this._handleBatchedMessages(chatId);
675
- } catch (error) {
676
- logger.error(`Error handling batched messages for ${chatId}:`, error);
677
- }
678
- }, waitTime);
679
656
 
680
- this.pendingResponses.set(chatId, { timeoutId, typingInterval });
681
- logger.info(`Waiting ${Math.round(waitTime/1000)} seconds for more messages from ${chatId}`);
682
- }
683
-
684
- /**
685
- * Start typing indicator refresh interval
686
- */
687
- async _startTypingRefresh(chatId) {
688
- if (!this.provider || typeof this.provider.sendTypingIndicator !== 'function') {
689
- return null;
657
+ if (this.processingLocks.has(chatId)) {
658
+ logger.info(`[CheckAfter] Already processing ${chatId}, new message will be included`);
659
+ return;
690
660
  }
691
661
 
692
- const lastMessage = await Message.findOne({
693
- numero: chatId,
694
- from_me: false,
695
- message_id: { $exists: true, $ne: null }
696
- }).sort({ createdAt: -1 });
697
-
698
- if (!lastMessage?.message_id) return null;
699
-
700
- return setInterval(() =>
701
- this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
702
- logger.debug('[_startTypingRefresh] Failed', { error: err.message })
703
- ), 5000
704
- );
662
+ await this._processWithLock(chatId, typingInterval);
705
663
  }
706
664
 
707
665
  /**
708
- * Process all batched messages for a chat
666
+ * Process messages with per-chat lock and check-after logic
709
667
  */
710
- async _handleBatchedMessages(chatId) {
711
- let typingInterval = null;
712
-
668
+ async _processWithLock(chatId, existingTypingInterval = null) {
669
+ this.processingLocks.set(chatId, true);
670
+ let typingInterval = existingTypingInterval;
671
+
713
672
  try {
714
- logger.info(`Processing batched messages from ${chatId} (including media if any)`);
715
-
716
- typingInterval = await this._startTypingRefresh(chatId);
717
-
718
- // Get assistant response
673
+ if (!typingInterval) {
674
+ typingInterval = await this._startTypingRefresh(chatId);
675
+ }
676
+ logger.info(`[CheckAfter] Processing messages for ${chatId}`);
677
+
719
678
  const result = await replyAssistant(chatId);
720
679
  const botResponse = typeof result === 'string' ? result : result?.output;
721
680
  const tools_executed = typeof result === 'object' ? result?.tools_executed : undefined;
722
-
681
+
682
+ // Small delay to catch very recent DB writes
683
+ await new Promise(resolve => setTimeout(resolve, this.batchingConfig.checkDelayMs));
684
+
685
+ // Check for new unprocessed messages
686
+ const hasNewMessages = await Message.exists({
687
+ numero: chatId,
688
+ processed: false,
689
+ from_me: false
690
+ });
691
+
692
+ if (hasNewMessages) {
693
+ logger.info(`[CheckAfter] New messages detected for ${chatId}, discarding response and reprocessing`);
694
+ return await this._processWithLock(chatId, typingInterval);
695
+ }
696
+
697
+ // No new messages - send response and mark processed
723
698
  if (botResponse) {
724
699
  await this.sendMessage({
725
700
  code: chatId,
@@ -729,40 +704,45 @@ class NexusMessaging {
729
704
  tools_executed
730
705
  });
731
706
  }
732
-
733
- // Emit event for batched processing
734
- this.events.emit('messages:batched', { chatId, response: botResponse });
735
-
707
+
708
+ this.events.emit('messages:processed', { chatId, response: botResponse });
709
+
736
710
  } catch (error) {
737
- logger.error('Error in batched message handling:', { error: error.message });
711
+ logger.error('[CheckAfter] Error processing messages:', { chatId, error: error.message });
738
712
  } finally {
739
- if (typingInterval) {
740
- clearInterval(typingInterval);
741
- }
713
+ if (typingInterval) clearInterval(typingInterval);
714
+ this.processingLocks.delete(chatId);
742
715
  }
743
716
  }
744
717
 
745
718
  /**
746
- * Clear pending response for a chat (useful for cleanup)
719
+ * Start typing indicator refresh interval
747
720
  */
748
- clearPendingResponse(chatId) {
749
- const pending = this.pendingResponses.get(chatId);
750
- if (pending) {
751
- if (pending.timeoutId) {
752
- clearTimeout(pending.timeoutId);
753
- }
754
- if (pending.typingInterval) {
755
- clearInterval(pending.typingInterval);
756
- }
757
- this.pendingResponses.delete(chatId);
721
+ async _startTypingRefresh(chatId) {
722
+ if (!this.provider || typeof this.provider.sendTypingIndicator !== 'function') {
723
+ return null;
758
724
  }
725
+
726
+ const lastMessage = await Message.findOne({
727
+ numero: chatId,
728
+ from_me: false,
729
+ message_id: { $exists: true, $ne: null }
730
+ }).sort({ createdAt: -1 });
731
+
732
+ if (!lastMessage?.message_id) return null;
733
+
734
+ return setInterval(() =>
735
+ this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
736
+ logger.debug('[_startTypingRefresh] Failed', { error: err.message })
737
+ ), 5000
738
+ );
759
739
  }
760
740
 
761
741
  /**
762
- * Get batching status for a chat
742
+ * Check if chat is currently being processed
763
743
  */
764
- hasPendingResponse(chatId) {
765
- return this.pendingResponses.has(chatId);
744
+ isProcessing(chatId) {
745
+ return this.processingLocks.has(chatId);
766
746
  }
767
747
  }
768
748
 
@@ -80,6 +80,12 @@ messageSchema.index({ numero: 1, createdAt: -1 });
80
80
  messageSchema.index({ numero: 1, processed: 1, origin: 1 }, { name: 'numero_processed_origin_idx' });
81
81
  messageSchema.index({ numero: 1, createdAt: -1, processed: 1 }, { name: 'numero_created_processed_idx' });
82
82
 
83
+ // Indexes for conversation aggregation queries
84
+ messageSchema.index({ group_id: 1, createdAt: 1 }, { name: 'conversation_sort_idx' });
85
+ messageSchema.index({ group_id: 1, from_me: 1, read: 1 }, { name: 'unread_filter_idx' });
86
+ messageSchema.index({ group_id: 1, numero: 1, createdAt: -1 }, { name: 'conversation_lookup_idx' });
87
+ messageSchema.index({ createdAt: -1 }, { name: 'global_sort_idx' });
88
+
83
89
  messageSchema.pre('save', function (next) {
84
90
  if (this.timestamp) {
85
91
  this.timestamp = moment.tz(this.timestamp, 'America/Mexico_City').toDate();
@@ -287,10 +287,7 @@ class OpenAIResponsesProvider {
287
287
  const makeAPICall = (inputData) => retryWithBackoff(() =>
288
288
  this.client.responses.create({
289
289
  prompt: promptConfig,
290
- model: model || this.defaults.responseModel,
291
- instructions: additionalInstructions || instructions,
292
290
  input: inputData,
293
- metadata, top_p: topP, temperature, max_output_tokens: maxOutputTokens,
294
291
  truncation: truncationStrategy,
295
292
  }), { providerName: PROVIDER_NAME });
296
293
 
@@ -1,5 +1,5 @@
1
1
  const { ConversationManager } = require('./ConversationManager');
2
- const { getLastNMessages } = require('../helpers/messageHelper');
2
+ const { getLastNMessages, formatMessage } = require('../helpers/messageHelper');
3
3
  const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
4
4
  const { getRecordByFilter } = require('./airtableService');
5
5
  const { Follow_Up_ID } = require('../config/airtableConfig');
@@ -23,10 +23,13 @@ class DefaultConversationManager extends ConversationManager {
23
23
  return additionalMessages;
24
24
  }
25
25
 
26
- const messageContext = allMessages.reverse().map(msg => ({
27
- role: msg.origin === 'patient' ? 'user' : 'assistant',
28
- content: msg.body || msg.content || ''
29
- }));
26
+ const messageContext = allMessages.reverse().map(msg => {
27
+ const formattedContent = formatMessage(msg);
28
+ return {
29
+ role: msg.origin === 'patient' ? 'user' : 'assistant',
30
+ content: formattedContent || msg.body || msg.content || ''
31
+ };
32
+ });
30
33
 
31
34
  return [...additionalMessages, ...messageContext];
32
35
  } catch (error) {
@@ -41,22 +41,23 @@ const fetchConversationData = async (filter, skip, limit) => {
41
41
  },
42
42
  { $project: { threadInfo: 0 } },
43
43
  ...(filter === 'pending-review' ? [{ $match: { $or: [{ review: false }, { review: null }] } }] : []),
44
- { $sort: { 'latestMessage.createdAt': -1, 'latestMessage.timestamp': -1 } },
44
+ { $sort: { 'latestMessage.createdAt': -1 } },
45
45
  { $skip: skip },
46
46
  { $limit: limit }
47
47
  ];
48
48
 
49
49
  const startTime = Date.now();
50
50
  const [conversations, contactNames, unreadCounts, totalResult] = await Promise.all([
51
- Message.aggregate(pipeline),
52
- Message.aggregate([{ $match: { ...baseMatch, from_me: false } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }]),
53
- Message.aggregate([{ $match: { ...baseMatch, ...unreadMatch } }, { $group: { _id: '$numero', unreadCount: { $sum: 1 } } }]),
51
+ Message.aggregate(pipeline, { allowDiskUse: true }),
52
+ Message.aggregate([{ $match: { ...baseMatch, from_me: false } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }], { allowDiskUse: true }),
53
+ Message.aggregate([{ $match: { ...baseMatch, ...unreadMatch } }, { $group: { _id: '$numero', unreadCount: { $sum: 1 } } }], { allowDiskUse: true }),
54
54
  Message.aggregate(
55
55
  filter === 'no-response'
56
- ? [{ $match: baseMatch }, { $project: { numero: 1, from_me: 1, createdAt: 1, timestamp: 1 } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', latestMessage: { $first: '$$ROOT' } } }, { $match: { 'latestMessage.from_me': false } }, { $count: 'total' }]
56
+ ? [{ $match: baseMatch }, { $project: { numero: 1, from_me: 1, createdAt: 1 } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', latestMessage: { $first: '$$ROOT' } } }, { $match: { 'latestMessage.from_me': false } }, { $count: 'total' }]
57
57
  : filter === 'pending-review'
58
58
  ? [{ $match: baseMatch }, { $group: { _id: '$numero' } }, { $lookup: { from: 'threads', localField: '_id', foreignField: 'code', as: 'threadInfo' } }, { $addFields: { review: { $arrayElemAt: ['$threadInfo.review', 0] } } }, { $match: { $or: [{ review: false }, { review: null }] } }, { $count: 'total' }]
59
- : [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }]
59
+ : [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }],
60
+ { allowDiskUse: true }
60
61
  )
61
62
  ]);
62
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -105,7 +105,7 @@
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
110
  "twilio": "5.6.0"
111
111
  },