@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.
- package/lib/config/airtableConfig.js +11 -11
- package/lib/core/AssistantProcessor.js +41 -0
- package/lib/core/BatchingManager.js +122 -0
- package/lib/core/MessageParser.js +3 -0
- package/lib/core/NexusMessaging.js +49 -158
- package/lib/helpers/twilioHelper.js +38 -5
- package/lib/index.d.ts +97 -0
- package/lib/index.js +13 -1
- package/lib/models/messageModel.js +34 -3
- package/lib/queue/LocalQueueAdapter.js +104 -0
- package/lib/queue/QueueAdapter.js +34 -0
- package/lib/queue/RedisQueueAdapter.js +114 -0
- package/lib/queue/index.js +28 -0
- package/lib/utils/sanitizerUtils.js +23 -1
- package/package.json +11 -1
|
@@ -6,16 +6,16 @@ const airtableConfig = {
|
|
|
6
6
|
apiKey: runtimeConfig.get('AIRTABLE_API_KEY'),
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
const Calendar_ID =
|
|
10
|
-
const Config_ID =
|
|
11
|
-
const Historial_Clinico_ID =
|
|
12
|
-
const Logging_ID =
|
|
13
|
-
const Monitoreo_ID =
|
|
14
|
-
const Programa_Juntas_ID =
|
|
15
|
-
const Symptoms_ID =
|
|
16
|
-
const Follow_Up_ID =
|
|
17
|
-
const Webinars_Leads_ID =
|
|
18
|
-
const Product_ID =
|
|
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 =
|
|
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 };
|
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
313
|
-
|
|
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, () =>
|
|
318
|
-
await this.
|
|
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 } =
|
|
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, () =>
|
|
333
|
-
await this.
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
return false;
|
|
502
|
-
}
|
|
406
|
+
const sendResponseFn = async (result) => {
|
|
407
|
+
await this.assistantProcessor.sendResponse(chatId, result);
|
|
408
|
+
};
|
|
503
409
|
|
|
504
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
122
|
+
clinicalContext = JSON.parse(records[0]['clinical-context-json']);
|
|
109
123
|
}
|
|
110
|
-
|
|
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.
|
|
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
|
},
|