@peopl-health/nexus 1.7.5 → 1.7.7

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.
@@ -19,6 +19,12 @@ async function startServer() {
19
19
  accountSid: process.env.TWILIO_ACCOUNT_SID,
20
20
  authToken: process.env.TWILIO_AUTH_TOKEN,
21
21
  whatsappNumber: process.env.TWILIO_WHATSAPP_NUMBER
22
+ },
23
+
24
+ // Media configuration for AWS S3 upload
25
+ media: {
26
+ bucketName: process.env.AWS_S3_BUCKET_NAME,
27
+ region: process.env.AWS_REGION || 'us-east-1'
22
28
  }
23
29
  });
24
30
 
@@ -28,7 +34,7 @@ async function startServer() {
28
34
  // Add webhook endpoint for incoming messages
29
35
  app.post('/webhook', async (req, res) => {
30
36
  try {
31
- await nexus.messaging.processIncomingMessage(req.body);
37
+ await nexus.processMessage(req.body);
32
38
  res.status(200).send('OK');
33
39
  } catch (error) {
34
40
  console.error('Webhook error:', error);
@@ -42,7 +48,12 @@ async function startServer() {
42
48
  connected: nexus.messaging.isConnected(),
43
49
  provider: 'twilio',
44
50
  mongodb: nexus.messaging.mongodb?.readyState === 1,
45
- airtable: !!nexus.messaging.airtable
51
+ airtable: !!nexus.messaging.airtable,
52
+ aws: {
53
+ configured: !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY),
54
+ bucket: process.env.AWS_S3_BUCKET_NAME || 'not configured',
55
+ region: process.env.AWS_REGION || 'us-east-1'
56
+ }
46
57
  });
47
58
  });
48
59
 
@@ -64,7 +75,14 @@ async function startServer() {
64
75
  console.log('- POST /webhook - Webhook for incoming messages');
65
76
  console.log('- GET /status - Connection status');
66
77
  console.log('- GET /airtable-test - Test Airtable connection');
78
+ console.log('- GET /media-test - Test AWS media upload configuration');
67
79
  console.log('- All Nexus API routes under /api/*');
80
+ console.log('');
81
+ console.log('📸 Media Upload Setup:');
82
+ console.log(' Add these to your .env file for media upload:');
83
+ console.log(' - AWS_ACCESS_KEY_ID=your_access_key');
84
+ console.log(' - AWS_SECRET_ACCESS_KEY=your_secret_key');
85
+ console.log(' - AWS_S3_BUCKET_NAME=your-bucket-name');
68
86
  });
69
87
  }
70
88
 
@@ -84,19 +84,19 @@ class BaileysProvider extends MessageProvider {
84
84
  throw new Error('Baileys provider not connected');
85
85
  }
86
86
 
87
- const { code, message, fileUrl, fileType, hidePreview = false } = messageData;
87
+ const { code, body, fileUrl, fileType, hidePreview = false } = messageData;
88
88
 
89
89
  if (!code) {
90
90
  throw new Error('Recipient is required');
91
91
  }
92
92
 
93
- if (!message && !fileUrl) {
93
+ if (!body && !fileUrl) {
94
94
  throw new Error('Message or file URL is required');
95
95
  }
96
96
 
97
97
  const formattedCode = this.formatCode(code);
98
98
  let sendOptions = {};
99
- let formattedMessage = this.formatMessage(message || '');
99
+ let formattedMessage = this.formatMessage(body || '');
100
100
 
101
101
  // Handle mentions
102
102
  const regex = /@\d+/g;
@@ -44,7 +44,7 @@ class TwilioProvider extends MessageProvider {
44
44
  throw new Error('Twilio provider not initialized');
45
45
  }
46
46
 
47
- const { code, message, fileUrl, fileType, variables, contentSid } = messageData;
47
+ const { code, body, fileUrl, fileType, variables, contentSid } = messageData;
48
48
 
49
49
  const formattedFrom = this.ensureWhatsAppFormat(this.whatsappNumber);
50
50
  const formattedCode = this.ensureWhatsAppFormat(code);
@@ -77,8 +77,8 @@ class TwilioProvider extends MessageProvider {
77
77
  });
