@peopl-health/nexus 1.5.4 → 1.5.6

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.
@@ -148,17 +148,36 @@ class BaileysProvider extends MessageProvider {
148
148
  }
149
149
 
150
150
  async sendScheduledMessage(scheduledMessage) {
151
- const { sendTime, timeZone } = scheduledMessage;
151
+ const { sendTime, timeZone, __nexusSend } = scheduledMessage;
152
152
  const delay = this.calculateDelay(sendTime, timeZone);
153
-
153
+
154
+ const sender = typeof __nexusSend === 'function'
155
+ ? __nexusSend
156
+ : async (payload) => await this.sendMessage(payload);
157
+
158
+ console.log('[BaileysProvider] Scheduled message created', {
159
+ to: scheduledMessage.to || scheduledMessage.code,
160
+ delay,
161
+ hasMedia: Boolean(scheduledMessage.fileUrl)
162
+ });
163
+
154
164
  setTimeout(async () => {
155
165
  try {
156
- await this.sendMessage(scheduledMessage);
166
+ const payload = { ...scheduledMessage };
167
+ delete payload.__nexusSend;
168
+ console.log('[BaileysProvider] Timer fired', {
169
+ to: payload.to || payload.code,
170
+ hasMessage: Boolean(payload.message || payload.body),
171
+ hasMedia: Boolean(payload.fileUrl)
172
+ });
173
+ await sender(payload);
157
174
  console.log('Scheduled message sent successfully');
158
175
  } catch (error) {
159
176
  console.error(`Scheduled message failed: ${error.message}`);
160
177
  }
161
178
  }, delay);
179
+
180
+ return { scheduled: true, delay };
162
181
  }
163
182
 
