@peopl-health/nexus 4.4.4 → 4.5.21

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.
Files changed (45) hide show
  1. package/README.md +4 -9
  2. package/lib/adapters/BaileysProvider.js +4 -2
  3. package/lib/adapters/MessageProvider.js +2 -2
  4. package/lib/adapters/TwilioProvider.js +7 -3
  5. package/lib/controllers/assistantController.js +2 -6
  6. package/lib/controllers/bugReportController.js +2 -2
  7. package/lib/controllers/conversationController.js +13 -13
  8. package/lib/controllers/interactionController.js +2 -2
  9. package/lib/controllers/messageController.js +6 -5
  10. package/lib/controllers/qualityMessageController.js +3 -2
  11. package/lib/core/AssistantProcessor.js +3 -3
  12. package/lib/core/BatchingManager.js +6 -5
  13. package/lib/core/NexusMessaging.js +230 -155
  14. package/lib/core/PhiProcessor.js +113 -0
  15. package/lib/eval/EvalProvider.js +6 -1
  16. package/lib/helpers/baileysHelper.js +3 -1
  17. package/lib/helpers/conversationWindowHelper.js +4 -4
  18. package/lib/helpers/deliveryAttemptHelper.js +3 -1
  19. package/lib/helpers/filesHelper.js +2 -5
  20. package/lib/helpers/messageHelper.js +10 -71
  21. package/lib/helpers/messageStatusHelper.js +3 -3
  22. package/lib/helpers/nerHelper.js +64 -0
  23. package/lib/helpers/templateRecoveryHelper.js +2 -0
  24. package/lib/index.d.ts +16 -1
  25. package/lib/jobs/ScheduledMessageJob.js +15 -23
  26. package/lib/jobs/TemplateApprovalJob.js +4 -1
  27. package/lib/memory/DefaultMemoryManager.js +5 -5
  28. package/lib/memory/SessionManager.js +3 -6
  29. package/lib/models/deliveryAttemptModel.js +1 -1
  30. package/lib/models/globalEntityMapModel.js +27 -0
  31. package/lib/models/messageModel.js +0 -94
  32. package/lib/models/tokenMapModel.js +28 -0
  33. package/lib/providers/NerClient.js +43 -0
  34. package/lib/providers/OpenAIResponsesProvider.js +9 -7
  35. package/lib/providers/OpenAIResponsesProviderTools.js +26 -9
  36. package/lib/services/assistantService.js +20 -11
  37. package/lib/services/dashboardService.js +4 -4
  38. package/lib/services/globalEntityService.js +59 -0
  39. package/lib/services/messageService.js +107 -0
  40. package/lib/services/metaService.js +5 -13
  41. package/lib/services/patientService.js +3 -2
  42. package/lib/services/tokenMapService.js +100 -0
  43. package/lib/storage/MongoStorage.js +9 -14
  44. package/lib/utils/tokenMapUtils.js +12 -0
  45. package/package.json +1 -1
@@ -3,10 +3,11 @@ const EventEmitter = require('events');
3
3
  const { airtable } = require('../config/airtableConfig');
4
4
  const llmConfigModule = require('../config/llmConfig');
5
5
  const { connect } = require('../config/mongoConfig');
6
+ const { setMetaConfig } = require('../config/metaConfig');
6
7
 
7
8
  const { logger } = require('../utils/logger');
8
9
 
9
- const { Message, insertMessage } = require('../models/messageModel');
10
+ const { Message } = require('../models/messageModel');
10
11
  const { Thread } = require('../models/threadModel');
11
12
 
12
13
  const { ensureThreadExists } = require('../helpers/threadHelper');
@@ -18,6 +19,7 @@ const { createMessagingProvider } = require('../adapters/registry');
18
19
 
19
20
  const { preProcessMessages } = require('../services/assistantService');
20
21
  const { hasPreprocessingHandler, invokePreprocessingHandler } = require('../services/preprocessingService');
22
+ const { getMessages, updateMessage } = require('../services/messageService');
21
23
 
22
24
  const { BatchingManager } = require('../core/BatchingManager');
23
25
  const { ProcessingPipeline } = require('../core/ProcessingPipeline');
