@peopl-health/nexus 6.3.1 → 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/clinical/providers/BaseLLMProvider.js +7 -6
- package/lib/clinical/tools/setContingencyPlanTool.js +1 -1
- 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 || '';
|
|
@@ -342,7 +342,7 @@ class BaseLLMProvider {
|
|
|
342
342
|
'- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. Si en este turno le indicaste un medicamento, una medida de autocuidado o que contacte a alguien (dentista, su médico, urgencias), o le transmitiste un tratamiento que indicó el equipo, decide el seguimiento en este mismo turno: si no hay ya un seguimiento activo que lo cubra, programa uno a 24–48 h (una red abierta no lo impide: la red espera su dato y el seguimiento pregunta por lo indicado) con utility_basis agreed_action: si fue un medicamento o una medida, para saber si funcionó; si fue una cita o un estudio, para saber si pudo agendarlo o si ya fue y cuándo. Pedirle «avísame cómo te va» no sustituye ese seguimiento. No lo programes por inactividad sola. Si la paciente vuelve a escribir antes, el seguimiento sigue pendiente y lo verás en «Seguimientos ya programados»: mantenlo, actualízalo o cancélalo según lo que cambió.',
|
|
343
343
|
'- RECORDATORIO: sólo existe si la paciente pidió explícitamente que se lo recuerdes o aceptó explícitamente tu oferta de recordárselo. Usa schedulePatientReminder con el tipo concreto (medicamento, cita, examen u otros) y exactamente a la hora que pidió. No lo adelantes para que llegue antes de una cita o tarea salvo que ella haya pedido ese adelanto. No infieras un recordatorio porque mencionó o agendó algo, ni lo uses para preguntar cómo sigue o vigilar incertidumbre clínica.',
|
|
344
344
|
'- CONTINGENCIA: protege una incertidumbre clínica todavía abierta; falta un dato u observación que podría cambiar el grado, la urgencia o la conducta. Usa setContingencyPlan: la contingencia define qué se espera, cuánto silencio tolerar y, si corresponde, la escalada posterior. Nunca la sustituyas por seguimiento ni por recordatorio.',
|
|
345
|
-
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene.',
|
|
345
|
+
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene. Lo mismo vale para un medicamento o una receta que indicó el equipo: el seguimiento sobre si lo consiguió o pasó por ella no espera a que se cierre la red.',
|
|
346
346
|
].join('\n'),
|
|
347
347
|
}];
|
|
348
348
|
}
|
|
@@ -445,19 +445,20 @@ class BaseLLMProvider {
|
|
|
445
445
|
parts.push(
|
|
446
446
|
'', '## Check-ins que ya le llegaron en las últimas 48 h', ...(sent.length ? sent : ['- ninguno']),
|
|
447
447
|
'',
|
|
448
|
-
`Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes,
|
|
448
|
+
`Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes, compara lo que querías saber con todo lo que dijo sobre este tema, incluido lo que te contó justo antes de que quedara pendiente:`,
|
|
449
449
|
'- Este check-in es el seguimiento que está venciendo ahora, aunque lo veas programado unos minutos más tarde: ya no está pendiente y no se puede actualizar. Si decides esperar, programa uno nuevo.',
|
|
450
|
-
'- Si ya lo
|
|
450
|
+
'- Si ya te dio lo que querías saber, a ti o al equipo, no se lo vuelvas a preguntar como si faltara. Si es una red de contingencia y ya no hace falta revisarlo, resuélvela con setContingencyPlan y registra hold con hold_reason answered_since_armed. Si todavía importa saber si empeoró, deja la red como está, empieza por lo que te dijo y pregunta sólo si cambió («Me dijiste que…» o «Me dijo que…», según cómo le hables). Si sólo te dio parte del dato, lo que falta sigue pendiente.',
|
|
451
451
|
'- Si tu último mensaje ya le hace esta pregunta o la cubre, y hoy estuvo en la conversación y dejó de contestar hace menos de 6 h, no la repitas hoy: programa la siguiente vez o resuélvela si ya no hace falta.',
|
|
452
452
|
'- Si sólo quedó una pregunta tuya sin respuesta y lleva 8 h o más en silencio, retomarla es justo para lo que existe este check-in: escríbele, salvo que sea de noche.',
|
|
453
453
|
'- Fuera de esos casos, decide la siguiente vez según el tipo de seguimiento y lo que ha ido contando: lo que puede empeorar en horas se pregunta pronto; un síntoma estable o ya en manejo puede esperar días. Si ya le llegó otro check-in hoy y esto puede esperar, no le escribas hoy: programa con schedulePatientReminder (reminder_type seguimiento, con la utility_basis de lo que quedó pendiente, nunca entre 07:30 y 11:00) la hora que corresponda y registra hold con hold_reason rescheduled_for_cadence y el mismo next_check_at.',
|
|
454
|
-
'- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle.',
|
|
454
|
+
'- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle. Esta red espera cómo evolucionó el síntoma: lo que reportó al abrirla no lo responde.',
|
|
455
455
|
'- Este check-in es el seguimiento que vencía: no cuenta como otro pendiente. Si un seguimiento distinto en la lista de pendientes, o una red activa, ya va a preguntar esto, no programes otro: registra hold con next_check_at null y hold_reason contingency_already_armed.',
|
|
456
456
|
'- Si le escribes, un solo mensaje con una sola pregunta; si otro tema pendiente se responde con esa misma pregunta, intégralo y resuelve las redes que ese mensaje ya cubre. Registra en utility_basis lo que contó o acordó.',
|
|
457
|
+
'- Si tu último mensaje dejó una pregunta distinta sin contestar que no cambia el grado ni la urgencia (por ejemplo, cuándo pasa por una receta que indicó el equipo), no la pierdas: le escribas o no, antes de cerrar el turno y de DeliverPatientMessage programa esa pregunta con schedulePatientReminder (reminder_type seguimiento, con la utility_basis que corresponda, al menos 4 h después y nunca entre 07:30 y 11:00). Si una red activa ya la espera, no programes nada. Si ya hay un seguimiento pendiente, no crees otro ni le sumes preguntas. Si en este turno reprogramas este check-in, sólo cabe un seguimiento: programa el de este check-in.',
|
|
457
458
|
exchange?.sessionOpen === false
|
|
458
459
|
? 'Si escribes, tu texto llega dentro de una plantilla que ya empieza con «Hola {su nombre},»: no saludes ni escribas su nombre; entra directo a lo que quieres saber.'
|
|
459
460
|
: 'Si escribes, le llega como un mensaje normal. Si llevan horas sin hablar, ábrelo con un saludo breve y natural según la hora del día, con el nombre de quien lleva la conversación si lo conoces (a un cuidador cuyo nombre no conoces, sin nombre); luego entra a lo que quieres saber.',
|
|
460
|
-
'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, si tu
|
|
461
|
+
'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, sólo si tu pregunta sigue sin respuesta y tu último mensaje fue esa pregunta, dilo en pocas palabras («te había preguntado por…» o «le había preguntado por…», según cómo le hables). Si ya te respondió, no digas que se lo habías preguntado: retoma lo que te dijo. Esa única pregunta va por lo que sigue faltando de lo que esperabas saber o por si cambió lo que ya te dijo; si lo que querías preguntarle trae varias, elige la que más importa hoy. Háblale directo a quien lleva la conversación, de tú o de usted, como en la conversación; nunca hables en tercera persona de quien te está leyendo.',
|
|
461
462
|
'No le comentes como novedad algo que no te dijo en el chat: sus respuestas del cuestionario las conoces tú, no son algo que te acaba de contar. No repitas lo que ya le dijiste (por ejemplo, que el equipo ya tiene el aviso) ni des por hecho detalles que no te dio.',
|
|
462
463
|
'Breve, sin listas de señales de alarma y sin mencionar recordatorios, planes ni seguimientos programados.',
|
|
463
464
|
);
|
|
@@ -521,7 +522,7 @@ class BaseLLMProvider {
|
|
|
521
522
|
const dueNet = outreachBrief?.checkIn?.planId || null;
|
|
522
523
|
const waiting = active.map(({ carePlan: plan = {} } = {}) => {
|
|
523
524
|
let fired = '';
|
|
524
|
-
if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado.';
|
|
525
|
+
if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado. Decide esta red con las reglas del check-in, no con la decisión de abajo.';
|
|
525
526
|
else if (plan.status === 'fired_reminder') fired = ' Su check-in ya se envió.';
|
|
526
527
|
return `- ${plan.planId} (armada ${localFireTime(plan.createdAt)}) espera: ${sanitizeInlineText(plan.awaitingCharacterization)}.${fired}`;
|
|
527
528
|
});
|
|
@@ -12,7 +12,7 @@ const MAX_PATIENT_SILENCE_MIN = 1380;
|
|
|
12
12
|
|
|
13
13
|
const definition = {
|
|
14
14
|
name: 'setContingencyPlan',
|
|
15
|
-
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
15
|
+
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. The same goes for a medication or prescription the team indicated: schedule the seguimiento about getting it without waiting for the net to close. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
16
16
|
strict: false,
|
|
17
17
|
parameters: {
|
|
18
18
|
type: 'object',
|
|
@@ -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 };
|