164
183
  calculateDelay(sendTime) {
@@ -1,5 +1,10 @@
1
1
  const { MessageProvider } = require('../core/MessageProvider');
2
2
  const axios = require('axios');
3
+ const runtimeConfig = require('../config/runtimeConfig');
4
+ const { uploadMediaToS3, sanitizeFileName, getFileExtension } = require('../helpers/mediaHelper');
5
+ const { generatePresignedUrl } = require('../config/awsConfig');
6
+ const { validateMedia, getMediaType } = require('../utils/mediaValidator');
7
+ const { v4: uuidv4 } = require('uuid');
3
8
 
4
9
  /**
5
10
  * Twilio WhatsApp messaging provider
@@ -40,7 +45,7 @@ class TwilioProvider extends MessageProvider {
40
45
  }
41
46
 
42
47
  const { to, message, fileUrl, fileType, variables, contentSid } = messageData;
43
-
48
+
44
49
  const formattedFrom = this.ensureWhatsAppFormat(this.whatsappNumber);
45
50
  const formattedTo = this.ensureWhatsAppFormat(to);
46
51
 
@@ -71,10 +76,19 @@ class TwilioProvider extends MessageProvider {
71
76
 
72
77
  // Handle media messages
73
78
  if (fileUrl && fileType !== 'text') {
74
- messageParams.mediaUrl = [fileUrl];
79
+ const mediaPrep = await this.prepareOutboundMedia(messageData, formattedTo);
80
+ const outboundMediaUrl = mediaPrep.mediaUrl || fileUrl;
81
+ messageParams.mediaUrl = [outboundMediaUrl];
75
82
  if (!messageParams.body || messageParams.body.trim() === '') {
76
83
  delete messageParams.body;
77
84
  }
85
+ if (mediaPrep.uploaded) {
86
+ console.log('[TwilioProvider] Outbound media uploaded to S3', {
87
+ to: formattedTo,
88
+ bucket: mediaPrep.bucketName,
89
+ key: mediaPrep.key
90
+ });
91
+ }
78
92
  }
79
93
 
80
94
  // Validate message has content
@@ -84,6 +98,28 @@ class TwilioProvider extends MessageProvider {
84
98
 
85
99
  try {
86
100
  const result = await this.twilioClient.messages.create(messageParams);
101
+ if (this.messageStorage && typeof this.messageStorage.saveMessage === 'function') {
102
+ try {
103
+ console.log('[TwilioProvider] Persisting outbound message', {
104
+ to: formattedTo,
105
+ from: formattedFrom,
106
+ hasMedia: Boolean(messageParams.mediaUrl && messageParams.mediaUrl.length),
107
+ hasTemplate: Boolean(messageParams.contentSid)
108
+ });
109
+ await this.messageStorage.saveMessage({
110
+ ...messageData,
111
+ to: formattedTo,
112
+ from: formattedFrom,
113
+ messageId: result.sid,
114
+ provider: 'twilio',
115
+ timestamp: new Date(),
116
+ fromMe: true
117
+ });
118
+ console.log('[TwilioProvider] Message persisted successfully', { messageId: result.sid });
119
+ } catch (storageError) {
120
+ console.error('TwilioProvider storage failed:', storageError);
121
+ }
122
+ }
87
123
  return {
88
124
  success: true,
89
125
  messageId: result.sid,
@@ -96,17 +132,142 @@ class TwilioProvider extends MessageProvider {
96
132
  }
97
133
 
98
134
  async sendScheduledMessage(scheduledMessage) {
99
- const { sendTime, timeZone } = scheduledMessage;
135
+ const { sendTime, timeZone, __nexusSend } = scheduledMessage;
100
136
  const delay = this.calculateDelay(sendTime, timeZone);
101
-
137
+
138
+ const sender = typeof __nexusSend === 'function'
139
+ ? __nexusSend
140
+ : async (payload) => await this.sendMessage(payload);
141
+
142
+ console.log('[TwilioProvider] Scheduled message created', {
143
+ to: scheduledMessage.to || scheduledMessage.code,
144
+ delay,
145
+ hasContentSid: Boolean(scheduledMessage.contentSid)
146
+ });
147
+
102
148
  setTimeout(async () => {
103
149
  try {
104
- await this.sendMessage(scheduledMessage);
150
+ const payload = { ...scheduledMessage };
151
+ delete payload.__nexusSend;
152
+ console.log('[TwilioProvider] Timer fired', {
153
+ to: payload.to || payload.code,
154
+ hasMessage: Boolean(payload.message || payload.body),
155
+ hasMedia: Boolean(payload.fileUrl)
156
+ });
157
+ await sender(payload);
105
158
  console.log('Scheduled message sent successfully');
106
159
  } catch (error) {
107
160
  console.error(`Scheduled message failed: ${error.message}`);
108
161
  }
109
162
  }, delay);
163
+
164
+ return { scheduled: true, delay };
165
+ }
166
+
167
+ supportsMessageStorage() {
168
+ return true;
169
+ }
170
+
171
+ async prepareOutboundMedia(messageData, formattedTo) {
172
+ const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME') || process.env.AWS_S3_BUCKET_NAME;
173
+ const fileUrl = messageData.fileUrl;
174
+
175
+ if (!fileUrl) {
176
+ return { mediaUrl: null, uploaded: false };
177
+ }
178
+
179
+ if (!bucketName) {
180
+ console.warn('[TwilioProvider] AWS_S3_BUCKET_NAME not configured. Skipping media upload for outbound message.');
181
+ return { mediaUrl: fileUrl, uploaded: false };
182
+ }
183
+
184
+ // Reuse existing uploaded media if present
185
+ if (messageData.media?.bucketName && messageData.media?.key) {
186
+ const presigned = await generatePresignedUrl(messageData.media.bucketName, messageData.media.key, 3600);
187
+ return {
188
+ mediaUrl: presigned,
189
+ uploaded: false,
190
+ bucketName: messageData.media.bucketName,
191
+ key: messageData.media.key
192
+ };
193
+ }
194
+
195
+ try {
196
+ const response = await axios.get(fileUrl, { responseType: 'arraybuffer' });
197
+ const buffer = Buffer.from(response.data);
198
+ let contentType = messageData.contentType || response.headers['content-type'];
199
+ const declaredType = typeof messageData.fileType === 'string' ? messageData.fileType : null;
200
+
201
+ if (!contentType) {
202
+ const fallbackType = declaredType ? {
203
+ image: 'image/jpeg',
204
+ video: 'video/mp4',
205
+ audio: 'audio/mpeg',
206
+ document: 'application/pdf'
207
+ }[declaredType] : null;
208
+ contentType = fallbackType || 'application/octet-stream';
209
+ }
210
+
211
+ const validation = validateMedia(buffer, contentType);
212
+ const mediaType = validation.valid ? validation.mediaType : (declaredType || getMediaType(contentType));
213
+
214
+ if (!validation.valid) {
215
+ console.warn('[TwilioProvider] Outbound media validation warning', {
216
+ to: formattedTo,
217
+ message: validation.message
218
+ });
219
+ }
220
+
221
+ const existingFileName = messageData.fileName || (() => {
222
+ try {
223
+ const parsed = new URL(fileUrl);
224
+ const segments = parsed.pathname.split('/').filter(Boolean);
225
+ return segments.length ? segments[segments.length - 1] : null;
226
+ } catch (err) {
227
+ return null;
228
+ }
229
+ })();
230
+
231
+ const baseName = existingFileName ? existingFileName.split('.').slice(0, -1).join('.') : `${mediaType}_${Date.now()}`;
232
+ const sanitizedBase = sanitizeFileName(baseName) || `${mediaType}_${Date.now()}`;
233
+ const extension = getFileExtension(contentType) || 'bin';
234
+ const uploadId = uuidv4();
235
+ const key = await uploadMediaToS3(buffer, uploadId, sanitizedBase, bucketName, contentType, mediaType);
236
+ const presignedUrl = await generatePresignedUrl(bucketName, key, 3600);
237
+ const s3Url = `https://${bucketName}.s3.amazonaws.com/${key}`;
238
+
239
+ messageData.media = {
240
+ contentType,
241
+ bucketName,
242
+ key,
243
+ mediaType,
244
+ fileName: existingFileName || `${sanitizedBase}.${extension}`,
245
+ fileSize: buffer.length,
246
+ caption: messageData.message || '',
247
+ metadata: {
248
+ originalUrl: fileUrl,
249
+ uploadedAt: new Date().toISOString()
250
+ }
251
+ };
252
+
253
+ messageData.fileUrl = s3Url;
254
+ messageData.fileType = mediaType;
255
+ messageData.contentType = contentType;
256
+
257
+ return {
258
+ mediaUrl: presignedUrl,
259
+ uploaded: true,
260
+ bucketName,
261
+ key
262
+ };
263
+ } catch (error) {
264
+ console.error('[TwilioProvider] Failed to upload outbound media to S3. Using original URL.', {
265
+ to: formattedTo,
266
+ error: error?.message || error
267
+ });
268
+
269
+ return { mediaUrl: fileUrl, uploaded: false };
270
+ }
110
271
  }
111
272
 
112
273
  calculateDelay(sendTime) {
@@ -5,6 +5,7 @@ class MessageProvider {
5
5
  constructor(config = {}) {
6
6
  this.config = config;
7
7
  this.isConnected = false;
8
+ this.messageStorage = null;
8
9
  }
9
10
 
10
11
  /**
@@ -66,6 +67,22 @@ class MessageProvider {
66
67
  async disconnect() {
67
68
  throw new Error('disconnect() must be implemented by provider');
68
69
  }
70
+
71
+ /**
72
+ * Inject message storage so providers can persist messages themselves
73
+ * @param {Object|null} storage
74
+ */
75
+ setMessageStorage(storage) {
76
+ this.messageStorage = storage;
77
+ }
78
+
79
+ /**
80
+ * Whether the provider performs its own message storage
81
+ * @returns {boolean}
82
+ */
83
+ supportsMessageStorage() {
84
+ return false;
85
+ }
69
86
  }
