@peopl-health/nexus 6.3.2 → 6.3.4

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.
@@ -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'].caption || '';
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 = MIME_MAP[ext] || 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({
@@ -431,11 +430,15 @@ class NexusMessaging {
431
430
  if (messageData.body) messageData.body = decodeUnicodeEscapes(messageData.body);
432
431
  if (messageData.variables) messageData.variables = decodeUnicodeEscapesDeep(messageData.variables);
433
432
 
434
- if (messageData.fileUrl && this._providerStores) await prepareMessageMedia(messageData);
433
+ if (messageData.fileUrl && !messageData.media?.key && this._providerStores) await prepareMessageMedia(messageData);
435
434
 
436
- if (messageData.contentSid && !messageData.body && this.provider.renderTemplate) {
437
- const rendered = await this.provider.renderTemplate(messageData.contentSid, messageData.variables);
438
- if (rendered) messageData.body = rendered;
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, requireProvider = null } = {}) {
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,
@@ -53,6 +53,7 @@ async function releaseOne(message, now) {
53
53
  return false;
54
54
  }
55
55
 
56
+ const { media } = message;
56
57
  const body = await resolveRecoveryBody(message);
57
58
  if (hasUnresolvedPhi(body)) {
58
59
  logger.error('[HeldMessage] Body still carries a PHI placeholder, refusing to release', logCtx);
@@ -72,6 +73,7 @@ async function releaseOne(message, now) {
72
73
  code: message.numero,
73
74
  ...contactOf(message),
74
75
  body,
76
+ ...(media?.key && { media, fileUrl: `https://${media.bucketName}.s3.amazonaws.com/${media.key}`, fileType: media.mediaType }),
75
77
  parentMessageId: message._id,
76
78
  kind: 'freeform',
77
79
  idempotencyKey: `window-release:${message._id}`
@@ -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. A bucket name
205
- // with dots (whatsapp.prod) is presigned path-style, so the bucket is then the first path segment.
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.3.2",
3
+ "version": "6.3.4",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",