@peopl-health/nexus 6.3.2 → 6.3.3
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/TwilioProvider.js +16 -4
- package/lib/controllers/mediaController.js +2 -8
- package/lib/core/NexusMessaging.js +8 -5
- package/lib/helpers/mediaHelper.js +12 -2
- package/lib/jobs/ScheduledMessageJob.js +1 -7
- package/lib/services/officeAssistantService.js +3 -9
- package/lib/utils/mediaValidator.js +8 -0
- package/lib/utils/s3Utils.js +32 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
const { isBenchMode } = require('../utils/benchModeHelper');
|
|
11
11
|
|
|
12
12
|
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
13
|
+
const { templateMediaOf } = require('../helpers/mediaHelper');
|
|
13
14
|
const { recordDeliveryAttempt } = require('../helpers/deliveryAttemptHelper');
|
|
14
15
|
const { blockedSendResult, isOutboundAllowed, lastDigits } = require('../helpers/outboundAllowlistHelper');
|
|
15
16
|
|
|
@@ -301,6 +302,10 @@ class TwilioProvider extends MessageProvider {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
async renderTemplate(contentSid, variables) {
|
|
305
|
+
return (await this.renderTemplateContent(contentSid, variables))?.body ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async renderTemplateContent(contentSid, variables) {
|
|
304
309
|
try {
|
|
305
310
|
if (!contentSid) return null;
|
|
306
311
|
|
|
@@ -311,18 +316,21 @@ class TwilioProvider extends MessageProvider {
|
|
|
311
316
|
return null;
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
const [mediaUrl] = template.types['twilio/media']?.media || template.types['twilio/card']?.media || [];
|
|
320
|
+
const media = mediaUrl ? templateMediaOf(this._renderTemplateWithVariables(mediaUrl, variables)) : null;
|
|
321
|
+
|
|
314
322
|
let textContent = this._extractTextFromTemplate(template);
|
|
315
323
|
|
|
316
324
|
if (!textContent) {
|
|
317
325
|
logger.warn('[TwilioProvider] No text content found in template:', contentSid);
|
|
318
|
-
return null;
|
|
326
|
+
return { body: null, media };
|
|
319
327
|
}
|
|
320
328
|
|
|
321
329
|
if (variables && Object.keys(variables).length > 0) {
|
|
322
|
-
return this._renderTemplateWithVariables(textContent, variables);
|
|
330
|
+
return { body: this._renderTemplateWithVariables(textContent, variables), media };
|
|
323
331
|
}
|
|
324
332
|
|
|
325
|
-
return textContent.trim();
|
|
333
|
+
return { body: textContent.trim(), media };
|
|
326
334
|
} catch (error) {
|
|
327
335
|
logger.error('[TwilioProvider] Error rendering template:', error.message);
|
|
328
336
|
return null;
|
|
@@ -356,7 +364,11 @@ class TwilioProvider extends MessageProvider {
|
|
|
356
364
|
}
|
|
357
365
|
|
|
358
366
|
if (types['twilio/flows']) return types['twilio/flows'].body || '';
|
|
359
|
-
if (types['twilio/media']) return types['twilio/media'].
|
|
367
|
+
if (types['twilio/media']) return types['twilio/media'].body || '';
|
|
368
|
+
if (types['twilio/card']) {
|
|
369
|
+
const card = types['twilio/card'];
|
|
370
|
+
return [card.title, card.body, card.subtitle].filter(Boolean).join('\n\n');
|
|
371
|
+
}
|
|
360
372
|
|
|
361
373
|
const fallback = Object.values(types).find(t => t?.body);
|
|
362
374
|
return fallback?.body || '';
|
|
@@ -2,13 +2,7 @@ const { downloadFileFromS3 } = require('../config/awsConfig');
|
|
|
2
2
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
3
|
|
|
4
4
|
const { logger } = require('../utils/logger');
|
|
5
|
-
|
|
6
|
-
const MIME_MAP = {
|
|
7
|
-
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
|
|
8
|
-
pdf: 'application/pdf',
|
|
9
|
-
mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
|
|
10
|
-
mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
|
|
11
|
-
};
|
|
5
|
+
const { EXTENSION_MIME_TYPES } = require('../utils/mediaValidator');
|
|
12
6
|
|
|
13
7
|
const getMediaController = async (req, res) => {
|
|
14
8
|
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
@@ -48,7 +42,7 @@ const getMediaController = async (req, res) => {
|
|
|
48
42
|
let contentType = fileData.ContentType || 'application/octet-stream';
|
|
49
43
|
if (contentType === 'application/octet-stream') {
|
|
50
44
|
const ext = mediaKey.split('.').pop().toLowerCase();
|
|
51
|
-
contentType =
|
|
45
|
+
contentType = EXTENSION_MIME_TYPES[ext] || contentType;
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
@@ -187,8 +187,7 @@ class NexusMessaging {
|
|
|
187
187
|
|
|
188
188
|
this.scheduledMessageJob = new ScheduledMessageJob({
|
|
189
189
|
queueAdapter: this.queueAdapter,
|
|
190
|
-
sendMessage: this.sendMessage.bind(this)
|
|
191
|
-
requireProvider: () => this.getProvider()
|
|
190
|
+
sendMessage: this.sendMessage.bind(this)
|
|
192
191
|
});
|
|
193
192
|
|
|
194
193
|
this.templateApprovalJob = new TemplateApprovalJob({
|
|
@@ -433,9 +432,13 @@ class NexusMessaging {
|
|
|
433
432
|
|
|
434
433
|
if (messageData.fileUrl && this._providerStores) await prepareMessageMedia(messageData);
|
|
435
434
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
435
|
+
const storesTemplateMedia = Boolean(messageData.contentSid && !messageData.fileUrl && !messageData.parentMessageId && this.provider.renderTemplateContent);
|
|
436
|
+
if (messageData.contentSid && (!messageData.body || storesTemplateMedia)) {
|
|
437
|
+
const content = this.provider.renderTemplateContent
|
|
438
|
+
? await this.provider.renderTemplateContent(messageData.contentSid, messageData.variables)
|
|
439
|
+
: { body: await this.provider.renderTemplate?.(messageData.contentSid, messageData.variables) };
|
|
440
|
+
if (!messageData.body && content?.body) messageData.body = content.body;
|
|
441
|
+
if (storesTemplateMedia && content?.media) messageData.media = { ...content.media, caption: messageData.body || '' };
|
|
439
442
|
}
|
|
440
443
|
|
|
441
444
|
if (this._needsTemplateRoute(messageData) && !(await isWithin24HourWindow(messageData.code, { contactCode: messageData.contactCode }))) {
|
|
@@ -8,8 +8,9 @@ const { uploadBufferToS3, generatePresignedUrl } = require('../config/awsConfig'
|
|
|
8
8
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
9
9
|
|
|
10
10
|
const { sanitizeMediaFilename } = require('../utils/sanitizerUtils.js');
|
|
11
|
-
const { validateMedia, getMediaType, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
|
|
11
|
+
const { validateMedia, getMediaType, EXTENSION_MIME_TYPES, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
|
|
12
12
|
const { logger } = require('../utils/logger');
|
|
13
|
+
const { ownS3Location } = require('../utils/s3Utils');
|
|
13
14
|
|
|
14
15
|
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
15
16
|
|
|
@@ -177,9 +178,18 @@ function getFileExtension(contentType) {
|
|
|
177
178
|
return mimeToExt[contentType] || 'bin';
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
function templateMediaOf(mediaUrl) {
|
|
182
|
+
const location = ownS3Location(mediaUrl);
|
|
183
|
+
if (!location?.key) return null;
|
|
184
|
+
const fileName = path.basename(location.key);
|
|
185
|
+
const contentType = EXTENSION_MIME_TYPES[path.extname(fileName).slice(1)] || 'application/octet-stream';
|
|
186
|
+
return { ...location, contentType, mediaType: getMediaType(contentType), fileName };
|
|
187
|
+
}
|
|
188
|
+
|
|
180
189
|
module.exports = {
|
|
181
190
|
uploadMediaToS3,
|
|
182
191
|
saveMediaToLocal,
|
|
183
192
|
prepareMessageMedia,
|
|
184
|
-
getFileExtension
|
|
193
|
+
getFileExtension,
|
|
194
|
+
templateMediaOf
|
|
185
195
|
};
|
|
@@ -23,13 +23,12 @@ const CANCELLED_SEND_MESSAGES = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
class ScheduledMessageJob extends BaseJob {
|
|
26
|
-
constructor({ queueAdapter, sendMessage
|
|
26
|
+
constructor({ queueAdapter, sendMessage } = {}) {
|
|
27
27
|
super({ queueAdapter, queueName: QUEUE_NAME });
|
|
28
28
|
if (typeof sendMessage !== 'function') {
|
|
29
29
|
throw new Error('ScheduledMessageJob requires a sendMessage function');
|
|
30
30
|
}
|
|
31
31
|
this.sendMessage = sendMessage;
|
|
32
|
-
this.requireProvider = requireProvider;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
async schedule({ scheduledMessageId, sendTime }) {
|
|
@@ -101,11 +100,6 @@ class ScheduledMessageJob extends BaseJob {
|
|
|
101
100
|
|
|
102
101
|
try {
|
|
103
102
|
const parentMessageId = msg.parentMessageId || null;
|
|
104
|
-
if (!parentMessageId && msg.contentSid && !msg.message) {
|
|
105
|
-
const provider = this.requireProvider?.();
|
|
106
|
-
const rendered = await provider?.renderTemplate?.(msg.contentSid, msg.variables);
|
|
107
|
-
if (rendered) msg.message = rendered;
|
|
108
|
-
}
|
|
109
103
|
|
|
110
104
|
const result = await this.sendMessage({
|
|
111
105
|
code: msg.code,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const { Estado_General_ID, Monitoreo_ID } = require('../config/airtableConfig');
|
|
2
|
-
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
2
|
|
|
4
3
|
const { httpError, requiredText } = require('../utils/httpErrorUtils');
|
|
5
4
|
const { inlineOf } = require('../utils/coercionUtils');
|
|
6
5
|
const { escapeRegExp } = require('../utils/textMatchUtils');
|
|
6
|
+
const { ownS3Location } = require('../utils/s3Utils');
|
|
7
7
|
const { logger } = require('../utils/logger');
|
|
8
8
|
const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
9
9
|
const { OfficeReadMarker } = require('../models/officeReadMarkerModel');
|
|
@@ -194,20 +194,14 @@ async function readOfficeMessages({ requestedBy, code, limit }) {
|
|
|
194
194
|
|
|
195
195
|
function officeDocumentUrl(fileUrl) {
|
|
196
196
|
if (!fileUrl) return null;
|
|
197
|
-
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
198
197
|
let url = null;
|
|
199
198
|
try {
|
|
200
199
|
url = new URL(String(fileUrl));
|
|
201
200
|
} catch {
|
|
202
201
|
url = null;
|
|
203
202
|
}
|
|
204
|
-
// The approved document template pins the S3 host and takes the rest of the URL as a variable.
|
|
205
|
-
|
|
206
|
-
const bucket = escapeRegExp(bucketName ?? '');
|
|
207
|
-
const isVirtualHosted = new RegExp(`^${bucket}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url?.hostname ?? '');
|
|
208
|
-
const isPathStyle = /^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url?.hostname ?? '')
|
|
209
|
-
&& url.pathname.startsWith(`/${bucketName}/`);
|
|
210
|
-
if (!bucketName || url?.protocol !== 'https:' || !(isVirtualHosted || isPathStyle)) {
|
|
203
|
+
// The approved document template pins the S3 host and takes the rest of the URL as a variable.
|
|
204
|
+
if (!ownS3Location(url)) {
|
|
211
205
|
throw httpError(400, 'The document must be uploaded before it is sent');
|
|
212
206
|
}
|
|
213
207
|
return url;
|
|
@@ -57,6 +57,13 @@ function validateMediaFormat(contentType, mediaType) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const EXTENSION_MIME_TYPES = {
|
|
61
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
|
|
62
|
+
pdf: 'application/pdf',
|
|
63
|
+
mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
|
|
64
|
+
mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
|
|
65
|
+
};
|
|
66
|
+
|
|
60
67
|
function getMediaType(contentType) {
|
|
61
68
|
for (const [type, formats] of Object.entries(ALLOWED_FORMATS)) {
|
|
62
69
|
if (formats.includes(contentType)) {
|
|
@@ -95,6 +102,7 @@ module.exports = {
|
|
|
95
102
|
validateMediaSize,
|
|
96
103
|
validateMediaFormat,
|
|
97
104
|
getMediaType,
|
|
105
|
+
EXTENSION_MIME_TYPES,
|
|
98
106
|
MEDIA_LIMITS,
|
|
99
107
|
ALLOWED_FORMATS,
|
|
100
108
|
STICKER_DIMENSIONS
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
const { escapeRegExp } = require('./textMatchUtils');
|
|
4
|
+
|
|
5
|
+
function decodedKey(rawKey) {
|
|
6
|
+
try {
|
|
7
|
+
return decodeURIComponent(rawKey);
|
|
8
|
+
} catch {
|
|
9
|
+
return rawKey;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A dotted bucket name is presigned path-style, so the bucket is then the first path segment.
|
|
14
|
+
function ownS3Location(value) {
|
|
15
|
+
let url = value;
|
|
16
|
+
try {
|
|
17
|
+
if (!(value instanceof URL)) url = new URL(String(value));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
22
|
+
if (!bucketName || url.protocol !== 'https:') return null;
|
|
23
|
+
if (new RegExp(`^${escapeRegExp(bucketName)}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url.hostname)) {
|
|
24
|
+
return { bucketName, key: decodedKey(url.pathname.slice(1)) };
|
|
25
|
+
}
|
|
26
|
+
if (/^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url.hostname) && url.pathname.startsWith(`/${bucketName}/`)) {
|
|
27
|
+
return { bucketName, key: decodedKey(url.pathname.slice(bucketName.length + 2)) };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { ownS3Location };
|