70
87
 
71
88
  module.exports = { MessageProvider };
@@ -1,6 +1,7 @@
1
1
  const { airtable, getBase } = require('../config/airtableConfig');
2
2
  const { replyAssistant } = require('../services/assistantService');
3
3
  const { createProvider } = require('../adapters/registry');
4
+ const runtimeConfig = require('../config/runtimeConfig');
4
5
 
5
6
  const mongoose = require('mongoose');
6
7
  const OpenAI = require('openai');
@@ -187,6 +188,9 @@ class NexusMessaging {
187
188
  */
188
189
  async initializeProvider(providerType, providerConfig) {
189
190
  this.provider = createProvider(providerType, providerConfig);
191
+ if (this.messageStorage && typeof this.provider?.setMessageStorage === 'function') {
192
+ this.provider.setMessageStorage(this.messageStorage);
193
+ }
190
194
  await this.provider.initialize();
191
195
  }
192
196
 
@@ -196,6 +200,9 @@ class NexusMessaging {
196
200
  */
197
201
  setMessageStorage(storage) {
198
202
  this.messageStorage = storage;
203
+ if (this.provider && typeof this.provider.setMessageStorage === 'function') {
204
+ this.provider.setMessageStorage(storage);
205
+ }
199
206
  }
200
207
 
201
208
  /**
@@ -270,9 +277,13 @@ class NexusMessaging {
270
277
  }
271
278
 
272
279
  const result = await this.provider.sendMessage(normalized);
273
-
274
- // Store message if storage is configured
275
- if (this.messageStorage) {
280
+
281
+ // Store message only if provider does not handle persistence itself
282
+ const providerStoresMessage = typeof this.provider.supportsMessageStorage === 'function'
283
+ ? this.provider.supportsMessageStorage()
284
+ : false;
285
+
286
+ if (this.messageStorage && !providerStoresMessage) {
276
287
  await this.messageStorage.saveMessage({
277
288
  ...normalized,
278
289
  messageId: result.messageId,
@@ -364,7 +375,12 @@ class NexusMessaging {
364
375
  typeof messageData.message?.conversation === 'string' ? messageData.message.conversation : null,
365
376
  typeof messageData.Body === 'string' ? messageData.Body : null,
366
377
  typeof messageData.raw?.message?.conversation === 'string' ? messageData.raw.message.conversation : null,
367
- typeof messageData.raw?.Body === 'string' ? messageData.raw.Body : null
378
+ typeof messageData.raw?.Body === 'string' ? messageData.raw.Body : null,
379
+ typeof messageData.caption === 'string' ? messageData.caption : null,
380
+ typeof messageData.media?.caption === 'string' ? messageData.media.caption : null,
381
+ Array.isArray(messageData.media) && typeof messageData.media[0]?.caption === 'string'
382
+ ? messageData.media[0].caption
383
+ : null
368
384
  ].find((value) => typeof value === 'string' && value.trim().length > 0) || null;
369
385
 
370
386
  return { from, message };
@@ -406,7 +422,109 @@ class NexusMessaging {
406
422
  }
407
423
 
408
424
  async handleMedia(messageData) {
409
- return await this._handleWithPipeline('media', 'onMedia', messageData);
425
+ await this._ensureMediaPersistence(messageData);
426
+
427
+ return await this._handleWithPipeline('media', 'onMedia', messageData, async (ctx) => {
428
+ return await this.handleMediaWithAssistant(ctx);
429
+ });
430
+ }
431
+
432
+ async handleMediaWithAssistant(messageData) {
433
+ try {
434
+ const { from, message } = this._extractAssistantInputs(messageData);
435
+
436
+ if (!from) {
437
+ console.warn('Unable to resolve sender for media message, skipping automatic reply.');
438
+ return;
439
+ }
440
+
441
+ const mediaDescriptor = (() => {
442
+ const media = Array.isArray(messageData.media) ? messageData.media[0] : messageData.media;
443
+ if (!media) return null;
444
+ if (typeof media.mediaType === 'string') return media.mediaType;
445
+ if (typeof media.type === 'string') return media.type;
446
+ if (typeof media.contentType === 'string') return media.contentType;
447
+ return null;
448
+ })();
449
+
450
+ const fallbackMessage = message && message.trim().length > 0
451
+ ? message
452
+ : `Media received (${mediaDescriptor || 'attachment'})`;
453
+
454
+ const response = await replyAssistant(from, fallbackMessage);
455
+
456
+ if (response) {
457
+ await this.sendMessage({
458
+ to: from,
459
+ message: response
460
+ });
461
+ }
462
+ } catch (error) {
463
+ console.error('Error in handleMediaWithAssistant:', error);
464
+ }
465
+ }
466
+
467
+ async _ensureMediaPersistence(messageData) {
468
+ try {
469
+ const raw = messageData?.raw;
470
+ if (!raw || raw.__nexusMediaProcessed) return;
471
+
472
+ const numMedia = parseInt(raw.NumMedia || '0', 10);
473
+ if (!numMedia || numMedia <= 0 || !raw.MediaUrl0) return;
474
+
475
+ const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME') || process.env.AWS_S3_BUCKET_NAME;
476
+ if (!bucketName) {
477
+ console.warn('[NexusMessaging] AWS_S3_BUCKET_NAME not configured. Skipping media persistence.');
478
+ return;
479
+ }
480
+
481
+ const { processTwilioMediaMessage } = require('../helpers/twilioMediaProcessor');
482
+ const { logger } = require('../utils/logger');
483
+ const mediaItems = await processTwilioMediaMessage(raw, logger, bucketName);
484
+
485
+ if (!mediaItems || mediaItems.length === 0) {
486
+ console.warn('[NexusMessaging] Media processing returned no items for incoming message.');
487
+ return;
488
+ }
489
+
490
+ raw.__nexusMediaProcessed = true;
491
+
492
+ const [primary, ...rest] = mediaItems;
493
+ const mediaPayload = rest.length > 0
494
+ ? { ...primary, metadata: { ...(primary.metadata || {}), attachments: rest } }
495
+ : primary;
496
+
497
+ messageData.media = messageData.media || mediaPayload;
498
+ messageData.fileUrl = messageData.fileUrl || mediaPayload.metadata?.presignedUrl || null;
499
+ messageData.fileType = messageData.fileType || mediaPayload.mediaType;
500
+ messageData.caption = messageData.caption || primary.caption;
501
+ if (!messageData.message && messageData.caption) {
502
+ messageData.message = messageData.caption;
503
+ }
504
+ messageData.isMedia = true;
505
+
506
+ if (!this.messageStorage) {
507
+ const { convertTwilioToInternalFormat } = require('../helpers/twilioHelper');
508
+ const { getMessageValues, insertMessage } = require('../models/messageModel');
509
+
510
+ const messageObj = convertTwilioToInternalFormat(raw);
511
+ const values = getMessageValues(
512
+ messageObj,
513
+ messageData.message || messageData.caption || '',
514
+ null,
515
+ true
516
+ );
517
+ values.media = mediaPayload;
518
+
519
+ await insertMessage(values);
520
+ console.log('[NexusMessaging] Media message stored via legacy inserter', {
521
+ messageId: values.message_id,
522
+ numero: values.numero
523
+ });
524
+ }
525
+ } catch (error) {
526
+ console.error('[NexusMessaging] Failed to ensure media persistence:', error);
527
+ }
410
528
  }
411
529
 
412
530
  async handleCommand(messageData) {
@@ -0,0 +1,149 @@
1
+ const { uploadMediaToS3 } = require('./mediaHelper');
2
+ const {
3
+ convertTwilioToInternalFormat,
4
+ downloadMediaFromTwilio,
5
+ getMediaTypeFromContentType,
6
+ extractTitle
7
+ } = require('./twilioHelper');
8
+ const { validateMedia } = require('../utils/mediaValidator');
9
+ const { generatePresignedUrl } = require('../config/awsConfig');
10
+ const { addRecord, getRecordByFilter } = require('../services/airtableService');
11
+ const { Monitoreo_ID } = require('../config/airtableConfig');
12
+
13
+ const ensureLogger = (logger) => {
14
+ if (!logger) return console;
15
+ const fallback = {};
16
+ for (const level of ['info', 'warn', 'error', 'debug']) {
17
+ fallback[level] = typeof logger[level] === 'function' ? logger[level].bind(logger) : console[level].bind(console);
18
+ }
19
+ return fallback;
20
+ };
21
+
22
+ const normalizeMediaType = (type) => {
23
+ if (!type || typeof type !== 'string') return 'document';
24
+ return type.replace(/Message$/i, '').replace(/WithCaption$/i, '').toLowerCase();
25
+ };
26
+
27
+ async function processTwilioMediaMessage(twilioMessage, logger, bucketName) {
28
+ if (!twilioMessage) return [];
29
+
30
+ const log = ensureLogger(logger);
31
+ const numMedia = parseInt(twilioMessage.NumMedia || '0', 10);
32
+ if (!numMedia || numMedia <= 0) {
33
+ log.debug && log.debug('[TwilioMedia] No media detected in message');
34
+ return [];
35
+ }
36
+
37
+ if (!bucketName) {
38
+ log.warn && log.warn('[TwilioMedia] AWS bucket not configured; skipping media processing');
39
+ return [];
40
+ }
41
+
42
+ const mediaItems = [];
43
+ const code = twilioMessage.From;
44
+ const caption = twilioMessage.Body || '';
45
+ const messageObj = convertTwilioToInternalFormat(twilioMessage);
46
+ const messageId = messageObj?.key?.id || twilioMessage.MessageSid;
47
+
48
+ for (let i = 0; i < numMedia; i++) {
49
+ const mediaUrl = twilioMessage[`MediaUrl${i}`];
50
+ const contentType = twilioMessage[`MediaContentType${i}`];
51
+
52
+ if (!mediaUrl || !contentType) {
53
+ log.warn && log.warn('[TwilioMedia] Missing media URL or content type', { index: i });
54
+ continue;
55
+ }
56
+
57
+ log.info && log.info('[TwilioMedia] Processing media item', { index: i, contentType, mediaUrl });
58
+
59
+ let mediaBuffer;
60
+ try {
61
+ mediaBuffer = await downloadMediaFromTwilio(mediaUrl, log);
62
+ } catch (error) {
63
+ log.error && log.error('[TwilioMedia] Failed to download media', { index: i, error: error?.message || error });
64
+ continue;
65
+ }
66
+
67
+ const validationResult = validateMedia(mediaBuffer, contentType);
68
+ if (!validationResult.valid) {
69
+ log.warn && log.warn('[TwilioMedia] Media validation warning', { index: i, message: validationResult.message });
70
+ } else {
71
+ log.debug && log.debug('[TwilioMedia] Media validation passed', { index: i, message: validationResult.message });
72
+ }
73
+
74
+ const helperMediaType = getMediaTypeFromContentType(contentType);
75
+ const derivedMediaType = validationResult.valid
76
+ ? validationResult.mediaType
77
+ : normalizeMediaType(helperMediaType);
78
+ const titleFile = (extractTitle(twilioMessage, helperMediaType) || '').trim();
79
+ const sanitizedTitle = titleFile.replace(/\s+/g, '');
80
+ const fallbackName = `${derivedMediaType}_${Date.now()}`;
81
+ const fileName = sanitizedTitle || fallbackName;
82
+
83
+ let key;
84
+ try {
85
+ key = await uploadMediaToS3(
86
+ mediaBuffer,
87
+ messageId,
88
+ fileName,
89
+ bucketName,
90
+ contentType,
91
+ derivedMediaType
92
+ );
93
+ } catch (error) {
94
+ log.error && log.error('[TwilioMedia] Failed to upload media to S3', { index: i, error: error?.message || error });
95
+ continue;
96
+ }
97
+
98
+ const baseMetadata = {
99
+ originalUrl: mediaUrl,
100
+ receivedAt: new Date().toISOString(),
101
+ validation: validationResult.message
102
+ };
103
+
104
+ const item = {
105
+ contentType,
106
+ bucketName,
107
+ key,
108
+ mediaType: derivedMediaType,
109
+ fileName: titleFile || fallbackName,
110
+ fileSize: mediaBuffer.length,
111
+ caption,
112
+ thumbnail: null,
113
+ metadata: baseMetadata
114
+ };
115
+
116
+ try {
117
+ const url = await generatePresignedUrl(bucketName, key);
118
+ item.metadata.presignedUrl = url;
119
+
120
+ if (Monitoreo_ID) {
121
+ const patient = await getRecordByFilter(Monitoreo_ID, 'estado_general', `whatsapp_id = "${code}"`);
122
+ const patientId = Array.isArray(patient) && patient.length > 0 ? [patient[0].recordID || patient[0].recordId || patient[0].record_id] : [];
123
+ await addRecord(Monitoreo_ID, 'estudios', [{
124
+ fields: {
125
+ estudios: [{ url }],
126
+ combined_estudios: [{ url }],
127
+ patient_id: patientId
128
+ }
129
+ }]);
130
+ }
131
+ } catch (error) {
132
+ log.warn && log.warn('[TwilioMedia] Failed to update Airtable with media reference', { index: i, error: error?.message || error });
133
+ }
134
+
135
+ mediaItems.push(item);
136
+ }
137
+
138
+ console.log('[TwilioMedia] Completed processing', {
139
+ from: code,
140
+ itemsStored: mediaItems.length,
141
+ bucket: bucketName
142
+ });
143
+
144
+ return mediaItems;
145
+ }
146
+
147
+ module.exports = {
148
+ processTwilioMediaMessage
149
+ };
@@ -61,9 +61,23 @@ class MongoStorage {
61
61
 
62
62
  async saveMessage(messageData) {
63
63
  try {
64
- const values = this.buildLegacyMessageValues(messageData);
64
+ console.log('[MongoStorage] saveMessage called', {
65
+ to: messageData?.to || messageData?.code || messageData?.numero,
66
+ from: messageData?.from,
67
+ provider: messageData?.provider || 'unknown',
68
+ hasRaw: Boolean(messageData?.raw),
69
+ hasMedia: Boolean(messageData?.media || messageData?.fileUrl)
70
+ });
71
+ const enrichedMessage = await this._enrichTwilioMedia(messageData);
72
+ const values = this.buildLegacyMessageValues(enrichedMessage);
65
73
  const { insertMessage } = require('../models/messageModel');
66
74
  await insertMessage(values);
75
+ console.log('[MongoStorage] Message stored', {
76
+ messageId: values.message_id,
77
+ numero: values.numero,
78
+ isMedia: values.is_media,
79
+ hasMediaPayload: Boolean(values.media)
80
+ });
67
81
  return values;
68
82
  } catch (error) {
69
83
  console.error('Error saving message:', error);
@@ -71,6 +85,64 @@ class MongoStorage {
71
85
  }
72
86
  }
73
87
 
88
+ async _enrichTwilioMedia(messageData = {}) {
89
+ try {
90
+ const rawMessage = messageData?.raw;
91
+ if (!rawMessage || !rawMessage.From) return messageData;
92
+
93
+ const numMedia = parseInt(rawMessage.NumMedia || '0', 10);
94
+ if (!numMedia || numMedia <= 0 || !rawMessage.MediaUrl0) {
95
+ return messageData;
96
+ }
97
+
98
+ console.log('[MongoStorage] Detected Twilio media message', {
99
+ from: rawMessage.From,
100
+ numMedia
101
+ });
102
+
103
+ const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME') || process.env.AWS_S3_BUCKET_NAME;
104
+ if (!bucketName) {
105
+ console.warn('[MongoStorage] AWS_S3_BUCKET_NAME not configured. Skipping media upload.');
106
+ return messageData;
107
+ }
108
+
109
+ const { processTwilioMediaMessage } = require('../helpers/twilioMediaProcessor');
110
+ const { logger } = require('../utils/logger');
111
+
112
+ const mediaItems = await processTwilioMediaMessage(rawMessage, logger, bucketName);
113
+ if (!mediaItems || mediaItems.length === 0) {
114
+ console.warn('[MongoStorage] Media processing returned no items');
115
+ return messageData;
116
+ }
117
+
118
+ const [primary, ...rest] = mediaItems;
119
+ const mediaPayload = rest.length > 0
120
+ ? { ...primary, metadata: { ...(primary.metadata || {}), attachments: rest } }
121
+ : primary;
122
+
123
+ rawMessage.__nexusMediaProcessed = true;
124
+
125
+ console.log('[MongoStorage] Media processed successfully', {
126
+ primaryType: mediaPayload.mediaType,
127
+ mediaCount: mediaItems.length,
128
+ s3Key: mediaPayload.key
129
+ });
130
+
131
+ return {
132
+ ...messageData,
133
+ media: mediaPayload,
134
+ fileUrl: undefined,
135
+ fileType: mediaPayload.mediaType || messageData.fileType,
136
+ isMedia: true,
137
+ message: messageData.message || rawMessage.Body || primary.caption || '',
138
+ caption: primary.caption || messageData.caption
139
+ };
140
+ } catch (error) {
141
+ console.error('[MongoStorage] Failed to enrich Twilio media message:', error);
142
+ return messageData;
143
+ }
144
+ }
145
+
74
146
  normalizeNumero(numero) {
75
147
  if (!numero || typeof numero !== 'string') return numero;
76
148
 
@@ -0,0 +1,94 @@
1
+ const MEDIA_LIMITS = {
2
+ image: 5 * 1024 * 1024, // 5MB
3
+ video: 16 * 1024 * 1024, // 16MB
4
+ audio: 16 * 1024 * 1024, // 16MB
5
+ document: 100 * 1024 * 1024, // 100MB (PDFs, etc.)
6
+ sticker: 500 * 1024 // 500KB
7
+ };
8
+
9
+ const ALLOWED_FORMATS = {
10
+ image: ['image/jpeg', 'image/png', 'image/webp'],
11
+ video: ['video/mp4', 'video/3gpp'],
12
+ audio: ['audio/mp4', 'audio/mpeg', 'audio/ogg', 'audio/amr'],
13
+ document: [
14
+ 'application/pdf',
15
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
16
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
17
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
18
+ 'text/plain',
19
+ 'application/zip'
20
+ ],
21
+ sticker: ['image/webp']
22
+ };
23
+
24
+ function validateMediaSize(media, mediaType) {
25
+ const fileSize = Buffer.isBuffer(media) ? media.length : media;
26
+ const maxSize = MEDIA_LIMITS[mediaType] || MEDIA_LIMITS.document;
27
+
28
+ if (fileSize > maxSize) {
29
+ const maxSizeMB = maxSize / (1024 * 1024);
30
+ return {
31
+ valid: false,
32
+ message: `${mediaType} exceeds WhatsApp limit of ${maxSizeMB}MB`
33
+ };
34
+ }
35
+
36
+ return {
37
+ valid: true,
38
+ message: `${mediaType} size is valid (${(fileSize / (1024 * 1024)).toFixed(2)}MB)`
39
+ };
40
+ }
41
+
42
+ function validateMediaFormat(contentType, mediaType) {
43
+ const allowedTypes = ALLOWED_FORMATS[mediaType] || [];
44
+
45
+ if (allowedTypes.length === 0 || !allowedTypes.includes(contentType)) {
46
+ return {
47
+ valid: false,
48
+ message: `Content type ${contentType} is not supported for ${mediaType}`
49
+ };
50
+ }
51
+
52
+ return {
53
+ valid: true,
54
+ message: `${contentType} is a valid format for ${mediaType}`
55
+ };
56
+ }
57
+
58
+ function getMediaType(contentType) {
59
+ for (const [type, formats] of Object.entries(ALLOWED_FORMATS)) {
60
+ if (formats.includes(contentType)) {
61
+ return type;
62
+ }
63
+ }
64
+ return 'document';
65
+ }
66
+
67
+ function validateMedia(media, contentType) {
68
+ const mediaType = getMediaType(contentType);
69
+ const formatValidation = validateMediaFormat(contentType, mediaType);
70
+
71
+ if (!formatValidation.valid) {
72
+ return formatValidation;
73
+ }
74
+
75
+ const sizeValidation = validateMediaSize(media, mediaType);
76
+ if (!sizeValidation.valid) {
77
+ return sizeValidation;
78
+ }
79
+
80
+ return {
81
+ valid: true,
82
+ mediaType,
83
+ message: `Media validated successfully as ${mediaType}`
84
+ };
85
+ }
86
+
87
+ module.exports = {
88
+ validateMedia,
89
+ validateMediaSize,
90
+ validateMediaFormat,
91
+ getMediaType,
92
+ MEDIA_LIMITS,
93
+ ALLOWED_FORMATS
94
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "publishConfig": {
6
6
  "access": "public"