@peopl-health/nexus 3.3.10 → 3.3.12

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.
@@ -6,16 +6,16 @@ const airtableConfig = {
6
6
  apiKey: runtimeConfig.get('AIRTABLE_API_KEY'),
7
7
  };
8
8
 
9
- const Calendar_ID = require('./runtimeConfig').get('AIRTABLE_CALENDAR_ID') || 'appIjEstWR6972tbF';
10
- const Config_ID = require('./runtimeConfig').get('AIRTABLE_CONFIG_ID') || 'app9K4EvGI8McC8jF';
11
- const Historial_Clinico_ID = require('./runtimeConfig').get('AIRTABLE_HISTORIAL_CLINICO_ID') || 'appdUpGUS06XIzVnY';
12
- const Logging_ID = require('./runtimeConfig').get('AIRTABLE_LOGGING_ID') || 'appQ7YhzfebRDbSPJ';
13
- const Monitoreo_ID = require('./runtimeConfig').get('AIRTABLE_MONITOREO_ID') || 'appdvraKSdp0XVn5n';
14
- const Programa_Juntas_ID = require('./runtimeConfig').get('AIRTABLE_PROGRAMA_JUNTAS_ID') || 'appKFWzkcDEWlrXBE';
15
- const Symptoms_ID = require('./runtimeConfig').get('AIRTABLE_SYMPTOMS_ID') || 'appQRhZlQ9tMfYZWJ';
16
- const Follow_Up_ID = require('./runtimeConfig').get('AIRTABLE_FOLLOW_UP_ID') || 'appBjKw1Ub0KkbZf0';
17
- const Webinars_Leads_ID = require('./runtimeConfig').get('AIRTABLE_WEBINARS_LEADS_ID') || 'appzjpVXTI0TgqGPq';
18
- const Product_ID = require('./runtimeConfig').get('AIRTABLE_PRODUCT_ID') || 'appu2YDW2pKDYLL5H';
9
+ const Calendar_ID = runtimeConfig.get('AIRTABLE_CALENDAR_ID') || 'appIjEstWR6972tbF';
10
+ const Config_ID = runtimeConfig.get('AIRTABLE_CONFIG_ID') || 'app9K4EvGI8McC8jF';
11
+ const Historial_Clinico_ID = runtimeConfig.get('AIRTABLE_HISTORIAL_CLINICO_ID') || 'appdUpGUS06XIzVnY';
12
+ const Logging_ID = runtimeConfig.get('AIRTABLE_LOGGING_ID') || 'appQ7YhzfebRDbSPJ';
13
+ const Monitoreo_ID = runtimeConfig.get('AIRTABLE_MONITOREO_ID') || 'appdvraKSdp0XVn5n';
14
+ const Programa_Juntas_ID = runtimeConfig.get('AIRTABLE_PROGRAMA_JUNTAS_ID') || 'appKFWzkcDEWlrXBE';
15
+ const Symptoms_ID = runtimeConfig.get('AIRTABLE_SYMPTOMS_ID') || 'appQRhZlQ9tMfYZWJ';
16
+ const Follow_Up_ID = runtimeConfig.get('AIRTABLE_FOLLOW_UP_ID') || 'appBjKw1Ub0KkbZf0';
17
+ const Webinars_Leads_ID = runtimeConfig.get('AIRTABLE_WEBINARS_LEADS_ID') || 'appzjpVXTI0TgqGPq';
18
+ const Product_ID = runtimeConfig.get('AIRTABLE_PRODUCT_ID') || 'appu2YDW2pKDYLL5H';
19
19
 
20
20
  let airtable = null;