78
78
  messageParams.contentVariables = JSON.stringify(formattedVariables);
79
79
  }
80
- } else if (message) {
81
- messageParams.body = message;
80
+ } else if (body) {
81
+ messageParams.body = body;
82
82
  }
83
83
 
84
84
  // Handle media messages
@@ -154,6 +154,65 @@ class TwilioProvider extends MessageProvider {
154
154
  hasContentSid: Boolean(scheduledMessage.contentSid)
155
155
  });
156
156
 
157
+ const updateStatus = async (status, messageId = null, error = null) => {
158
+ if (!scheduledMessage) return;
159
+
160
+ const now = new Date();
161
+ const errorCode = error?.code || error?.status || error?.errorCode || null;
162
+ const errorMessage = error?.message || error?.statusMessage || null;
163
+ const statusEntry = { status, at: now, errorCode, errorMessage };
164
+ const baseUpdate = {
165
+ status,
166
+ lastStatus: status,
167
+ lastStatusAt: now,
168
+ errorCode,
169
+ errorMessage
170
+ };
171
+
172
+ if (messageId) baseUpdate.wa_id = messageId;
173
+
174
+ const ScheduledMessageModel = (() => {
175
+ if (scheduledMessage.constructor && typeof scheduledMessage.constructor.updateOne === 'function') {
176
+ return scheduledMessage.constructor;
177
+ }
178
+ try {
179
+ return require('../models/agendaMessageModel').ScheduledMessage;
180
+ } catch (e) {
181
+ return null;
182
+ }
183
+ })();
184
+
185
+ if (!ScheduledMessageModel) {
186
+ console.warn('[TwilioProvider] Scheduled message model unavailable for status update');
187
+ return;
188
+ }
189
+
190
+ const query = (() => {
191
+ if (scheduledMessage._id) return { _id: scheduledMessage._id };
192
+ if (messageId) return { wa_id: messageId };
193
+ if (scheduledMessage.wa_id) return { wa_id: scheduledMessage.wa_id };
194
+ return null;
195
+ })();
196
+
197
+ if (!query) {
198
+ console.warn('[TwilioProvider] Scheduled message status update skipped: no identifier', {
199
+ hasId: Boolean(scheduledMessage._id),
200
+ messageId,
201
+ existingWaId: scheduledMessage.wa_id
202
+ });
203
+ return;
204
+ }
205
+
206
+ try {
207
+ await ScheduledMessageModel.updateOne(query, {
208
+ $set: baseUpdate,
209
+ $push: { statusHistory: statusEntry }
210
+ });
211
+ } catch (statusErr) {
212
+ console.warn('[TwilioProvider] Failed to update scheduled message status', statusErr?.message || statusErr);
213
+ }
214
+ };
215
+
157
216
  setTimeout(async () => {
158
217
  try {
159
218
  // Convert Mongoose document to plain object if needed
@@ -164,9 +223,11 @@ class TwilioProvider extends MessageProvider {
164
223
  hasMessage: Boolean(payload.message || payload.body),
165
224
  hasMedia: Boolean(payload.fileUrl)
166
225
  });
167
- await sender(payload);
168
- console.log('Scheduled message sent successfully');
226
+ const response = await sender(payload);
227
+ const messageId = response?.result?.sid || null;
228
+ await updateStatus('sent', messageId);
169
229
  } catch (error) {
230
+ await updateStatus('failed', null, error);
170
231
  console.error(`Scheduled message failed: ${error.message}`);
171
232
  }
172
233
  }, delay);
