@peopl-health/nexus 2.5.0 → 2.5.1

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.
@@ -154,6 +154,41 @@ class TwilioProvider extends MessageProvider {
154
154
  }
155
155
  }
156
156
 
157
+ async sendTypingIndicator(messageId) {
158
+ try {
159
+ const response = await axios.post(
160
+ 'https://messaging.twilio.com/v2/Indicators/Typing.json',
161
+ new URLSearchParams({
162
+ messageId: messageId,
163
+ channel: 'whatsapp'
164
+ }),
165
+ {
166
+ auth: {
167
+ username: this.accountSid,
168
+ password: this.authToken
169
+ },
170
+ headers: {
171
+ 'Content-Type': 'application/x-www-form-urlencoded'
172
+ }
173
+ }
174
+ );
175
+ logger.info('[TwilioProvider] Typing indicator sent successfully', {
176
+ messageId,
177
+ success: response.data?.success,
178
+ status: response.status
179
+ });
180
+ return { success: true };
181
+ } catch (error) {
182
+ logger.error('[TwilioProvider] Failed to send typing indicator', {
183
+ error: error.message,
184
+ messageId,
185
+ status: error.response?.status,
186
+ data: error.response?.data
187
+ });
188
+ return { success: false };
189
+ }
190
+ }
191
+
157
192
  async sendScheduledMessage(scheduledMessage) {
158
193
  const { sendTime, timeZone, __nexusSend } = scheduledMessage;
159
194
  const delay = this.calculateDelay(sendTime, timeZone);
@@ -42,8 +42,7 @@ class NexusMessaging {
42
42
  this.pendingResponses = new Map();
43
43
  this.batchingConfig = {
44
44
  enabled: config.messageBatching?.enabled ?? false,
45
- baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000,
46
- randomVariation: config.messageBatching?.randomVariation ?? 5000
45
+ baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000
47
46
  };
48
47
  }
49
48
 
@@ -373,6 +372,22 @@ class NexusMessaging {
373
372
  } else if (messageData.flow) {
374
373
  return await this.handleFlow(messageData);
375
374
  } else {
375
+ if (chatId && this.provider && typeof this.provider.sendTypingIndicator === 'function') {
376
+ const messageId = messageData.id || messageData.MessageSid || messageData.message_id;
377
+ if (messageId) {
378
+ this.provider.sendTypingIndicator(messageId)
379
+ .then(() => logger.debug('[processIncomingMessage] Typing indicator sent successfully', { messageId }))
380
+ .catch(err => logger.warn('[processIncomingMessage] Typing indicator failed', {
381
+ error: err.message,
382
+ messageId
383
+ }));
384
+ } else {
385
+ logger.warn('[processIncomingMessage] No message ID found for typing indicator', {
386
+ messageData: JSON.stringify(messageData).substring(0, 200)
387
+ });
388
+ }
389
+ }
390
+
376
391
  // For regular messages and media, use batching if enabled
377
392
  logger.info('Batching config:', this.batchingConfig);
378
393
  if (this.batchingConfig.enabled && chatId) {
@@ -635,17 +650,12 @@ class NexusMessaging {
635
650
  * Handle message with batching - waits for additional messages before processing
636
651
  */
637
652
  async _handleWithBatching(messageData, chatId) {
638
- // Clear existing timeout if there is one
639
653
  if (this.pendingResponses.has(chatId)) {
640
654
  clearTimeout(this.pendingResponses.get(chatId));
641
655
  logger.info(`Received additional message from ${chatId}, resetting wait timer`);
642
656
  }
643
-
644
- // Calculate wait time with random variation
645
- const waitTime = this.batchingConfig.baseWaitTime +
646
- Math.floor(Math.random() * this.batchingConfig.randomVariation);
647
-
648
- // Set new timeout
657
+
658
+ const waitTime = this.batchingConfig.baseWaitTime;
649
659
  const timeoutId = setTimeout(async () => {
650
660
  try {
651
661
  this.pendingResponses.delete(chatId);
@@ -685,7 +695,7 @@ class NexusMessaging {
685
695
  this.events.emit('messages:batched', { chatId, response: botResponse });
686
696
 
687
697
  } catch (error) {
688
- logger.error('Error in batched message handling:', error);
698
+ logger.error('Error in batched message handling:', { error: error.message });
689
699
  }
690
700
  }
691
701
 
@@ -5,6 +5,7 @@ const runtimeConfig = require('../config/runtimeConfig');
5
5
  const { BaseAssistant } = require('../assistants/BaseAssistant');
6
6
  const { createProvider } = require('../providers/createProvider');
7
7
 
8
+ const { Message } = require('../models/messageModel.js');
8
9
  const { Thread } = require('../models/threadModel.js');
9
10
  const { PredictionMetrics } = require('../models/predictionMetricsModel');
10
11
  const { insertMessage } = require('../models/messageModel');
@@ -328,161 +329,188 @@ const addInsAssistant = withTracing(
328
329
  })
329
330
  );
330
331
 
332
+ const startTypingIndicator = async (provider, code) => {
333
+ try {
334
+ const lastMessage = await Message.findOne({
335
+ numero: code,
336
+ from_me: false,
337
+ message_id: { $exists: true, $ne: null }
338
+ }).sort({ createdAt: -1 });
339
+
340
+ if (!lastMessage?.message_id) return null;
341
+
342
+ return setInterval(() =>
343
+ provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
344
+ logger.debug('[startTypingIndicator] Interval failed', { error: err.message })
345
+ ), 25000
346
+ );
347
+ } catch (err) {
348
+ logger.debug('[startTypingIndicator] Failed to start', { error: err.message });
349
+ return null;
350
+ }
351
+ };
352
+
331
353
  const replyAssistantCore = async (code, message_ = null, thread_ = null, runOptions = {}) => {
332
354
  const timings = {};
333
355
  const startTotal = Date.now();
334
356
 
335
- const { result: thread, duration: getThreadMs } = await withTracing(
336
- getThread,
337
- 'get_thread_operation',
338
- (threadCode) => ({
339
- 'thread.code': threadCode,
340
- 'operation.type': 'thread_retrieval',
341
- 'thread.provided': !!thread_
342
- }),
343
- { returnTiming: true }
344
- )(code);
345
- timings.get_thread_ms = getThreadMs;
346
-
347
- if (!thread_ && !thread) return null;
348
- const finalThread = thread_ || thread;
349
-
350
- const { result: patientReply, duration: getMessagesMs } = await withTracing(
351
- getLastMessages,
352
- 'get_last_messages',
353
- (code) => ({ 'thread.code': code }),
354
- { returnTiming: true }
355
- )(code);
356
- timings.get_messages_ms = getMessagesMs;
357
-
358
- if (!patientReply) {
359
- logger.info('[replyAssistantCore] No relevant data found for this assistant.');
360
- return null;
361
- }
362
-
363
357
  const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
358
+ const typingInterval = await startTypingIndicator(provider, code);
364
359
 
365
- logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
366
-
367
- const { result: processResult, duration: processMessagesMs } = await withTracing(
368
- processThreadMessage,
369
- 'process_thread_messages',
370
- (code, patientReply, provider) => ({
371
- 'messages.count': patientReply.length,
372
- 'thread.code': code
373
- }),
374
- { returnTiming: true }
375
- )(code, patientReply, provider);
376
-
377
- const { results: processResults, timings: processTimings } = processResult;
378
- timings.process_messages_ms = processMessagesMs;
379
-
380
- logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
381
-
382
- if (processTimings) {
383
- timings.process_messages_breakdown = {
384
- download_ms: processTimings.download_ms || 0,
385
- image_analysis_ms: processTimings.image_analysis_ms || 0,
386
- audio_transcription_ms: processTimings.audio_transcription_ms || 0,
387
- url_generation_ms: processTimings.url_generation_ms || 0,
388
- total_media_ms: processTimings.total_media_ms || 0
389
- };
390
- }
391
-
392
- const patientMsg = processResults.some(r => r.isPatient);
393
- const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
394
- const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
395
- const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
396
-
397
- if (allMessagesToAdd.length > 0) {
398
- logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
399
- await withThreadRecovery(
400
- async (thread = finalThread) => {
401
- const threadId = thread.getConversationId();
402
- await provider.addMessage({ threadId, messages: allMessagesToAdd });
403
- },
404
- finalThread,
405
- process.env.VARIANT || 'assistants'
406
- );
407
- }
408
-
409
- await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
410
- await cleanupFiles(allTempFiles);
360
+ try {
361
+ const { result: thread, duration: getThreadMs } = await withTracing(
362
+ getThread,
363
+ 'get_thread_operation',
364
+ (threadCode) => ({
365
+ 'thread.code': threadCode,
366
+ 'operation.type': 'thread_retrieval',
367
+ 'thread.provided': !!thread_
368
+ }),
369
+ { returnTiming: true }
370
+ )(code);
371
+ timings.get_thread_ms = getThreadMs;
372
+
373
+ if (!thread_ && !thread) return null;
374
+ const finalThread = thread_ || thread;
375
+
376
+ const { result: patientReply, duration: getMessagesMs } = await withTracing(
377
+ getLastMessages,
378
+ 'get_last_messages',
379
+ (code) => ({ 'thread.code': code }),
380
+ { returnTiming: true }
381
+ )(code);
382
+ timings.get_messages_ms = getMessagesMs;
383
+
384
+ if (!patientReply) {
385
+ logger.info('[replyAssistantCore] No relevant data found for this assistant.');
386
+ return null;
387
+ }
411
388
 
412
- if (urls.length > 0) {
413
- logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
414
- const { result: pdfResult, duration: pdfCombinationMs } = await withTracing(
415
- combineImagesToPDF,
416
- 'combine_images_to_pdf',
417
- ({ code }) => ({
418
- 'pdf.thread_code': code,
419
- 'pdf.url_count': urls.length
389
+ logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
390
+ const { result: processResult, duration: processMessagesMs } = await withTracing(
391
+ processThreadMessage,
392
+ 'process_thread_messages',
393
+ (code, patientReply, provider) => ({
394
+ 'messages.count': patientReply.length,
395
+ 'thread.code': code
420
396
  }),
421
397
  { returnTiming: true }
422
- )({ code });
423
- timings.pdf_combination_ms = pdfCombinationMs;
424
- const { pdfBuffer, processedFiles } = pdfResult;
425
- logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
398
+ )(code, patientReply, provider);
426
399
 
427
- if (pdfBuffer) {
428
- const key = `${code}-${Date.now()}-combined.pdf`;
429
- const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
430
- if (bucket) {
431
- await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
432
- }
400
+ const { results: processResults, timings: processTimings } = processResult;
401
+ timings.process_messages_ms = processMessagesMs;
402
+
403
+ logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
404
+
405
+ if (processTimings) {
406
+ timings.process_messages_breakdown = {
407
+ download_ms: processTimings.download_ms || 0,
408
+ image_analysis_ms: processTimings.image_analysis_ms || 0,
409
+ audio_transcription_ms: processTimings.audio_transcription_ms || 0,
410
+ url_generation_ms: processTimings.url_generation_ms || 0,
411
+ total_media_ms: processTimings.total_media_ms || 0
412
+ };
433
413
  }
414
+
415
+ const patientMsg = processResults.some(r => r.isPatient);
416
+ const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
417
+ const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
418
+ const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
434
419
 
435
- if (processedFiles && processedFiles.length) {
436
- cleanupFiles(processedFiles);
420
+ if (allMessagesToAdd.length > 0) {
421
+ logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
422
+ await withThreadRecovery(
423
+ async (thread = finalThread) => {
424
+ const threadId = thread.getConversationId();
425
+ await provider.addMessage({ threadId, messages: allMessagesToAdd });
426
+ },
427
+ finalThread,
428
+ process.env.VARIANT || 'assistants'
429
+ );
430
+ }
431
+
432
+ await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
433
+ await cleanupFiles(allTempFiles);
434
+
435
+ if (urls.length > 0) {
436
+ logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
437
+ const { result: pdfResult, duration: pdfCombinationMs } = await withTracing(
438
+ combineImagesToPDF,
439
+ 'combine_images_to_pdf',
440
+ ({ code }) => ({
441
+ 'pdf.thread_code': code,
442
+ 'pdf.url_count': urls.length
443
+ }),
444
+ { returnTiming: true }
445
+ )({ code });
446
+ timings.pdf_combination_ms = pdfCombinationMs;
447
+ const { pdfBuffer, processedFiles } = pdfResult;
448
+ logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
449
+
450
+ if (pdfBuffer) {
451
+ const key = `${code}-${Date.now()}-combined.pdf`;
452
+ const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
453
+ if (bucket) {
454
+ await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
455
+ }
456
+ }
457
+
458
+ if (processedFiles && processedFiles.length) {
459
+ cleanupFiles(processedFiles);
460
+ }
437
461
  }
438
- }
439
462
 
440
- if (!patientMsg || finalThread.stopped) return null;
463
+ if (!patientMsg || finalThread.stopped) return null;
441
464
 
442
- const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
443
- const { result: runResult, duration: runAssistantMs } = await withTracing(
444
- runAssistantWithRetries,
445
- 'run_assistant_with_retries',
446
- (thread, assistant, runConfig, patientReply) => ({
447
- 'assistant.id': thread.getAssistantId(),
448
- 'assistant.max_retries': DEFAULT_MAX_RETRIES,
449
- 'assistant.has_patient_reply': !!patientReply
450
- }),
451
- { returnTiming: true }
452
- )(finalThread, assistant, runOptions, patientReply);
453
- timings.run_assistant_ms = runAssistantMs;
454
- timings.total_ms = Date.now() - startTotal;
455
-
456
- const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
457
-
458
- logger.info('[Assistant Reply Complete]', {
459
- code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
460
- messageCount: patientReply.length,
461
- hasMedia: urls.length > 0,
462
- retries,
463
- totalMs: timings.total_ms,
464
- toolsExecuted: tools_executed?.length || 0
465
- });
466
-
467
- if (output && predictionTimeMs) {
468
- logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
469
- timing_breakdown: timings,
470
- has_breakdown: !!timings.process_messages_breakdown
471
- });
465
+ const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
466
+ const { result: runResult, duration: runAssistantMs } = await withTracing(
467
+ runAssistantWithRetries,
468
+ 'run_assistant_with_retries',
469
+ (thread, assistant, runConfig, patientReply) => ({
470
+ 'assistant.id': thread.getAssistantId(),
471
+ 'assistant.max_retries': DEFAULT_MAX_RETRIES,
472
+ 'assistant.has_patient_reply': !!patientReply
473
+ }),
474
+ { returnTiming: true }
475
+ )(finalThread, assistant, runOptions, patientReply);
476
+ timings.run_assistant_ms = runAssistantMs;
477
+ timings.total_ms = Date.now() - startTotal;
472
478
 
473
- await PredictionMetrics.create({
474
- message_id: `${code}-${Date.now()}`,
475
- numero: code,
476
- assistant_id: finalThread.getAssistantId(),
477
- thread_id: finalThread.getConversationId(),
478
- prediction_time_ms: predictionTimeMs,
479
- retry_count: retries,
480
- completed: completed,
481
- timing_breakdown: timings
482
- }).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
483
- }
479
+ const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
480
+
481
+ logger.info('[Assistant Reply Complete]', {
482
+ code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
483
+ messageCount: patientReply.length,
484
+ hasMedia: urls.length > 0,
485
+ retries,
486
+ totalMs: timings.total_ms,
487
+ toolsExecuted: tools_executed?.length || 0
488
+ });
489
+
490
+ if (output && predictionTimeMs) {
491
+ logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
492
+ timing_breakdown: timings,
493
+ has_breakdown: !!timings.process_messages_breakdown
494
+ });
495
+
496
+ await PredictionMetrics.create({
497
+ message_id: `${code}-${Date.now()}`,
498
+ numero: code,
499
+ assistant_id: finalThread.getAssistantId(),
500
+ thread_id: finalThread.getConversationId(),
501
+ prediction_time_ms: predictionTimeMs,
502
+ retry_count: retries,
503
+ completed: completed,
504
+ timing_breakdown: timings
505
+ }).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
506
+ }
484
507
 
485
- return { output, tools_executed };
508
+ return { output, tools_executed };
509
+ } finally {
510
+ if (typingInterval) {
511
+ clearInterval(typingInterval);
512
+ }
513
+ }
486
514
  };
487
515
 
488
516
  const replyAssistant = withTracing(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",