@@ -27,6 +29,8 @@ const { createQueueAdapter } = require('../queue');
27
29
  const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
28
30
  const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
29
31
 
32
+ const { PhiProcessor } = require('./PhiProcessor');
33
+
30
34
  /**
31
35
  * Core messaging orchestrator for providers, storage, and assistant processing.
32
36
  */
@@ -47,15 +51,6 @@ class NexusMessaging {
47
51
  onFlow: null
48
52
  };
49
53
  this.events = new EventEmitter();
50
- this.middleware = {
51
- any: [],
52
- message: [],
53
- interactive: [],
54
- media: [],
55
- command: [],
56
- keyword: [],
57
- flow: []
58
- };
59
54
 
60
55
  this.batchingConfig = {
61
56
  enabled: config.messageBatching?.enabled ?? true,
@@ -75,17 +70,23 @@ class NexusMessaging {
75
70
  const queueConfig = config.queue?.config || {};
76
71
  this.queueAdapter = createQueueAdapter(queueType, queueConfig);
77
72
 
73
+ this.phiProcessor = new PhiProcessor({
74
+ encode: config.phi?.encode || false,
75
+ ner: config.phi?.ner || null,
76
+ hashSecret: config.phi?.hashSecret || null,
77
+ });
78
+
78
79
  this.assistantProcessor = new AssistantProcessor({
79
80
  mode: config.assistant?.mode || 'local',
80
81
  queueAdapter: this.queueAdapter,
81
- sendMessage: this.sendMessage.bind(this),
82
+ sendMessage: this.sendAssistantReply.bind(this),
83
+ phiProcessor: this.phiProcessor,
82
84
  storeRunMetrics,
83
85
  });
84
86
 
85
87
  this.scheduledMessageJob = new ScheduledMessageJob({
86
88
  queueAdapter: this.queueAdapter,
87
89
  sendMessage: this.sendMessage.bind(this),
88
- requireMessageStorage: () => this.messageStorage,
89
90
  requireProvider: () => this.getProvider()
90
91
  });
91
92
 
@@ -98,10 +99,45 @@ class NexusMessaging {
98
99
  return provider;
99
100
  }
100
101
  });
102
+
103
+ this._providerStores = false;
104
+ this._saveFn = null;
105
+ this._saveMessageFn = async () => null;
106
+ }
107
+
108
+ _selectSaveFn(providerStores) {
109
+ if (!this.messageStorage) return null;
110
+ if (providerStores && this.messageStorage.savePendingMessage) {
111
+ return this.messageStorage.savePendingMessage.bind(this.messageStorage);
112
+ }
113
+ if (!providerStores && this.messageStorage.saveMessage) {
114
+ return this.messageStorage.saveMessage.bind(this.messageStorage);
115
+ }
116
+ return null;
117
+ }
118
+
119
+ async _transport(messageData) {
120
+ if (messageData.contentSid && !messageData.body && this.provider.renderTemplate) {
121
+ const rendered = await this.provider.renderTemplate(messageData.contentSid, messageData.variables);
122
+ if (rendered) messageData = { ...messageData, body: rendered };
123
+ }
124
+
125
+ const result = await this.provider.sendMessage(messageData);
126
+
127
+ if (messageData.parentMessageId && result?.finalize && this.messageStorage?.finalizePendingMessage) {
128
+ await this.messageStorage.finalizePendingMessage(messageData.parentMessageId, result.finalize.sid, {
129
+ status: result.finalize.status,
130
+ updatedAt: new Date()
131
+ });
132
+ }
133
+
134
+ if (messageData.code) ensureThreadExists(messageData.code);
135
+
136
+ return result;
101
137
  }
102
138
 