@@ -253,7 +314,7 @@ class TwilioProvider extends MessageProvider {
253
314
  mediaType,
254
315
  fileName: existingFileName || `${sanitizedBase}.${extension}`,
255
316
  fileSize: buffer.length,
256
- caption: messageData.message || '',
317
+ caption: messageData.body || '',
257
318
  metadata: {
258
319
  originalUrl: fileUrl,
259
320
  uploadedAt: new Date().toISOString()
@@ -260,12 +260,12 @@ const getConversationReplyController = async (req, res) => {
260
260
  }
261
261
 
262
262
  if (message && message.trim() !== '') {
263
- messageData.message = message;
263
+ messageData.body = message;
264
264
  }
265
265
  }
266
266
  // Handle text message
267
267
  else if (message) {
268
- messageData.message = message;
268
+ messageData.body = message;
269
269
  }
270
270
 
271
271
  console.log('Sending message with data:', JSON.stringify(messageData));
@@ -28,7 +28,7 @@ class MessageProvider {
28
28
  * Send a message
29
29
  * @param {Object} messageData - Message data
30
30
  * @param {string} messageData.code - Recipient
31
- * @param {string} messageData.message - Message text
31
+ * @param {string} messageData.body - Message text
32
32
  * @param {string} messageData.fileUrl - Optional file URL
33
33
  * @param {string} messageData.fileType - File type (text, image, document, audio)
34
34
  * @param {Object} messageData.variables - Template variables
@@ -364,7 +364,7 @@ class NexusMessaging {
364
364
 
365
365
  _extractAssistantInputs(messageData) {
366
366
  if (!messageData || typeof messageData !== 'object') {
367
- return { from: null, message: null };
367
+ return { from: null, body: null };
368
368
  }
369
369
 
370
370
  const from = [
@@ -380,7 +380,7 @@ class NexusMessaging {
380
380
  ].find((value) => typeof value === 'string' && value.trim().length > 0) || null;
381
381
 
382
382
  const message = [
383
- typeof messageData.message === 'string' ? messageData.message : null,
383
+ typeof messageData.body === 'string' ? messageData.body : null, // Unified body field for all message types
384
384
  typeof messageData.message?.conversation === 'string' ? messageData.message.conversation : null,
385
385
  typeof messageData.Body === 'string' ? messageData.Body : null,
386
386
  typeof messageData.raw?.message?.conversation === 'string' ? messageData.raw.message.conversation : null,
@@ -392,7 +392,7 @@ class NexusMessaging {
392
392
  : null
393
393
  ].find((value) => typeof value === 'string' && value.trim().length > 0) || null;
394
394
 
395
- return { from, message };
395
+ return { from, body: message };
396
396
  }
397
397
 
398
398
  async handleMessage(messageData) {
@@ -403,19 +403,25 @@ class NexusMessaging {
403
403
 
404
404
  async handleMessageWithAssistant(messageData) {
405
405
  try {
406
- const { from, message } = this._extractAssistantInputs(messageData);
406
+ const { from, body } = this._extractAssistantInputs(messageData);
407
407
 
408
- if (!from || !message) {
408
+ // Skip assistant processing for all interactive messages to avoid conflicts with predefined flows
409
+ if (messageData.isInteractive) {
410
+ return;
411
+ }
412
+
413
+ if (!from || !body) {
409
414
  console.warn('Unable to resolve assistant inputs from message, skipping automatic reply.');
410
415
  return;
411
416
  }
412
417
 
413
- const response = await replyAssistant(from, message);
418
+ const response = await replyAssistant(from, body);
414
419
 
415
420
  if (response) {
416
421
  await this.sendMessage({
417
422
  code: from,
418
- message: response
423
+ body: response,
424
+ processed: true
419
425
  });
420
426
  }
421
427
  } catch (error) {
@@ -427,7 +433,24 @@ class NexusMessaging {
427
433
  if (this.messageStorage) {
428
434
  await this.messageStorage.saveInteractive(messageData);
429
435
  }
430
- return await this._handleWithPipeline('interactive', 'onInteractive', messageData);
436
+
437
+ this.events.emit('interactive:received', messageData);
438
+
439
+ // Run interactive handler if available
440
+ const result = await this._runPipeline('interactive', messageData, async (ctx) => {
441
+ const handler = this.handlers.onInteractive;
442
+ if (handler) {
443
+ return await handler(ctx, this);
444
+ }
445
+
446
+ // Interactive messages are handled by specific flows on other servers
447
+ // No assistant processing to avoid conflicts with predefined flows
448
+
449
+ return null;
450
+ });
451
+
452
+ this.events.emit('interactive:handled', messageData);
453
+ return result;
431
454
  }
432
455
 
433
456
  async handleMedia(messageData) {
@@ -440,7 +463,7 @@ class NexusMessaging {
440
463
 
441
464
  async handleMediaWithAssistant(messageData) {
442
465
  try {
443
- const { from, message } = this._extractAssistantInputs(messageData);
466
+ const { from, body } = this._extractAssistantInputs(messageData);
444
467
 
445
468
  if (!from) {
446
469
  console.warn('Unable to resolve sender for media message, skipping automatic reply.');
@@ -456,8 +479,8 @@ class NexusMessaging {
456
479
  return null;
457
480
  })();
458
481
 
459
- const fallbackMessage = message && message.trim().length > 0
460
- ? message
482
+ const fallbackMessage = body && body.trim().length > 0
483
+ ? body
461
484
  : `Media received (${mediaDescriptor || 'attachment'})`;
462
485
 
463
486
  const response = await replyAssistant(from, fallbackMessage);
@@ -465,7 +488,7 @@ class NexusMessaging {
465
488
  if (response) {
466
489
  await this.sendMessage({
467
490
  code: from,
468
- message: response
491
+ body: response
469
492
  });
470
493
  }
471
494
  } catch (error) {
@@ -507,8 +530,8 @@ class NexusMessaging {
507
530
  messageData.fileUrl = messageData.fileUrl || mediaPayload.metadata?.presignedUrl || null;
508
531
  messageData.fileType = messageData.fileType || mediaPayload.mediaType;
509
532
  messageData.caption = messageData.caption || primary.caption;
510
- if (!messageData.message && messageData.caption) {
511
- messageData.message = messageData.caption;
533
+ if (!messageData.body && messageData.caption) {
534
+ messageData.body = messageData.caption;
512
535
  }
513
536
  messageData.isMedia = true;
514
537
 
@@ -519,7 +542,7 @@ class NexusMessaging {
519
542
  const messageObj = convertTwilioToInternalFormat(raw);
520
543
  const values = getMessageValues(
521
544
  messageObj,
522
- messageData.message || messageData.caption || '',
545
+ messageData.body || messageData.caption || '',
523
546
  null,
524
547
  true
525
548
  );
@@ -604,8 +627,9 @@ class NexusMessaging {
604
627
  if (botResponse && this.provider) {
605
628
  await this.provider.sendMessage({
606
629
  to: chatId,
607
- message: botResponse,
608
- type: 'text'
630
+ body: botResponse,
631
+ type: 'text',
632
+ processed: true
609
633
  });
610
634
  }
611
635
 
package/lib/index.js CHANGED
@@ -212,7 +212,7 @@ class Nexus {
212
212
  * Send a message
213
213
  * @param {Object} messageData - Message data
214
214
  * @param {string} messageData.code - Recipient phone number
215
- * @param {string} messageData.message - Message text
215
+ * @param {string} messageData.body - Message text
216
216
  * @param {string} [messageData.fileUrl] - Optional file URL
217
217
  * @param {string} [messageData.fileType] - File type
218
218
  * @param {Object} [messageData.variables] - Template variables
@@ -10,6 +10,12 @@ const messageSchema = new mongoose.Schema({
10
10
  message_id: { type: String, required: true },
11
11
  is_group: { type: Boolean, required: true },
12
12
  is_media: { type: Boolean, required: true },
13
+ is_interactive: { type: Boolean, default: false },
14
+ interaction_type: {
15
+ type: String,
16
+ enum: ['button', 'list', 'flow', 'quick_reply'],
17
+ default: null
18
+ },
13
19
  group_id: { type: String, default: null },
14
20
  reply_id: { type: String, default: null },
15
21
  processed: { type: Boolean, default: false },
@@ -64,6 +70,8 @@ async function insertMessage(values) {
64
70
  message_id: values.message_id,
65
71
  is_group: values.is_group,
66
72
  is_media: values.is_media,
73
+ is_interactive: values.is_interactive || false,
74
+ interaction_type: values.interaction_type || null,
67
75
  group_id: values.group_id,
68
76
  reply_id: values.reply_id,
69
77
  from_me: values.from_me,
@@ -21,7 +21,7 @@ class MongoStorage {
21
21
  const interactionSchema = new mongoose.Schema({
22
22
  messageId: String,
23
23
  numero: String,
24
- interactionType: String, // 'button', 'list', 'flow'
24
+ interaction_type: String, // 'button', 'list', 'flow'
25
25
  payload: mongoose.Schema.Types.Mixed,
26
26
  timestamp: String,
27
27
  createdAt: { type: Date, default: Date.now }
@@ -67,7 +67,9 @@ class MongoStorage {
67
67
  provider: messageData?.provider || 'unknown',
68
68
  hasRaw: Boolean(messageData?.raw),
69
69
  hasMedia: Boolean(messageData?.media || messageData?.fileUrl),
70
- hasContentSid: Boolean(messageData?.contentSid)
70
+ hasContentSid: Boolean(messageData?.contentSid),
71
+ is_interactive: messageData.isInteractive,
72
+ interaction_type: messageData.interactionType
71
73
  });
72
74
  const enrichedMessage = await this._enrichTwilioMedia(messageData);
73
75
  const values = this.buildMessageValues(enrichedMessage);
@@ -135,7 +137,7 @@ class MongoStorage {
135
137
  fileUrl: undefined,
136
138
  fileType: mediaPayload.mediaType || messageData.fileType,
137
139
  isMedia: true,
138
- message: messageData.message || rawMessage.Body || primary.caption || '',
140
+ message: messageData.body || rawMessage.Body || primary.caption || '',
139
141
  caption: primary.caption || messageData.caption
140
142
  };
141
143
  } catch (error) {
@@ -179,7 +181,7 @@ class MongoStorage {
179
181
  const nombre = messageData.nombre_whatsapp || messageData.author || messageData.fromName || runtimeConfig.get('USER_DB_MONGO') || process.env.USER_DB_MONGO || 'Nexus';
180
182
 
181
183
  // Use message body directly (template rendering is now handled by the provider)
182
- let textBody = messageData.message || messageData.body;
184
+ let textBody = messageData.body;
183
185
 
184
186
  if (!textBody && isMedia) {
185
187
  textBody = `[Media:${messageData.fileType || 'attachment'}]`;
@@ -205,6 +207,8 @@ class MongoStorage {
205
207
  message_id: providerId,
206
208
  is_group: isGroup,
207
209
  is_media: isMedia,
210
+ is_interactive: messageData.isInteractive || false,
211
+ interaction_type: messageData.interactionType || null,
208
212
  group_id: isGroup ? normalizedNumero : null,
209
213
  reply_id: messageData.reply_id || messageData.replyId || null,
210
214
  from_me: messageData.fromMe !== undefined ? messageData.fromMe : true,
@@ -219,7 +223,7 @@ class MongoStorage {
219
223
  const interaction = new this.schemas.Interaction({
220
224
  messageId: interactionData.messageId,
221
225
  numero: interactionData.from,
222
- interactionType: interactionData.type, // 'button', 'list', 'flow'
226
+ interaction_type: interactionData.type, // 'button', 'list', 'flow'
223
227
  payload: interactionData.payload,
224
228
  timestamp: this.formatTimestamp(interactionData.timestamp)
225
229
  });
@@ -16,7 +16,7 @@ class MessageParser {
16
16
  */
17
17
  parseMessage(rawMessage) {
18
18
  const messageData = {
19
- id: rawMessage.id || rawMessage.key?.id,
19
+ id: rawMessage.id || rawMessage.key?.id || rawMessage.MessageSid,
20
20
  from: this.extractSender(rawMessage),
21
21
  timestamp: rawMessage.timestamp || Date.now(),
22
22
  raw: rawMessage
@@ -24,20 +24,34 @@ class MessageParser {
24
24
 
25
25
  // Check for interactive messages (buttons, lists, flows)
26
26
  if (this.isInteractiveMessage(rawMessage)) {
27
+ const interactive = this.parseInteractive(rawMessage);
28
+ const interactionText = this.extractInteractionText(interactive);
29
+
27
30
  return {
28
31
  ...messageData,
32
+ body: interactionText,
29
33
  type: 'interactive',
30
- interactive: this.parseInteractive(rawMessage)
34
+ interactive: interactive,
35
+ isInteractive: true, // Flag to indicate this is an interactive message
36
+ interactionType: interactive.type // Store the specific interaction type
31
37
  };
32
38
  }
33
39
 
34
40
  // Check for media messages
35
41
  if (this.isMediaMessage(rawMessage)) {
36
- return {
42
+ const mediaResult = {
37
43
  ...messageData,
38
44
  type: 'media',
39
45
  media: this.parseMedia(rawMessage)
40
46
  };
47
+
48
+ // Check if media message also has text content (caption)
49
+ const textContent = this.extractTextContent(rawMessage);
50
+ if (textContent) {
51
+ mediaResult.body = textContent;
52
+ }
53
+
54
+ return mediaResult;
41
55
  }
42
56
 
43
57
  // Parse text content
@@ -46,7 +60,7 @@ class MessageParser {
46
60
  return { ...messageData, type: 'unknown' };
47
61
  }
48
62
 
49
- messageData.message = textContent;
63
+ messageData.body = textContent;
50
64
 
51
65
  // Check for commands
52
66
  if (this.isCommand(textContent)) {
@@ -117,12 +131,19 @@ class MessageParser {
117
131
  }
118
132
 
119
133
  isInteractiveMessage(rawMessage) {
120
- // Twilio interactive messages
121
- return !!(rawMessage.ButtonPayload || rawMessage.ListId || rawMessage.FlowData || rawMessage.InteractiveData);
134
+ // Twilio interactive messages - check for any interactive indicators
135
+ return !!(
136
+ rawMessage.ButtonPayload ||
137
+ rawMessage.ListId ||
138
+ rawMessage.FlowData ||
139
+ rawMessage.InteractiveData ||
140
+ rawMessage.ButtonText
141
+ );
122
142
  }
123
143
 
124
144
  parseInteractive(rawMessage) {
125
- if (rawMessage.ButtonPayload) {
145
+ if (rawMessage.ButtonPayload !== undefined && rawMessage.ButtonPayload !== null && rawMessage.ButtonPayload.trim() !== '') {
146
+ // Non-empty payload = regular button
126
147
  return {
127
148
  type: 'button',
128
149
  payload: rawMessage.ButtonPayload,
@@ -130,6 +151,14 @@ class MessageParser {
130
151
  };
131
152
  }
132
153
 
154
+ if (rawMessage.ButtonText) {
155
+ return {
156
+ type: 'quick_reply',
157
+ title: rawMessage.ButtonText,
158
+ payload: rawMessage.ButtonText
159
+ };
160
+ }
161
+
133
162
  if (rawMessage.ListId) {
134
163
  return {
135
164
  type: 'list',
@@ -149,6 +178,33 @@ class MessageParser {
149
178
  return null;
150
179
  }
151
180
 
181
+ /**
182
+ * Extract interaction text for body field from interactive message
183
+ * @param {Object} interactive - Parsed interactive data
184
+ * @returns {string} Human-readable interaction text
185
+ */
186
+ extractInteractionText(interactive) {
187
+ if (!interactive) return '';
188
+
189
+ switch (interactive.type) {
190
+ case 'button':
191
+ return interactive.title || interactive.payload || '[Button clicked]';
192
+
193
+ case 'quick_reply':
194
+ return interactive.title || interactive.payload || '[Quick reply]';
195
+
196
+ case 'list':
197
+ return interactive.title || interactive.description || '[List item selected]';
198
+
199
+ case 'flow':
200
+ // Flows contain complex JSON data that doesn't need assistant processing
201
+ return '';
202
+
203
+ default:
204
+ return '[Interactive message]';
205
+ }
206
+ }
207
+
152
208
  isMediaMessage(rawMessage) {
153
209
  // Twilio format
154
210
  if (rawMessage.NumMedia && parseInt(rawMessage.NumMedia) > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",