@peopl-health/nexus 1.7.6 → 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
@@ -314,7 +314,7 @@ class TwilioProvider extends MessageProvider {
314
314
  mediaType,
315
315
  fileName: existingFileName || `${sanitizedBase}.${extension}`,
316
316
  fileSize: buffer.length,
317
- caption: messageData.message || '',
317
+ caption: messageData.body || '',
318
318
  metadata: {
319
319
  originalUrl: fileUrl,
320
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,24 @@ 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,
419
424
  processed: true
420
425
  });
421
426
  }
@@ -428,7 +433,24 @@ class NexusMessaging {
428
433
  if (this.messageStorage) {
429
434
  await this.messageStorage.saveInteractive(messageData);
430
435
  }
431
- 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;
432
454
  }
433
455
 
434
456
  async handleMedia(messageData) {
@@ -441,7 +463,7 @@ class NexusMessaging {
441
463
 
442
464
  async handleMediaWithAssistant(messageData) {
443
465
  try {
444
- const { from, message } = this._extractAssistantInputs(messageData);
466
+ const { from, body } = this._extractAssistantInputs(messageData);
445
467
 
446
468
  if (!from) {
447
469
  console.warn('Unable to resolve sender for media message, skipping automatic reply.');
@@ -457,8 +479,8 @@ class NexusMessaging {
457
479
  return null;
458
480
  })();
459
481
 
460
- const fallbackMessage = message && message.trim().length > 0
461
- ? message
482
+ const fallbackMessage = body && body.trim().length > 0
483
+ ? body
462
484
  : `Media received (${mediaDescriptor || 'attachment'})`;
463
485
 
464
486
  const response = await replyAssistant(from, fallbackMessage);
@@ -466,7 +488,7 @@ class NexusMessaging {
466
488
  if (response) {
467
489
  await this.sendMessage({
468
490
  code: from,
469
- message: response
491
+ body: response
470
492
  });
471
493
  }
472
494
  } catch (error) {
@@ -508,8 +530,8 @@ class NexusMessaging {
508
530
  messageData.fileUrl = messageData.fileUrl || mediaPayload.metadata?.presignedUrl || null;
509
531
  messageData.fileType = messageData.fileType || mediaPayload.mediaType;
510
532
  messageData.caption = messageData.caption || primary.caption;
511
- if (!messageData.message && messageData.caption) {
512
- messageData.message = messageData.caption;
533
+ if (!messageData.body && messageData.caption) {
534
+ messageData.body = messageData.caption;
513
535
  }
514
536
  messageData.isMedia = true;
515
537
 
@@ -520,7 +542,7 @@ class NexusMessaging {
520
542
  const messageObj = convertTwilioToInternalFormat(raw);
521
543
  const values = getMessageValues(
522
544
  messageObj,
523
- messageData.message || messageData.caption || '',
545
+ messageData.body || messageData.caption || '',
524
546
  null,
525
547
  true
526
548
  );
@@ -605,7 +627,7 @@ class NexusMessaging {
605
627
  if (botResponse && this.provider) {
606
628
  await this.provider.sendMessage({
607
629
  to: chatId,
608
- message: botResponse,
630
+ body: botResponse,
609
631
  type: 'text',
610
632
  processed: true
611
633
  });
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
  });
@@ -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.6",
3
+ "version": "1.7.7",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",