21
21
  if (airtableConfig.apiKey) {
@@ -48,7 +48,7 @@ module.exports = {
48
48
  Follow_Up_ID,
49
49
  Webinars_Leads_ID,
50
50
  Product_ID,
51
- getBase: (baseKeyOrId = require('./runtimeConfig').get('AIRTABLE_BASE_ID')) => {
51
+ getBase: (baseKeyOrId = runtimeConfig.get('AIRTABLE_BASE_ID')) => {
52
52
  if (!airtable) {
53
53
  throw new Error('Airtable not configured. Please set AIRTABLE_API_KEY environment variable.');
54
54
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Handles assistant message processing with local or queue modes.
3
+ */
4
+ class AssistantProcessor {
5
+ constructor({ mode = 'local', queueAdapter = null, sendMessage = null, replyAssistant = null }) {
6
+ Object.assign(this, { mode, queueAdapter, sendMessage, replyAssistant });
7
+ if (mode === 'queue' && queueAdapter) {
8
+ queueAdapter.process('assistant.process', (payload) => this._processLocal(payload));
9
+ }
10
+ }
11
+
12
+ setReplyAssistant(fn) { this.replyAssistant = fn; }
13
+ setSendMessage(fn) { this.sendMessage = fn; }
14
+
15
+ async process({ code, body = null, runOptions = {} }) {
16
+ if (!code) throw new Error('code is required for assistant processing');
17
+ if (this.mode === 'queue') return await this._processViaQueue({ code, body, runOptions });
18
+ return await this._processLocal({ code, body, runOptions });
19
+ }
20
+
21
+ async _processLocal({ code, body = null, runOptions = {} }) {
22
+ if (!this.replyAssistant) throw new Error('replyAssistant function not configured');
23
+ return await this.replyAssistant(code, body, null, runOptions);
24
+ }
25
+
26
+ async _processViaQueue({ code, body, runOptions }) {
27
+ if (!this.queueAdapter) throw new Error('queueAdapter is required for queue mode');
28
+ const jobId = await this.queueAdapter.enqueue('assistant.process', { code, body, runOptions });
29
+ return await this.queueAdapter.waitForResult(jobId, 120000);
30
+ }
31
+
32
+ async sendResponse(code, result) {
33
+ if (!this.sendMessage) throw new Error('sendMessage function not configured');
34
+ if (!result?.output) return null;
35
+ await this.sendMessage({ code, body: result.output, processed: true, origin: 'assistant', tools_executed: result.tools_executed, prompt: result.prompt, response_id: result.response_id });
36
+ return result.output;
37
+ }
38
+
39
+ }
40
+
41
+ module.exports = { AssistantProcessor };
@@ -0,0 +1,122 @@
1
+ const { logger } = require('../utils/logger');
2
+ const { Message } = require('../models/messageModel');
3
+
4
+ /**
5
+ * Manages message batching, processing locks, and run abandonment.
6
+ */
7
+ class BatchingManager {
8
+ constructor({ provider = null, config = {} }) {
9
+ this.provider = provider;
10
+ this.config = {
11
+ enabled: config.enabled ?? true,
12
+ abortOnNewMessage: config.abortOnNewMessage ?? true,
13
+ immediateRestart: config.immediateRestart ?? true,
14
+ typingIndicator: config.typingIndicator ?? false
15
+ };
16
+
17
+ this.processingLocks = new Map();
18
+ this.activeRequests = new Map();
19
+ this.abandonedRuns = new Set();
20
+ this.typingIntervals = new Map();
21
+ }
22
+
23
+ setProvider(provider) {
24
+ this.provider = provider;
25
+ }
26
+
27
+ isProcessing(chatId) {
28
+ return this.processingLocks.has(chatId);
29
+ }
30
+
31
+ isActiveRun(chatId, runId) {
32
+ return this.activeRequests.get(chatId) === runId && !this.abandonedRuns.has(runId);
33
+ }
34
+
35
+ async handleBatchedProcessing(chatId, processingFn, sendResponseFn) {
36
+ if (this.processingLocks.has(chatId)) {
37
+ if (this.config.abortOnNewMessage && this.activeRequests.has(chatId)) {
38
+ const runId = this.activeRequests.get(chatId);
39
+ this.abandonedRuns.add(runId);
40
+ logger.info('[BatchingManager] Marked run as abandoned', { runId, chatId });
41
+
42
+ if (this.config.immediateRestart) {
43
+ this._clearProcessingState(chatId);
44
+ await this._processWithLock(chatId, processingFn, sendResponseFn);
45
+ }
46
+ }
47
+ return;
48
+ }
49
+
50
+ this.processingLocks.set(chatId, true);
51
+ await this._processWithLock(chatId, processingFn, sendResponseFn);
52
+ }
53
+
54
+ async _processWithLock(chatId, processingFn, sendResponseFn) {
55
+ this.processingLocks.set(chatId, true);
56
+ const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
57
+ this.activeRequests.set(chatId, runId);
58
+
59
+ try {
60
+ await this._startTypingRefresh(chatId, runId);
61
+ if (this._checkAbandoned(runId)) return;
62
+
63
+ const result = await processingFn(runId);
64
+ if (this._checkAbandoned(runId)) return;
65
+
66
+ if (sendResponseFn && result) await sendResponseFn(result);
67
+ } catch (error) {
68
+ logger.error('[BatchingManager] Error processing messages', { chatId, error: error.message });
69
+ } finally {
70
+ if (this.activeRequests.get(chatId) === runId) {
71
+ this._clearProcessingState(chatId);
72
+ }
73
+ if (this.abandonedRuns.size > 100) {
74
+ const toKeep = [...this.abandonedRuns].slice(-20);
75
+ this.abandonedRuns.clear();
76
+ toKeep.forEach(id => this.abandonedRuns.add(id));
77
+ }
78
+ }
79
+ }
80
+
81
+ _clearProcessingState(chatId) {
82
+ this.processingLocks.delete(chatId);
83
+ this.activeRequests.delete(chatId);
84
+ this._stopTyping(chatId);
85
+ }
86
+
87
+ _stopTyping(chatId) {
88
+ const interval = this.typingIntervals.get(chatId);
89
+ if (interval) {
90
+ clearInterval(interval);
91
+ this.typingIntervals.delete(chatId);
92
+ }
93
+ }
94
+
95
+ _checkAbandoned(runId) {
96
+ if (this.abandonedRuns.has(runId)) {
97
+ this.abandonedRuns.delete(runId);
98
+ logger.debug('[BatchingManager] Run was abandoned', { runId });
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ async _startTypingRefresh(chatId, runId) {
105
+ if (!this.config.typingIndicator || !this.provider?.sendTypingIndicator) return;
106
+
107
+ const lastMessage = await Message.findOne({
108
+ numero: chatId, from_me: false, processed: false,
109
+ message_id: { $exists: true, $ne: null, $not: /^pending-/ }
110
+ }).sort({ createdAt: -1 });
111
+
112
+ if (!lastMessage?.message_id || this.activeRequests.get(chatId) !== runId) return;
113
+
114
+ this.typingIntervals.set(chatId, setInterval(() => {
115
+ this.provider.sendTypingIndicator(lastMessage.message_id)
116
+ .catch(err => logger.debug('[BatchingManager] Typing indicator failed', { chatId, error: err.message }));
117
+ }, 5000));
118
+ }
119
+
120
+ }
121
+
122
+ module.exports = { BatchingManager };
@@ -1,5 +1,8 @@
1
1
  const MEDIA_TYPE_REGEX = /(image|audio|video|document)/;
2
2
 
3
+ /**
4
+ * Parses raw messages into normalized MessageData format.
5
+ */
3
6
  class MessageParser {
4
7
  constructor(config = {}) {
5
8
  this.config = config;
@@ -19,11 +19,15 @@ const { createMessagingProvider } = require('../adapters/registry');
19
19
  const { addMsgAssistant, replyAssistant } = require('../services/assistantService');
20
20
  const { hasPreprocessingHandler, invokePreprocessingHandler } = require('../services/preprocessingService');
21
21
 
22
- class NexusMessaging {
23
- /*
24
- * INITIALIZATION
25
- */
22
+ const { BatchingManager } = require('../core/BatchingManager');
23
+ const { AssistantProcessor } = require('../core/AssistantProcessor');
24
+
25
+ const { createQueueAdapter } = require('../queue');
26
26
 
27
+ /**
28
+ * Core messaging orchestrator for providers, storage, and assistant processing.
29
+ */
30
+ class NexusMessaging {
27
31
  constructor(config = {}) {
28
32
  this.config = config;
29
33
  this.provider = null;
@@ -49,18 +53,28 @@ class NexusMessaging {
49
53
  keyword: [],
50
54
  flow: []
51
55
  };
52
- this.processingLocks = new Map();
53
- this.activeRequests = new Map();
54
- this.abandonedRuns = new Set();
55
- this.typingIntervals = new Map();
56
+
56
57
  this.batchingConfig = {
57
58
  enabled: config.messageBatching?.enabled ?? true,
58
59
  abortOnNewMessage: config.messageBatching?.abortOnNewMessage ?? true,
59
60
  immediateRestart: config.messageBatching?.immediateRestart ?? true,
60
- batchWindowMs: config.messageBatching?.batchWindowMs ?? 2000,
61
- maxBatchWait: config.messageBatching?.maxBatchWait ?? 5000,
62
61
  typingIndicator: config.messageBatching?.typingIndicator ?? false
63
62
  };
63
+
64
+ this.batchingManager = new BatchingManager({
65
+ config: this.batchingConfig
66
+ });
67
+
68
+ const queueType = config.queue?.type || 'local';
69
+ const queueConfig = config.queue?.config || {};
70
+ this.queueAdapter = createQueueAdapter(queueType, queueConfig);
71
+
72
+ this.assistantProcessor = new AssistantProcessor({
73
+ mode: config.assistant?.mode || 'local',
74
+ queueAdapter: this.queueAdapter,
75
+ sendMessage: this.sendMessage.bind(this),
76
+ replyAssistant
77
+ });
64
78
  }
65
79
 
66
80
  async initialize(options = {}) {
@@ -108,6 +122,7 @@ class NexusMessaging {
108
122
  this.provider.setMessageStorage(this.messageStorage);
109
123
  }
110
124
  await this.provider.initialize();
125
+ this.batchingManager.setProvider(this.provider);
111
126
  }
112
127
 
113
128
  /*
@@ -139,8 +154,11 @@ class NexusMessaging {
139
154
  getProvider() { return this.provider; }
140
155
  getLLMProvider() { return this.llmProvider; }
141
156
  getEventBus() { return this.events; }
157
+ getBatchingManager() { return this.batchingManager; }
158
+ getAssistantProcessor() { return this.assistantProcessor; }
159
+ getQueueAdapter() { return this.queueAdapter; }
142
160
  isConnected() { return this.provider?.getConnectionStatus() ?? false; }
143
- isProcessing(chatId) { return this.processingLocks.has(chatId); }
161
+ isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
144
162
 
145
163
  /*
146
164
  * MIDDLEWARE
@@ -309,64 +327,32 @@ class NexusMessaging {
309
327
  */
310
328
 
311
329
  async handleMessageWithAssistant(messageData) {
312
- if (messageData.isInteractive) return;
313
- const { from, body } = this._extractAssistantInputs(messageData);
314
- if (!from || !body) return;
330
+ const { from, body, isInteractive } = messageData;
331
+ if (isInteractive || !from || !body) return;
315
332
 
316
333
  try {
317
- const result = await this._processMessages(from, () => replyAssistant(from, body));
318
- await this._sendAssistantResponse(from, result);
334
+ const result = await this._processMessages(from, () => this.assistantProcessor.process({ code: from, body }));
335
+ await this.assistantProcessor.sendResponse(from, result);
319
336
  } catch (error) {
320
337
  logger.error('Error in handleMessageWithAssistant', { error: error.message });
321
338
  }
322
339
  }
323
340
 
324
341
  async handleMediaWithAssistant(messageData) {
325
- const { from, body } = this._extractAssistantInputs(messageData);
342
+ const { from, body } = messageData;
326
343
  if (!from) return;
327
344
 
328
345
  const media = Array.isArray(messageData.media) ? messageData.media[0] : messageData.media;
329
346
  const message = body?.trim() || `Media received (${media?.mediaType || media?.type || 'attachment'})`;
330
347
 
331
348
  try {
332
- const result = await this._processMessages(from, () => replyAssistant(from, message));
333
- await this._sendAssistantResponse(from, result);
349
+ const result = await this._processMessages(from, () => this.assistantProcessor.process({ code: from, body: message }));
350
+ await this.assistantProcessor.sendResponse(from, result);
334
351
  } catch (error) {
335
352
  logger.error('Error in handleMediaWithAssistant', { error: error.message });
336
353
  }
337
354
  }
338
355
 
339
- _extractAssistantInputs(messageData) {
340
- if (!messageData || typeof messageData !== 'object') return { from: null, body: null };
341
-
342
- const findFirst = (candidates) => candidates.find(v => typeof v === 'string' && v.trim().length > 0) || null;
343
-
344
- const from = findFirst([
345
- messageData.from, messageData.sender, messageData.numero, messageData.code, messageData.From,
346
- messageData.key?.remoteJid, messageData.raw?.from, messageData.raw?.From, messageData.raw?.key?.remoteJid
347
- ]);
348
-
349
- const body = findFirst([
350
- messageData.body, messageData.message?.conversation, messageData.Body,
351
- messageData.raw?.message?.conversation, messageData.raw?.Body,
352
- messageData.caption, messageData.media?.caption,
353
- Array.isArray(messageData.media) ? messageData.media[0]?.caption : null
354
- ]);
355
-
356
- return { from, body };
357
- }
358
-
359
- async _sendAssistantResponse(code, result) {
360
- const response = typeof result === 'string' ? result : result?.output;
361
- if (!response) return null;
362
-
363
- await this.sendMessage({
364
- code, body: response, processed: true, origin: 'assistant',
365
- tools_executed: result?.tools_executed, prompt: result?.prompt, response_id: result?.response_id
366
- });
367
- return response;
368
- }
369
-
370
356
  /*
371
357
  * MEDIA PROCESSING
372
358
  */
@@ -412,137 +398,42 @@ class NexusMessaging {
412
398
  */
413
399
 
414
400
  async _handleWithCheckAfter(chatId) {
415
- if (this.processingLocks.has(chatId)) {
416
- if (this.batchingConfig.abortOnNewMessage && this.activeRequests.has(chatId)) {
417
- const runId = this.activeRequests.get(chatId);
418
- this.abandonedRuns.add(runId);
419
- logger.info(`[CheckAfter] Marked run ${runId} as abandoned for ${chatId}`);
420
-
421
- if (this.batchingConfig.immediateRestart) {
422
- this._clearProcessingState(chatId);
423
- await this._processWithLock(chatId, null);
424
- }
425
- }
426
- return;
427
- }
428
- await this._processWithLock(chatId, await this._startTypingRefresh(chatId));
429
- }
430
-
431
- async _processWithLock(chatId, typingInterval = null) {
432
- this.processingLocks.set(chatId, true);
433
- if (typingInterval) {
434
- this.typingIntervals.set(chatId, typingInterval);
435
- }
436
-
437
- const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
438
- this.activeRequests.set(chatId, runId);
439
-
440
- try {
441
- if (!typingInterval) {
442
- typingInterval = await this._startTypingRefresh(chatId);
443
- if (typingInterval) {
444
- this.typingIntervals.set(chatId, typingInterval);
445
- }
446
- }
447
-
448
- const startTime = Date.now();
449
- let lastCount = await this._getUnprocessedMessageCount(chatId);
450
-
451
- while (Date.now() - startTime < this.batchingConfig.batchWindowMs) {
452
- if (this._checkAbandoned(runId)) return;
453
-
454
- await new Promise(r => setTimeout(r, 500));
455
- const newCount = await this._getUnprocessedMessageCount(chatId);
456
- if (newCount > lastCount) lastCount = newCount;
457
- if (Date.now() - startTime >= this.batchingConfig.maxBatchWait) break;
458
- }
459
-
460
- if (this._checkAbandoned(runId)) return;
461
-
462
- const result = await this._processMessages(chatId, () => replyAssistant(chatId, null, null, { runId }));
463
- if (this._checkAbandoned(runId)) return;
464
-
465
- if (typingInterval) {
466
- clearInterval(typingInterval);
467
- typingInterval = null;
468
- this.typingIntervals.delete(chatId);
469
- }
470
-
471
- const response = await this._sendAssistantResponse(chatId, result);
472
- this.events.emit('messages:processed', { chatId, response });
473
- } catch (error) {
474
- logger.error('[Batching] Error processing messages', { chatId, error: error.message });
475
- } finally {
476
- if (typingInterval) clearInterval(typingInterval);
477
- this.typingIntervals.delete(chatId);
478
- if (this.activeRequests.get(chatId) === runId) {
479
- this.processingLocks.delete(chatId);
480
- this.activeRequests.delete(chatId);
481
- }
482
- if (this.abandonedRuns.size > 100) this.abandonedRuns.clear();
483
- }
484
- }
485
-
486
- _clearProcessingState(chatId) {
487
- this.processingLocks.delete(chatId);
488
- this.activeRequests.delete(chatId);
489
- const interval = this.typingIntervals.get(chatId);
490
- if (interval) {
491
- clearInterval(interval);
492
- this.typingIntervals.delete(chatId);
493
- }
494
- }
401
+ const processingFn = async (runId) => {
402
+ const shouldFinalize = () => this.batchingManager.isActiveRun(chatId, runId);
403
+ return await this._processMessages(chatId, () => this.assistantProcessor.process({ code: chatId, runOptions: { runId } }), shouldFinalize);
404
+ };
495
405
 
496
- _checkAbandoned(runId) {
497
- if (this.abandonedRuns.has(runId)) {
498
- this.abandonedRuns.delete(runId);
499
- return true;
500
- }
501
- return false;
502
- }
406
+ const sendResponseFn = async (result) => {
407
+ await this.assistantProcessor.sendResponse(chatId, result);
408
+ };
503
409
 
504
- async _getUnprocessedMessageCount(chatId) {
505
- return Message.countDocuments({ numero: chatId, processed: false, from_me: false });
410
+ await this.batchingManager.handleBatchedProcessing(chatId, processingFn, sendResponseFn);
506
411
  }
507
412
 
508
- async _processMessages(chatId, processingFn) {
413
+ async _processMessages(chatId, processingFn, shouldFinalize = () => true) {
509
414
  const query = { numero: chatId, from_me: false, processed: false };
510
415
  const unprocessed = await Message.find(query).select('_id');
511
416
 
512
417
  try {
513
418
  const result = await processingFn();
514
- if (unprocessed.length > 0) {
419
+ if (unprocessed.length > 0 && shouldFinalize()) {
515
420
  await Message.updateMany({ _id: { $in: unprocessed.map(m => m._id) } }, { $set: { processed: true } });
516
- logger.info(`[_processMessages] Marked ${unprocessed.length} messages as processed for ${chatId}`);
421
+ logger.info('[_processMessages] Marked messages as processed', { count: unprocessed.length, chatId });
517
422
  }
518
423
  return result;
519
424
  } catch (error) {
520
- logger.debug(`[_processMessages] Processing failed for ${chatId}`);
425
+ logger.debug('[_processMessages] Processing failed', { chatId });
521
426
  throw error;
522
427
  }
523
428
  }
524
429
 
525
- async _startTypingRefresh(chatId) {
526
- if (!this.batchingConfig.typingIndicator || !this.provider?.sendTypingIndicator) return null;
527
-
528
- const lastMessage = await Message.findOne({
529
- numero: chatId, from_me: false, processed: false,
530
- message_id: { $exists: true, $ne: null, $not: /^pending-/ }
531
- }).sort({ createdAt: -1 });
532
-
533
- if (!lastMessage?.message_id) return null;
534
-
535
- return setInterval(() =>
536
- this.provider.sendTypingIndicator(lastMessage.message_id).catch(() => {}), 5000
537
- );
538
- }
539
-
540
430
  /*
541
431
  * LIFECYCLE
542
432
  */
543
433
 
544
434
  async disconnect() {
545
435
  if (this.provider) await this.provider.disconnect();
436
+ if (this.queueAdapter) await this.queueAdapter.shutdown();
546
437
  }
547
438
  }
548
439
 
@@ -2,6 +2,9 @@ const axios = require('axios');
2
2
  const { v4: uuidv4 } = require('uuid');
3
3
 
4
4
  const runtimeConfig = require('../config/runtimeConfig');
5
+ const { isAllowedUrl, redactUrl } = require('../utils/sanitizerUtils');
6
+
7
+ const TWILIO_CDN_DOMAINS = ['api.twilio.com', 'mms.twiliocdn.com', 'media.twiliocdn.com'];
5
8
 
6
9
  function convertTwilioToInternalFormat(twilioMessage) {
7
10
  const from = twilioMessage.From || '';
@@ -23,29 +26,59 @@ function convertTwilioToInternalFormat(twilioMessage) {
23
26
  }
24
27
 
25
28
  async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
29
+ if (!isAllowedUrl(mediaUrl, TWILIO_CDN_DOMAINS)) {
30
+ throw new Error('Invalid media URL domain');
31
+ }
32
+
26
33
  const accountSid = runtimeConfig.get('TWILIO_ACCOUNT_SID');
27
34
  const authToken = runtimeConfig.get('TWILIO_AUTH_TOKEN');
28
35
  const authorization = `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString('base64')}`;
29
36
 
30
37
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
31
38
  try {
32
- logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries });
39
+ logger.debug('[TwilioMedia] Download attempt', { attempt, maxRetries, url: redactUrl(mediaUrl) });
33
40
 
34
- const response = await axios.get(mediaUrl, {
41
+ const redirectResponse = await axios.get(mediaUrl, {
35
42
  headers: {
36
43
  'Authorization': authorization,
37
44
  'User-Agent': 'Nexus-Media-Processor/1.0'
38
45
  },
46
+ maxRedirects: 0,
47
+ validateStatus: status => status >= 200 && status < 400,
48
+ responseType: 'arraybuffer',
49
+ timeout: 30000
50
+ });
51
+
52
+ if (redirectResponse.status === 200) {
53
+ return Buffer.from(redirectResponse.data);
54
+ }
55
+
56
+ const cdnUrl = redirectResponse.headers.location;
57
+ if (!cdnUrl) {
58
+ throw new Error('No redirect URL in response');
59
+ }
60
+
61
+ if (!isAllowedUrl(cdnUrl, TWILIO_CDN_DOMAINS)) {
62
+ throw new Error('Redirect URL not in allowed domains');
63
+ }
64
+
65
+ logger.debug('[TwilioMedia] Following redirect', { url: redactUrl(cdnUrl) });
66
+
67
+ const response = await axios.get(cdnUrl, {
68
+ headers: { 'User-Agent': 'Nexus-Media-Processor/1.0' },
39
69
  responseType: 'arraybuffer',
40
70
  timeout: 30000
41
71
  });
42
72
  return Buffer.from(response.data);
43
73
  } catch (error) {
44
74
  const is404 = error.response?.status === 404;
75
+ const isDnsError = error.code === 'ENOTFOUND';
45
76
  const isLastAttempt = attempt === maxRetries;
46
77
 
47
- if (is404 && !isLastAttempt) {
48
- await new Promise(r => setTimeout(r, Math.pow(2, attempt - 1) * 1000));
78
+ if ((is404 || isDnsError) && !isLastAttempt) {
79
+ const delay = Math.pow(2, attempt - 1) * 1000;
80
+ logger.warn('[TwilioMedia] Download failed, retrying', { error: error.message, attempt, delay });
81
+ await new Promise(r => setTimeout(r, delay));
49
82
  continue;
50
83
  }
51
84
 
@@ -54,7 +87,7 @@ async function downloadMediaFromTwilio(mediaUrl, logger, maxRetries = 5) {
54
87
  return null;
55
88
  }
56
89
 
57
- logger.error('[TwilioMedia] Download failed', { error: error.message, status: error.response?.status, attempt });
90
+ logger.error('[TwilioMedia] Download failed', { error: error.message, attempt });
58
91
  throw error;
59
92
  }
60
93
  }
package/lib/index.d.ts CHANGED
@@ -335,4 +335,101 @@ declare module '@peopl-health/nexus' {
335
335
  getMessageValues: typeof getMessageValues;
336
336
  formatTimestamp: typeof formatTimestamp;
337
337
  };
338
+
339
+ // Queue Adapters
340
+ export interface QueueJobOptions {
341
+ priority?: number;
342
+ delay?: number;
343
+ attempts?: number;
344
+ backoff?: { type: 'fixed' | 'exponential'; delay: number };
345
+ }
346
+
347
+ export interface QueueJobStatus {
348
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'not_found';
349
+ result?: any;
350
+ error?: string;
351
+ }
352
+
353
+ export abstract class QueueAdapter {
354
+ constructor(config?: any);
355
+ abstract enqueue(jobType: string, payload: any, options?: QueueJobOptions): Promise<string>;
356
+ abstract process(jobType: string, handler: (payload: any) => Promise<any>): Promise<void>;
357
+ abstract getJobStatus(jobId: string): Promise<QueueJobStatus>;
358
+ abstract cancelJob(jobId: string): Promise<boolean>;
359
+ abstract waitForResult(jobId: string, timeout?: number): Promise<any>;
360
+ abstract shutdown(): Promise<void>;
361
+ }
362
+
363
+ export class LocalQueueAdapter extends QueueAdapter {
364
+ constructor(config?: any);
365
+ enqueue(jobType: string, payload: any, options?: QueueJobOptions): Promise<string>;
366
+ process(jobType: string, handler: (payload: any) => Promise<any>): Promise<void>;
367
+ getJobStatus(jobId: string): Promise<QueueJobStatus>;
368
+ cancelJob(jobId: string): Promise<boolean>;
369
+ waitForResult(jobId: string, timeout?: number): Promise<any>;
370
+ shutdown(): Promise<void>;
371
+ }
372
+
373
+ export interface RedisQueueConfig {
374
+ redis?: { host: string; port: number; password?: string };
375
+ defaultJobOptions?: QueueJobOptions;
376
+ }
377
+
378
+ export class RedisQueueAdapter extends QueueAdapter {
379
+ constructor(config?: RedisQueueConfig);
380
+ enqueue(jobType: string, payload: any, options?: QueueJobOptions): Promise<string>;
381
+ process(jobType: string, handler: (payload: any) => Promise<any>): Promise<void>;
382
+ getJobStatus(jobId: string): Promise<QueueJobStatus>;
383
+ cancelJob(jobId: string): Promise<boolean>;
384
+ waitForResult(jobId: string, timeout?: number): Promise<any>;
385
+ shutdown(): Promise<void>;
386
+ }
387
+
388
+ export function createQueueAdapter(type: 'local' | 'redis' | string, config?: any): QueueAdapter;
389
+ export function registerQueueAdapter(name: string, AdapterClass: typeof QueueAdapter): void;
390
+
391
+ // BatchingManager
392
+ export interface BatchingConfig {
393
+ enabled?: boolean;
394
+ abortOnNewMessage?: boolean;
395
+ immediateRestart?: boolean;
396
+ typingIndicator?: boolean;
397
+ }
398
+
399
+ export class BatchingManager {
400
+ constructor(options: {
401
+ provider?: MessageProvider;
402
+ config?: BatchingConfig;
403
+ });
404
+ setProvider(provider: MessageProvider): void;
405
+ isProcessing(chatId: string): boolean;
406
+ handleBatchedProcessing(
407
+ chatId: string,
408
+ processingFn: (runId: string) => Promise<any>,
409
+ sendResponseFn: (result: any) => Promise<void>
410
+ ): Promise<void>;
411
+ }
412
+
413
+ // AssistantProcessor
414
+ export interface AssistantProcessorConfig {
415
+ mode?: 'local' | 'queue';
416
+ queueAdapter?: QueueAdapter;
417
+ sendMessage?: (messageData: MessageData) => Promise<any>;
418
+ replyAssistant?: (code: string, body?: string, thread?: any, options?: any) => Promise<any>;
419
+ }
420
+
421
+ export interface ProcessInput {
422
+ code: string;
423
+ messageData: MessageData;
424
+ thread?: any;
425
+ runOptions?: Record<string, any>;
426
+ }
427
+
428
+ export class AssistantProcessor {
429
+ constructor(config: AssistantProcessorConfig);
430
+ setReplyAssistant(fn: AssistantProcessorConfig['replyAssistant']): void;
431
+ setSendMessage(fn: AssistantProcessorConfig['sendMessage']): void;
432
+ process(input: ProcessInput): Promise<{ output: string; tools_executed?: any[]; prompt?: string; response_id?: string } | null>;
433
+ sendResponse(code: string, result: any): Promise<string | null>;
434
+ }
338
435
  }
package/lib/index.js CHANGED
@@ -2,6 +2,8 @@ const { NexusMessaging, setDefaultInstance } = require('./core/NexusMessaging');
2
2
  const { MongoStorage } = require('./storage/MongoStorage');
3
3
  const { createStorage } = require('./storage/registry');
4
4
  const { MessageParser } = require('./core/MessageParser');
5
+ const { BatchingManager } = require('./core/BatchingManager');
6
+ const { AssistantProcessor } = require('./core/AssistantProcessor');
5
7
  const { logger } = require('./utils/logger');
6
8
  const { createLLMProvider } = require('./providers/createLLMProvider');
7
9
  const { OpenAIAssistantsProvider } = require('./providers/OpenAIAssistantsProvider');
@@ -24,6 +26,8 @@ const {
24
26
  } = require('./services/preprocessingService');
25
27
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
26
28
 
29
+ const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
30
+
27
31
  class Nexus {
28
32
  constructor(config = {}) {
29
33
  this.config = config;
@@ -221,6 +225,8 @@ module.exports = {
221
225
  BaileysProvider,
222
226
  MongoStorage,
223
227
  MessageParser,
228
+ BatchingManager,
229
+ AssistantProcessor,
224
230
  createLLMProvider,
225
231
  OpenAIAssistantsProvider,
226
232
  OpenAIResponsesProvider,
@@ -239,5 +245,11 @@ module.exports = {
239
245
  logger,
240
246
  setModelDatabases,
241
247
  setModelDatabase,
242
- getModelDatabase
248
+ getModelDatabase,
249
+
250
+ QueueAdapter,
251
+ LocalQueueAdapter,
252
+ RedisQueueAdapter,
253
+ createQueueAdapter,
254
+ registerQueueAdapter
243
255
  };
@@ -101,13 +101,39 @@ messageSchema.pre('save', function (next) {
101
101
 
102
102
  const Message = mongoose.model('Message', messageSchema);
103
103
 
104
+ const CLINICAL_CONTEXT_CACHE = new Map();
105
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes TTL
106
+ const MAX_CACHE_SIZE = 1000;
107
+
104
108
  async function getClinicalContext(whatsappId) {
109
+ const now = Date.now();
110
+ const cached = CLINICAL_CONTEXT_CACHE.get(whatsappId);
111
+
112
+ if (cached && (now - cached.timestamp < CACHE_TTL)) {
113
+ logger.debug('[getClinicalContext] Returning cached clinical context', { whatsappId });
114
+ return cached.data;
115
+ }
116
+
105
117
  try {
106
118
  const records = await getRecordByFilter(Monitoreo_ID, 'estado_general', `{whatsapp_id}='${whatsappId}'`);
119
+ let clinicalContext = null;
120
+
107
121
  if (records && records.length > 0 && records[0]['clinical-context-json']) {
108
- return JSON.parse(records[0]['clinical-context-json']);
122
+ clinicalContext = JSON.parse(records[0]['clinical-context-json']);
109
123
  }
110
- return null;
124
+
125
+ // Cache the result (even if null)
126
+ if (CLINICAL_CONTEXT_CACHE.size >= MAX_CACHE_SIZE) {
127
+ const firstKey = CLINICAL_CONTEXT_CACHE.keys().next().value;
128
+ CLINICAL_CONTEXT_CACHE.delete(firstKey);
129
+ }
130
+
131
+ CLINICAL_CONTEXT_CACHE.set(whatsappId, {
132
+ data: clinicalContext,
133
+ timestamp: now
134
+ });
135
+
136
+ return clinicalContext;
111
137
  } catch (error) {
112
138
  logger.error('Error fetching clinical context from Airtable:', error);
113
139
  return null;
@@ -187,10 +213,15 @@ async function getContactDisplayName(contactNumber) {
187
213
  }
188
214
  }
189
215
 
216
+ function _clearClinicalContextCache() {
217
+ CLINICAL_CONTEXT_CACHE.clear();
218
+ }
219
+
190
220
  module.exports = {
191
221
  Message,
192
222
  insertMessage,
193
223
  getMessageValues,
194
224
  formatTimestamp,
195
- getContactDisplayName
225
+ getContactDisplayName,
226
+ _clearClinicalContextCache
196
227
  };
@@ -0,0 +1,104 @@
1
+ const EventEmitter = require('events');
2
+ const { QueueAdapter } = require('./QueueAdapter');
3
+
4
+ class LocalQueueAdapter extends QueueAdapter {
5
+ constructor(config = {}) {
6
+ super(config);
7
+ this.jobs = new Map();
8
+ this.handlers = new Map();
9
+ this.events = new EventEmitter();
10
+ this.jobCounter = 0;
11
+ }
12
+
13
+ async enqueue(jobType, payload, options = {}) {
14
+ const jobId = `local_${++this.jobCounter}_${Date.now()}`;
15
+ const job = {
16
+ id: jobId,
17
+ type: jobType,
18
+ payload,
19
+ options,
20
+ status: 'pending',
21
+ result: null,
22
+ error: null,
23
+ createdAt: new Date(),
24
+ completedAt: null
25
+ };
26
+
27
+ this.jobs.set(jobId, job);
28
+
29
+ setImmediate(async () => {
30
+ const handler = this.handlers.get(jobType);
31
+ if (!handler) {
32
+ job.status = 'failed';
33
+ job.error = `No handler registered for job type: ${jobType}`;
34
+ this.events.emit(`job:${jobId}`, job);
35
+ return;
36
+ }
37
+
38
+ job.status = 'running';
39
+ try {
40
+ job.result = await handler(payload);
41
+ job.status = 'completed';
42
+ job.completedAt = new Date();
43
+ } catch (err) {
44
+ job.status = 'failed';
45
+ job.error = err.message;
46
+ }
47
+ this.events.emit(`job:${jobId}`, job);
48
+ this.jobs.delete(jobId);
49
+ });
50
+
51
+ return jobId;
52
+ }
53
+
54
+ async process(jobType, handler) {
55
+ this.handlers.set(jobType, handler);
56
+ }
57
+
58
+ async getJobStatus(jobId) {
59
+ const job = this.jobs.get(jobId);
60
+ if (!job) return { status: 'not_found' };
61
+ return { status: job.status, result: job.result, error: job.error };
62
+ }
63
+
64
+ async cancelJob(jobId) {
65
+ const job = this.jobs.get(jobId);
66
+ if (!job || job.status !== 'pending') return false;
67
+ job.status = 'cancelled';
68
+ this.events.emit(`job:${jobId}`, job);
69
+ this.jobs.delete(jobId);
70
+ return true;
71
+ }
72
+
73
+ async waitForResult(jobId, timeout = 60000) {
74
+ const job = this.jobs.get(jobId);
75
+ if (!job) throw new Error(`Job not found: ${jobId}`);
76
+
77
+ if (job.status === 'completed') return job.result;
78
+ if (job.status === 'failed') throw new Error(job.error);
79
+ if (job.status === 'cancelled') throw new Error('Job was cancelled');
80
+
81
+ return new Promise((resolve, reject) => {
82
+ const onComplete = (completedJob) => {
83
+ clearTimeout(timer);
84
+ if (completedJob.status === 'completed') resolve(completedJob.result);
85
+ else if (completedJob.status === 'failed') reject(new Error(completedJob.error));
86
+ else if (completedJob.status === 'cancelled') reject(new Error('Job was cancelled'));
87
+ else reject(new Error(`Unexpected job status: ${completedJob.status}`));
88
+ };
89
+ const timer = setTimeout(() => {
90
+ this.events.removeListener(`job:${jobId}`, onComplete);
91
+ reject(new Error(`Job timed out after ${timeout}ms`));
92
+ }, timeout);
93
+ this.events.once(`job:${jobId}`, onComplete);
94
+ });
95
+ }
96
+
97
+ async shutdown() {
98
+ this.jobs.clear();
99
+ this.handlers.clear();
100
+ this.events.removeAllListeners();
101
+ }
102
+ }
103
+
104
+ module.exports = { LocalQueueAdapter };
@@ -0,0 +1,34 @@
1
+ class QueueAdapter {
2
+ constructor(config = {}) {
3
+ this.config = config;
4
+ if (this.constructor === QueueAdapter) {
5
+ throw new Error('QueueAdapter is abstract and cannot be instantiated directly');
6
+ }
7
+ }
8
+
9
+ async enqueue(jobType, payload, options = {}) {
10
+ throw new Error('enqueue() must be implemented by subclass');
11
+ }
12
+
13
+ async process(jobType, handler) {
14
+ throw new Error('process() must be implemented by subclass');
15
+ }
16
+
17
+ async getJobStatus(jobId) {
18
+ throw new Error('getJobStatus() must be implemented by subclass');
19
+ }
20
+
21
+ async cancelJob(jobId) {
22
+ throw new Error('cancelJob() must be implemented by subclass');
23
+ }
24
+
25
+ async waitForResult(jobId, timeout = 60000) {
26
+ throw new Error('waitForResult() must be implemented by subclass');
27
+ }
28
+
29
+ async shutdown() {
30
+ throw new Error('shutdown() must be implemented by subclass');
31
+ }
32
+ }
33
+
34
+ module.exports = { QueueAdapter };
@@ -0,0 +1,114 @@
1
+ const { logger } = require('../utils/logger');
2
+
3
+ const { QueueAdapter } = require('../queue/QueueAdapter');
4
+
5
+ class RedisQueueAdapter extends QueueAdapter {
6
+ constructor(config = {}) {
7
+ super(config);
8
+ this.queues = new Map();
9
+ this.Bull = null;
10
+ this.redisConfig = config.redis || { host: 'localhost', port: 6379 };
11
+ this.defaultJobOptions = config.defaultJobOptions || {
12
+ attempts: 3,
13
+ backoff: { type: 'exponential', delay: 1000 },
14
+ removeOnComplete: 100,
15
+ removeOnFail: 50
16
+ };
17
+ }
18
+
19
+ _loadBull() {
20
+ if (this.Bull) return this.Bull;
21
+ try {
22
+ this.Bull = require('bull');
23
+ return this.Bull;
24
+ } catch (err) {
25
+ throw new Error('bull package is required for RedisQueueAdapter. Install with: npm install bull ioredis');
26
+ }
27
+ }
28
+
29
+ _getQueue(jobType) {
30
+ if (this.queues.has(jobType)) return this.queues.get(jobType);
31
+
32
+ const Bull = this._loadBull();
33
+ const queue = new Bull(jobType, { redis: this.redisConfig });
34
+
35
+ queue.on('error', (err) => logger.error('[RedisQueueAdapter] Queue error', { jobType, error: err.message }));
36
+ queue.on('failed', (job, err) => logger.error('[RedisQueueAdapter] Job failed', { jobId: job.id, error: err.message }));
37
+
38
+ this.queues.set(jobType, queue);
39
+ return queue;
40
+ }
41
+
42
+ async enqueue(jobType, payload, options = {}) {
43
+ const queue = this._getQueue(jobType);
44
+ const jobOptions = { ...this.defaultJobOptions, ...options };
45
+ const job = await queue.add(payload, jobOptions);
46
+ logger.debug('[RedisQueueAdapter] Job enqueued', { jobType, jobId: job.id });
47
+ return String(job.id);
48
+ }
49
+
50
+ async process(jobType, handler) {
51
+ const queue = this._getQueue(jobType);
52
+ queue.process(async (job) => {
53
+ logger.debug('[RedisQueueAdapter] Processing job', { jobType, jobId: job.id });
54
+ return await handler(job.data);
55
+ });
56
+ }
57
+
58
+ _mapState(state) {
59
+ const map = { waiting: 'pending', delayed: 'pending', paused: 'pending', active: 'running', completed: 'completed', failed: 'failed' };
60
+ return map[state] || 'pending';
61
+ }
62
+
63
+ async getJobStatus(jobId) {
64
+ for (const queue of this.queues.values()) {
65
+ const job = await queue.getJob(jobId);
66
+ if (job) {
67
+ const state = await job.getState();
68
+ return { status: this._mapState(state), result: job.returnvalue, error: job.failedReason };
69
+ }
70
+ }
71
+ return { status: 'not_found' };
72
+ }
73
+
74
+ async cancelJob(jobId) {
75
+ for (const queue of this.queues.values()) {
76
+ const job = await queue.getJob(jobId);
77
+ if (job) {
78
+ const state = await job.getState();
79
+ if (state === 'waiting' || state === 'delayed') {
80
+ await job.remove();
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ async waitForResult(jobId, timeout = 60000) {
90
+ for (const queue of this.queues.values()) {
91
+ const job = await queue.getJob(jobId);
92
+ if (job) {
93
+ let timer;
94
+ return await Promise.race([
95
+ job.finished().finally(() => clearTimeout(timer)),
96
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Job timed out after ${timeout}ms`)), timeout); })
97
+ ]);
98
+ }
99
+ }
100
+ throw new Error(`Job not found: ${jobId}`);
101
+ }
102
+
103
+ async shutdown() {
104
+ const closePromises = [];
105
+ for (const queue of this.queues.values()) {
106
+ closePromises.push(queue.close());
107
+ }
108
+ await Promise.all(closePromises);
109
+ this.queues.clear();
110
+ logger.info('[RedisQueueAdapter] Shutdown complete');
111
+ }
112
+ }
113
+
114
+ module.exports = { RedisQueueAdapter };
@@ -0,0 +1,28 @@
1
+ const { QueueAdapter } = require('./QueueAdapter');
2
+ const { LocalQueueAdapter } = require('./LocalQueueAdapter');
3
+ const { RedisQueueAdapter } = require('./RedisQueueAdapter');
4
+
5
+ const adapters = {
6
+ local: LocalQueueAdapter,
7
+ redis: RedisQueueAdapter
8
+ };
9
+
10
+ function registerQueueAdapter(name, AdapterClass) {
11
+ adapters[name] = AdapterClass;
12
+ }
13
+
14
+ function createQueueAdapter(type = 'local', config = {}) {
15
+ const AdapterClass = adapters[type];
16
+ if (!AdapterClass) {
17
+ throw new Error(`Unknown queue adapter type: ${type}. Available: ${Object.keys(adapters).join(', ')}`);
18
+ }
19
+ return new AdapterClass(config);
20
+ }
21
+
22
+ module.exports = {
23
+ QueueAdapter,
24
+ LocalQueueAdapter,
25
+ RedisQueueAdapter,
26
+ registerQueueAdapter,
27
+ createQueueAdapter
28
+ };
@@ -52,11 +52,33 @@ function validateRequired(params, fields) {
52
52
  return missing.length ? missing : null;
53
53
  }
54
54
 
55
+ function isAllowedUrl(url, allowedDomains) {
56
+ try {
57
+ const parsed = new URL(url);
58
+ return allowedDomains.some(domain => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function redactUrl(url) {
65
+ try {
66
+ const parsed = new URL(url);
67
+ const pathParts = parsed.pathname.split('/');
68
+ const id = pathParts.find(p => /^[A-Z]{2}[a-f0-9]{32}$/i.test(p)) || pathParts.pop();
69
+ return `${parsed.hostname}/.../${id}`;
70
+ } catch {
71
+ return '[invalid-url]';
72
+ }
73
+ }
74
+
55
75
  module.exports = {
56
76
  sanitizeFilename,
57
77
  sanitizeMediaFilename,
58
78
  sanitizeLogMetadata,
59
79
  maskSensitiveValue,
60
80
  cleanObject,
61
- validateRequired
81
+ validateRequired,
82
+ isAllowedUrl,
83
+ redactUrl
62
84
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.3.10",
3
+ "version": "3.3.12",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -90,10 +90,20 @@
90
90
  "peerDependencies": {
91
91
  "@anthropic-ai/sdk": "^0.32.0",
92
92
  "baileys": "^6.4.0",
93
+ "bull": "^4.12.0",
93
94
  "express": "^4.22.1",
95
+ "ioredis": "^5.3.0",
94
96
  "openai": "6.7.0",
95
97
  "twilio": "5.11.2"
96
98
  },
99
+ "peerDependenciesMeta": {
100
+ "bull": {
101
+ "optional": true
102
+ },
103
+ "ioredis": {
104
+ "optional": true
105
+ }
106
+ },
97
107
  "engines": {
98
108
  "node": ">=20.0.0"
99
109
  },