@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.
- package/lib/adapters/BaileysProvider.js +22 -3
- package/lib/adapters/TwilioProvider.js +166 -5
- package/lib/core/MessageProvider.js +17 -0
- package/lib/core/NexusMessaging.js +123 -5
- package/lib/helpers/twilioMediaProcessor.js +149 -0
- package/lib/storage/MongoStorage.js +73 -1
- package/lib/utils/mediaValidator.js +94 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|