@peopl-health/nexus 1.5.4 → 1.5.5
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 +13 -3
- package/lib/helpers/twilioMediaProcessor.js +149 -0
- package/lib/storage/MongoStorage.js +71 -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 };
|
|
@@ -187,6 +187,9 @@ class NexusMessaging {
|
|
|
187
187
|
*/
|
|
188
188
|
async initializeProvider(providerType, providerConfig) {
|
|
189
189
|
this.provider = createProvider(providerType, providerConfig);
|
|
190
|
+
if (this.messageStorage && typeof this.provider?.setMessageStorage === 'function') {
|
|
191
|
+
this.provider.setMessageStorage(this.messageStorage);
|
|
192
|
+
}
|
|
190
193
|
await this.provider.initialize();
|
|
191
194
|
}
|
|
192
195
|
|
|
@@ -196,6 +199,9 @@ class NexusMessaging {
|
|
|
196
199
|
*/
|
|
197
200
|
setMessageStorage(storage) {
|
|
198
201
|
this.messageStorage = storage;
|
|
202
|
+
if (this.provider && typeof this.provider.setMessageStorage === 'function') {
|
|
203
|
+
this.provider.setMessageStorage(storage);
|
|
204
|
+
}
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
/**
|
|
@@ -270,9 +276,13 @@ class NexusMessaging {
|
|
|
270
276
|
}
|
|
271
277
|
|
|
272
278
|
const result = await this.provider.sendMessage(normalized);
|
|
273
|
-
|
|
274
|
-
// Store message if
|
|
275
|
-
|
|
279
|
+
|
|
280
|
+
// Store message only if provider does not handle persistence itself
|
|
281
|
+
const providerStoresMessage = typeof this.provider.supportsMessageStorage === 'function'
|
|
282
|
+
? this.provider.supportsMessageStorage()
|
|
283
|
+
: false;
|
|
284
|
+
|
|
285
|
+
if (this.messageStorage && !providerStoresMessage) {
|
|
276
286
|
await this.messageStorage.saveMessage({
|
|
277
287
|
...normalized,
|
|
278
288
|
messageId: result.messageId,
|
|
@@ -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,62 @@ 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
|
+
console.log('[MongoStorage] Media processed successfully', {
|
|
124
|
+
primaryType: mediaPayload.mediaType,
|
|
125
|
+
mediaCount: mediaItems.length,
|
|
126
|
+
s3Key: mediaPayload.key
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...messageData,
|
|
131
|
+
media: mediaPayload,
|
|
132
|
+
fileUrl: undefined,
|
|
133
|
+
fileType: mediaPayload.mediaType || messageData.fileType,
|
|
134
|
+
isMedia: true,
|
|
135
|
+
message: messageData.message || rawMessage.Body || primary.caption || '',
|
|
136
|
+
caption: primary.caption || messageData.caption
|
|
137
|
+
};
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('[MongoStorage] Failed to enrich Twilio media message:', error);
|
|
140
|
+
return messageData;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
74
144
|
normalizeNumero(numero) {
|
|
75
145
|
if (!numero || typeof numero !== 'string') return numero;
|
|
76
146
|
|
|
@@ -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
|
+
};
|