103
139
  async initialize(options = {}) {
104
- const { provider, providerConfig, mongoUri, messageStorage, llm, llmConfig } = options;
140
+ const { provider, providerConfig, mongoUri, messageStorage, llm, llmConfig, meta } = options;
105
141
 
106
142
  if (mongoUri || process.env.MONGODB_URI) {
107
143
  await this.initializeMongoDB(mongoUri);
@@ -112,6 +148,9 @@ class NexusMessaging {
112
148
  if (llm && llmConfig) {
113
149
  this.initializeLLM(llm, llmConfig);
114
150
  }
151
+ if (meta) {
152
+ setMetaConfig(meta);
153
+ }
115
154
  if (messageStorage) {
116
155
  this.setMessageStorage(messageStorage);
117
156
  }
@@ -157,6 +196,9 @@ class NexusMessaging {
157
196
  if (this.provider && typeof this.provider.setMessageStorage === 'function') {
158
197
  this.provider.setMessageStorage(storage);
159
198
  }
199
+ this._providerStores = this.provider?.supportsMessageStorage?.() ?? false;
200
+ this._saveFn = this._selectSaveFn(this._providerStores);
201
+ this._saveMessageFn = this.messageStorage ? (msg) => this.messageStorage.saveMessage(msg) : async () => null;
160
202
  }
161
203
 
162
204
  setHandlers(handlers) {
@@ -181,54 +223,10 @@ class NexusMessaging {
181
223
  getPipeline() { return this.pipeline; }
182
224
  getAssistantProcessor() { return this.assistantProcessor; }
183
225
  getQueueAdapter() { return this.queueAdapter; }
226
+ getPhiProcessor() { return this.phiProcessor; }
184
227
  isConnected() { return this.provider?.getConnectionStatus() ?? false; }
185
228
  isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
186
229
 
187
- /*
188
- * MIDDLEWARE
189
- */
190
-
191
- use(typeOrFn, maybeFn) {
192
- if (typeof typeOrFn === 'function') {
193
- this.middleware.any.push(typeOrFn);
194
- return this;
195
- }
196
- const type = String(typeOrFn || '').toLowerCase();
197
- if (!this.middleware[type]) this.middleware[type] = [];
198
- this.middleware[type].push(maybeFn);
199
- return this;
200
- }
201
-
202
- async _runPipeline(type, messageData, finalHandler) {
203
- const chain = [
204
- ...(this.middleware.any || []),
205
- ...(this.middleware[type] || []),
206
- async (ctx) => await finalHandler(ctx)
207
- ];
208
- let idx = -1;
209
- const runner = async (i) => {
210
- if (i <= idx) throw new Error('next() called multiple times');
211
- idx = i;
212
- const fn = chain[i];
213
- if (!fn) return;
214
- return await fn(messageData, this, () => runner(i + 1));
215
- };
216
- return await runner(0);
217
- }
218
-
219
- async _handleWithPipeline(type, handlerKey, messageData, fallback) {
220
- const result = await this._runPipeline(type, messageData, async (ctx) => {
221
- const handler = this.handlers[handlerKey];
222
- if (handler) {
223
- return await handler(ctx, this);
224
- }
225
- if (fallback) {
226
- return await fallback(ctx);
227
- }
228
- });
229
- return result;
230
- }
231
-
232
230
  /*
233
231
  * OUTBOUND MESSAGING
234
232
  */
@@ -238,62 +236,96 @@ class NexusMessaging {
238
236
 
239
237
  if (messageData._fromConversationReply) messageData.processed = true;
240
238
 
241
- if (messageData.contentSid && !messageData.body && this.provider.renderTemplate) {
242
- const rendered = await this.provider.renderTemplate(messageData.contentSid, messageData.variables);
243
- if (rendered) messageData.body = rendered;
244
- }
245
-
246
239
  if (this._needsTemplateRoute(messageData) && !(await isWithin24HourWindow(messageData.code))) {
247
- const parent = messageData.parentMessageId
248
- ? await Message.findById(messageData.parentMessageId)
249
- : await this.messageStorage.savePendingMessage({
240
+ if (messageData.parentMessageId) {
241
+ const [found] = await getMessages({ _id: messageData.parentMessageId }, { limit: 1 });
242
+ if (!found) return { success: false, delivered: false, deferred: true };
243
+ await triggerTemplateRecovery(found, { source: 'preemptive' });
244
+ return { success: true, messageId: String(found._id), status: 'queued', preemptive: true, delivered: false, deferred: true };
245
+ }
246
+ const saveFn = this.messageStorage.savePendingMessage.bind(this.messageStorage);
247
+ return this.phiProcessor.outDataFromPlain(
248
+ {
250
249
  ...messageData,
251
- provider: 'twilio', fromMe: true,
252
- processed: messageData.processed ?? false
250
+ provider: 'twilio',
251
+ fromMe: true,
252
+ processed: messageData.processed ?? false,
253
+ },
254
+ saveFn,
255
+ async ({ body, _id }) => {
256
+ await triggerTemplateRecovery({ _id, body, numero: messageData.code }, { source: 'preemptive' });
257
+ return { success: true, messageId: String(_id), status: 'queued', preemptive: true, delivered: false, deferred: true };
258
+ },
259
+ { onSaved: messageData.onParentMessageId },
260
+ );
261
+ }
262
+
263
+ if (messageData.parentMessageId) {
264
+ return this._transport(messageData);
265
+ }
266
+
267
+ if (!this._providerStores) {
268
+ const encodedBody = await this.phiProcessor.encodeBody(messageData.body, messageData.code);
269
+ const result = await this._transport(messageData);
270
+ if (this.messageStorage?.saveMessage) {
271
+ await this.messageStorage.saveMessage({
272
+ ...messageData, body: encodedBody,
273
+ messageId: result?.messageId, provider: result?.provider,
274
+ fromMe: true, processed: true,
253
275
  });
254
- await triggerTemplateRecovery(parent, { source: 'preemptive' });
255
- return { success: true, messageId: String(parent._id), status: 'queued', preemptive: true };
276
+ }
277
+ return result;
256
278
  }
257
279
 
258
- const providerStores = this.provider.supportsMessageStorage?.() ?? false;
259
- let parentId = messageData.parentMessageId || null;
260
- if (!parentId && providerStores && this.messageStorage?.savePendingMessage) {
261
- const pending = await this.messageStorage.savePendingMessage({
262
- ...messageData,
263
- provider: 'twilio',
264
- fromMe: true,
265
- processed: messageData.processed ?? false
266
- });
267
- parentId = pending?._id || null;
280
+ const saveFn = this._saveFn;
281
+
282
+ if (!saveFn) {
283
+ return this._transport(messageData);
268
284
  }
269
285
 
270
- const result = await this.provider.sendMessage({ ...messageData, parentMessageId: parentId });
286
+ return this.phiProcessor.outDataFromPlain(
287
+ { ...messageData, provider: 'twilio', fromMe: true, processed: messageData.processed ?? false },
288
+ saveFn,
289
+ ({ body, _id }) => this._transport({ ...messageData, body, parentMessageId: _id }),
290
+ { onSaved: messageData.onParentMessageId },
291
+ );
292
+ }
271
293
 
272
- if (parentId && result?.finalize && this.messageStorage?.finalizePendingMessage) {
273
- await this.messageStorage.finalizePendingMessage(parentId, result.finalize.sid, {
274
- status: result.finalize.status,
275
- updatedAt: new Date()
276
- });
294
+ async sendAssistantReply(messageData) {
295
+ if (!this.provider) throw new Error('No provider initialized');
296
+
297
+ if (messageData.parentMessageId) {
298
+ return this._transport(messageData);
277
299
  }
278
300
 
279
- if (this.messageStorage && !providerStores) {
280
- await this.messageStorage.saveMessage({
281
- ...messageData,
282
- messageId: result.messageId,
283
- provider: result.provider,
284
- fromMe: true,
285
- processed: true
286
- });
301
+ if (!this._providerStores) {
302
+ const decodedBody = await this.phiProcessor.decodeBody(messageData.body, messageData.code);
303
+ const result = await this._transport({ ...messageData, body: decodedBody });
304
+ if (this.messageStorage?.saveMessage) {
305
+ await this.messageStorage.saveMessage({
306
+ ...messageData,
307
+ messageId: result?.messageId, provider: result?.provider,
308
+ fromMe: true, processed: true, origin: messageData.origin || 'assistant',
309
+ });
310
+ }
311
+ return result;
287
312
  }
288
313
 
289
- const chatId = messageData.code;
290
- if (chatId) ensureThreadExists(chatId);
314
+ const saveFn = this._saveFn;
291
315
 
292
- return result;
316
+ if (!saveFn) {
317
+ return this._transport(messageData);
318
+ }
319
+
320
+ return this.phiProcessor.outDataFromEncoded(
321
+ { ...messageData, fromMe: true, processed: true, origin: messageData.origin || 'assistant' },
322
+ saveFn,
323
+ ({ body, _id }) => this._transport({ ...messageData, body, parentMessageId: _id }),
324
+ );
293
325
  }
294
326
 
295
327
  _needsTemplateRoute(messageData) {
296
- if (this.provider?.supportsMessageStorage?.() !== true) return false;
328
+ if (!this._providerStores) return false;
297
329
  if (messageData.contentSid) return false;
298
330
  if (!messageData.code) return false;
299
331
  return true;
@@ -306,10 +338,11 @@ class NexusMessaging {
306
338
  if (!scheduledMessage?.sendTime) {
307
339
  throw new Error('sendScheduledMessage requires sendTime');
308
340
  }
309
- return await this.scheduledMessageJob.schedule({
341
+ const job = await this.scheduledMessageJob.schedule({
310
342
  scheduledMessageId: scheduledMessage._id,
311
343
  sendTime: scheduledMessage.sendTime
312
344
  });
345
+ return { ...(job || {}), delivered: false, deferred: true };
313
346
  }
314
347
 
315
348
  async scheduleTemplateApproval({ templateSid, messageId, originalMessageSid = null, attempt = 0 }) {
@@ -326,8 +359,6 @@ class NexusMessaging {
326
359
  */
327
360
 
328
361
  async processIncomingMessage(messageData) {
329
- if (this.messageStorage) await this.messageStorage.saveMessage(messageData);
330
-
331
362
  const chatId = messageData.code;
332
363
 
333
364
  if (chatId) {
@@ -339,41 +370,52 @@ class NexusMessaging {
339
370
  }
340
371
  }
341
372
 
342
- if (chatId && hasPreprocessingHandler()) {
343
- const stop = await invokePreprocessingHandler({ code: chatId, context: 'incomingMessage', message: messageData });
344
- if (stop) return;
345
- }
346
-
347
- if (messageData.interactive) return this.handleInteractive(messageData);
348
- if (messageData.command) return this.handleCommand(messageData);
349
- if (messageData.keyword) return this.handleKeyword(messageData);
350
- if (messageData.flow) return this.handleFlow(messageData);
351
-
352
- if (this.batchingConfig.enabled && chatId) {
353
- return this._handleWithCheckAfter(chatId);
354
- }
355
- return messageData.media ? this.handleMedia(messageData) : this.handleMessage(messageData);
373
+ return this.phiProcessor.inData(
374
+ messageData,
375
+ this._saveMessageFn,
376
+ async ({ body }) => {
377
+ messageData = { ...messageData, body };
378
+ if (chatId && hasPreprocessingHandler()) {
379
+ const stop = await invokePreprocessingHandler({ code: chatId, context: 'incomingMessage', message: messageData });
380
+ if (stop) return;
381
+ }
382
+
383
+ if (messageData.interactive) return this.handleInteractive(messageData);
384
+ if (messageData.command) return this.handleCommand(messageData);
385
+ if (messageData.keyword) return this.handleKeyword(messageData);
386
+ if (messageData.flow) return this.handleFlow(messageData);
387
+
388
+ if (this.batchingConfig.enabled && chatId) return this._handleWithCheckAfter(chatId);
389
+ return messageData.media ? this.handleMedia(messageData) : this.handleMessage(messageData);
390
+ },
391
+ );
356
392
  }
357
393
 
358
394
  async handleMessage(messageData) {
359
- return await this._handleWithPipeline('message', 'onMessage', messageData, async (ctx) => {
360
- return await this.handleMessageWithAssistant(ctx);
361
- });
395
+ if (this.handlers.onMessage) return await this.handlers.onMessage(messageData, this);
396
+ return await this.handleMessageWithAssistant(messageData);
362
397
  }
363
398
 
364
399
  async handleInteractive(messageData) {
365
- return this._handleWithPipeline('interactive', 'onInteractive', messageData);
400
+ if (this.handlers.onInteractive) return await this.handlers.onInteractive(messageData, this);
366
401
  }
367
402
 
368
403
  async handleMedia(messageData) {
369
- return await this._handleWithPipeline('media', 'onMedia', messageData, async (ctx) => {
370
- return await this.handleMediaWithAssistant(ctx);
371
- });
404
+ if (this.handlers.onMedia) return await this.handlers.onMedia(messageData, this);
405
+ return await this.handleMediaWithAssistant(messageData);
372
406
  }
373
407
 
374
- async handleCommand(messageData) { return this._handleWithPipeline('command', 'onCommand', messageData); }
375
- async handleKeyword(messageData) { return this._handleWithPipeline('keyword', 'onKeyword', messageData); }
376
- async handleFlow(messageData) { return this._handleWithPipeline('flow', 'onFlow', messageData); }
408
+ async handleCommand(messageData) {
409
+ if (this.handlers.onCommand) return await this.handlers.onCommand(messageData, this);
410
+ }
411
+
412
+ async handleKeyword(messageData) {
413
+ if (this.handlers.onKeyword) return await this.handlers.onKeyword(messageData, this);
414
+ }
415
+
416
+ async handleFlow(messageData) {
417
+ if (this.handlers.onFlow) return await this.handlers.onFlow(messageData, this);
418
+ }
377
419
 
378
420
  /*
379
421
  * ASSISTANT INTEGRATION
@@ -444,6 +486,22 @@ class NexusMessaging {
444
486
  if (!resolved) return null;
445
487
 
446
488
  const preProcessed = await preProcessMessages(chatId, null, resolved.thread);
489
+
490
+ await Promise.all((preProcessed.pendingMediaUpdates ?? []).map(u =>
491
+ this.phiProcessor.inData(
492
+ { body: u.processedContent, code: chatId },
493
+ ({ body: encoded }) => updateMessage(
494
+ { message_id: u.message_id },
495
+ { $set: {
496
+ assistant_id: u.assistantId,
497
+ thread_id: u.threadId,
498
+ 'media.metadata.processedContent': encoded,
499
+ }}
500
+ ),
501
+ () => null,
502
+ )
503
+ ));
504
+
447
505
  if (!preProcessed.shouldProcess) return null;
448
506
 
449
507
  const result = await this.assistantProcessor.executeLLM(
@@ -461,55 +519,69 @@ class NexusMessaging {
461
519
 
462
520
  async processInstruction(code, instruction, role = 'developer', { triggeredBy } = {}) {
463
521
  const assistantId = await this._getThreadAssistantId(code);
464
- await insertMessage({
465
- nombre_whatsapp: 'Instruction',
466
- numero: code,
522
+ const messageData = {
523
+ pushName: 'Instruction',
524
+ code,
467
525
  body: instruction,
468
- message_id: `instruction_${Date.now()}_${Math.random().toString(36).substring(7)}`,
469
- from_me: true,
526
+ messageId: `instruction_${Date.now()}_${Math.random().toString(36).substring(7)}`,
527
+ fromMe: true,
470
528
  processed: true,
471
529
  origin: 'instruction',
472
- assistant_id: assistantId,
530
+ assistantId,
473
531
  raw: { role },
474
- triggeredBy: triggeredBy || null
475
- });
532
+ triggeredBy: triggeredBy || null,
533
+ silent: true,
534
+ };
476
535
 
477
- const result = await this._executeWithPipeline(code, 'instruction', 'queue',
478
- async (preProcessResult) => {
479
- return await this.assistantProcessor.process({
536
+ const result = await this.phiProcessor.inData(
537
+ messageData,
538
+ this._saveMessageFn,
539
+ ({ body }) => this._executeWithPipeline(code, 'instruction', 'queue',
540
+ async (preProcessResult) => this.assistantProcessor.process({
480
541
  code,
481
542
  runOptions: {
482
543
  prePromptResult: preProcessResult,
483
- additionalInstructions: instruction,
484
- additionalMessages: [{ role, content: instruction }],
544
+ additionalInstructions: body,
545
+ additionalMessages: [{ role, content: body }],
485
546
  toolChoice: 'none',
486
547
  }
487
- });
488
- }
548
+ }),
549
+ ),
489
550
  );
490
551
 
491
552
  return result?.output || null;
492
553
  }
493
554
 
494
- async processSystemMessage(code, messages, role = 'system', { triggeredBy } = {}) {
495
- const normalizedMessages = Array.isArray(messages) ? messages : [messages];
555
+ async processSystemMessage(code, messages, role = 'system', { triggeredBy, reply = true } = {}) {
556
+ const thread = await Thread.findOne({ code }).lean();
557
+ if (!thread) return null;
558
+
496
559
  const assistantId = await this._getThreadAssistantId(code);
560
+ const normalizedMessages = Array.isArray(messages) ? messages : [messages];
497
561
 
498
562
  for (let i = 0; i < normalizedMessages.length; i++) {
499
- await insertMessage({
500
- nombre_whatsapp: 'System',
501
- numero: code,
502
- body: normalizedMessages[i],
503
- message_id: `system_${Date.now()}_${i}_${Math.random().toString(36).substring(7)}`,
504
- from_me: true,
505
- processed: true,
506
- origin: 'system',
507
- assistant_id: assistantId,
508
- raw: { role },
509
- triggeredBy: triggeredBy || null
510
- });
563
+ try {
564
+ const messageData = {
565
+ pushName: 'System',
566
+ code,
567
+ body: normalizedMessages[i],
568
+ messageId: `system_${Date.now()}_${i}_${Math.random().toString(36).substring(7)}`,
569
+ fromMe: true,
570
+ processed: true,
571
+ origin: 'system',
572
+ assistantId,
573
+ raw: { role },
574
+ triggeredBy: triggeredBy || null,
575
+ silent: true,
576
+ };
577
+ await this.phiProcessor.inData(messageData, this._saveMessageFn, () => {});
578
+ } catch (err) {
579
+ logger.error('[processSystemMessage] Error saving system message', { error: err.message, code, index: i });
580
+ }
511
581
  }
512
582
 
583
+ if (!reply) return null;
584
+
513
585
  const result = await this._executeWithPipeline(code, 'system', 'queue',
514
586
  async (preProcessResult) => {
515
587
  return await this.assistantProcessor.process({
@@ -532,7 +604,7 @@ class NexusMessaging {
532
604
 
533
605
  async _processMessages(chatId, processingFn, shouldFinalize = () => true) {
534
606
  const query = { numero: chatId, from_me: false, processed: false };
535
- const unprocessed = await Message.find(query).select('_id');
607
+ const unprocessed = await getMessages(query, { select: '_id' });
536
608
 
537
609
  try {
538
610
  const result = await processingFn();
@@ -618,6 +690,8 @@ const processSystemMessage = async (code, messages, role, options) => {
618
690
 
619
691
  const getEventBus = () => getDefaultInstance()?.getEventBus();
620
692
 
693
+ const getPhiProcessor = () => getDefaultInstance()?.getPhiProcessor();
694
+
621
695
  const _resetDefaultInstance = () => { defaultInstance = null; };
622
696
 
623
697
  module.exports = {
@@ -631,6 +705,7 @@ module.exports = {
631
705
  getDefaultInstance,
632
706
  requireDefaultInstance,
633
707
  getProvider,
708
+ getPhiProcessor,
634
709
  requireProvider,
635
710
  getEventBus,
636
711
  _resetDefaultInstance
@@ -0,0 +1,113 @@
1
+ const { createHmac } = require('crypto');
2
+
3
+ const { logger } = require('../utils/logger');
4
+
5
+ const { PHI_CATEGORIES } = require('../models/tokenMapModel');
6
+
7
+ const { NerClient } = require('../providers/NerClient');
8
+
9
+ const tokenMapService = require('../services/tokenMapService');
10
+
11
+ const { lookupGlobal, lookupGlobalToken } = require('../services/globalEntityService');
12
+
13
+ const TOKEN_REGEX = /<<[A-Z_]+_\d+>>/g;
14
+
15
+ class PhiProcessor {
16
+ constructor({ encode = false, ner, hashSecret } = {}) {
17
+ this._nerClient = null;
18
+ this._hashSecret = hashSecret || null;
19
+ this._phiEnabled = !!encode;
20
+ if (!encode) return;
21
+ if (!ner) {
22
+ logger.error('[PhiProcessor] PHI encoding enabled but no NER config provided — body will be stored as plain text');
23
+ return;
24
+ }
25
+ this._setNerClient(ner);
26
+ }
27
+
28
+ _setNerClient(nerConfig) {
29
+ if (!nerConfig) { this._nerClient = null; return; }
30
+ this._nerClient = typeof nerConfig.detectEntities === 'function' ? nerConfig : new NerClient(nerConfig);
31
+ }
32
+
33
+ hashCode(code) {
34
+ if (!code) return null;
35
+ if (!this._phiEnabled) return code;
36
+ if (!this._hashSecret) {
37
+ logger.warn('[PhiProcessor] PHI hash secret not configured — numero omitted from metadata');
38
+ return null;
39
+ }
40
+ return createHmac('sha256', this._hashSecret).update(code).digest('hex');
41
+ }
42
+
43
+ async encodeBody(body, numero) {
44
+ if (!body || !numero || !this._nerClient) return body;
45
+
46
+ const entities = await this._nerClient.detectEntities(body);
47
+ if (!entities?.length) return body;
48
+
49
+ const sorted = [...entities].sort((a, b) => b.start - a.start);
50
+
51
+ let encoded = body;
52
+ for (const entity of sorted) {
53
+ if (!PHI_CATEGORIES.includes(entity.label)) {
54
+ logger.debug('[PhiProcessor] Unknown label, skipping', { label: entity.label });
55
+ continue;
56
+ }
57
+
58
+ const plaintext = entity.plaintext || encoded.slice(entity.start, entity.end);
59
+ const global = await lookupGlobal(plaintext);
60
+ const token = global
61
+ ? global.token
62
+ : await tokenMapService.encode(numero, plaintext, entity.label);
63
+
64
+ encoded = encoded.slice(0, entity.start) + token + encoded.slice(entity.end);
65
+ }
66
+
67
+ logger.debug('[PhiProcessor] Body encoded', { numero, entityCount: entities.length });
68
+ return encoded;
69
+ }
70
+
71
+ async inData(messageData, saveFn, handler) {
72
+ const encoded = await this.encodeBody(messageData.body, messageData.code);
73
+ const saved = await saveFn({ ...messageData, body: encoded });
74
+ return handler({ body: encoded, _id: saved?._id });
75
+ }
76
+
77
+ async outDataFromPlain(messageData, saveFn, handler, { onSaved } = {}) {
78
+ const encoded = await this.encodeBody(messageData.body, messageData.code);
79
+ const saved = await saveFn({ ...messageData, body: encoded });
80
+ await onSaved?.(saved?._id);
81
+ return handler({ body: messageData.body, _id: saved?._id });
82
+ }
83
+
84
+ async decodeBody(body, numero) {
85
+ if (!body || !numero) return body;
86
+ const tokens = body.match(TOKEN_REGEX);
87
+ if (!tokens) return body;
88
+ let decoded = body;
89
+ try {
90
+ for (const token of new Set(tokens)) {
91
+ const globalPlain = await lookupGlobalToken(token);
92
+ const plaintext = globalPlain ?? await tokenMapService.decode(numero, token);
93
+ if (plaintext && plaintext !== token) decoded = decoded.replaceAll(token, plaintext);
94
+ }
95
+ } catch (e) {
96
+ logger.error('[PhiProcessor] decodeBody failed, returning encoded body', { numero, error: e.message });
97
+ return body;
98
+ }
99
+ return decoded;
100
+ }
101
+
102
+ async outDataFromEncoded(messageData, saveFn, handler) {
103
+ const saved = await saveFn(messageData);
104
+ const decodedBody = this._phiEnabled
105
+ ? await this.decodeBody(messageData.body, messageData.code)
106
+ : messageData.body;
107
+ return handler({ body: decodedBody, _id: saved?._id });
108
+ }
109
+ }
110
+
111
+ module.exports = {
112
+ PhiProcessor,
113
+ };