0006 — Alcance de contactos por canal y plantillas de aviso
Estado: aceptada · Fecha: 2026-09-19
Contexto
Las citas cargadas a mano en el calendario quedaban agendadas pero nadie confirmaba con el paciente:
- Sin remitente. Sin conversación,
SchedulingService.resolveDeliveryRoutedevolvíanully loscore_remindersse programaban sinchannel_account_id.RemindersServicelos descartaba al vencer, sin que nadie se enterase. Las ofertas de reagendo tenían el mismo problema en todas las citas. - Sin plantilla. WhatsApp solo acepta mensajes libres dentro de las 24 h desde el último mensaje del cliente. Fuera de eso, o si nunca escribió, solo acepta una plantilla aprobada por Meta. El recordatorio salía siempre como mensaje de sesión.
- Fallo silencioso. La Cloud API responde
200y rechaza después por el webhook destatuses(131047). Ese webhook se descartaba y el outbox marcaba como entregado lo que nunca llegó. Afectaba también a pacientes que habían reservado por el bot hace más de un día.
El problema no es de WhatsApp: cada canal tiene su propia regla sobre a quién se le puede escribir primero, y hasta ahora ninguna estaba escrita en el código.
Decisión
1. Una política de alcance por canal, en la plataforma
CHANNEL_REACH_POLICY (backend/src/channels/outreach/channel-reach-policy.ts), hermana de
CHANNEL_CAPABILITIES, responde a otra pregunta: no qué formatos entiende un canal, sino a quién
se le puede escribir y cuándo.
| Canal | Ventana de sesión | Contacto sin chat previo | Dato que se usa |
|---|---|---|---|
| 24 h | con plantilla aprobada | teléfono | |
| Correo | sin ventana | libre | correo |
| Messenger / Instagram | 24 h | no | — |
| Telegram | sin ventana | no (el chat_id nace al pulsar Iniciar) | — |
| Webchat / X | — | no | — |
- Una sola regla.
planDelivery(función pura) la aplica a cada ruta del outbox: sesión dentro de la ventana, plantilla fuera si el canal la admite y hay una aprobada, y si no, omitir la ruta con motivo. "Enviar recordatorio" en el escritorio aplica la misma comprobación en seco, así que no puede discrepar del envío real. - El panel no copia la tabla. Ajustes de recordatorios la recibe del backend
(
channel_policies) para mostrarla como ayuda.
2. Plantillas de aviso como dato de plataforma
- Tabla genérica.
channel_message_templatesguarda qué plantilla del proveedor cubre cada aviso de Xenpia (purpose), por cuenta de canal. Incluye el estado de aprobación y el mapeo de variables y botones. - El mapeo es dato, no constante. Una clínica puede usar su propia plantilla con las variables en otro orden.
- Plantilla estándar. Xenpia crea
xenpia_recordatorio_cita_v1(UTILITY,es) en la WABA del cliente con un clic desde Ajustes. Así la clínica no entra a Meta ni se equivoca con sus reglas. El nombre lleva versión porque Meta no deja reutilizar un nombre rechazado. - Las respuestas reutilizan lo que ya existía. Los botones QUICK_REPLY llevan el mismo
appt:confirm:<id>/appt:cancel:<id>que los botones de sesión, y el webhook ya los enrutaba aapplyAppointmentReply.
3. Quién envía se decide al programar; si se puede, al enviar
- Al programar, la agenda resuelve un remitente para las citas sin conversación: el número de WhatsApp elegido en Ajustes, o el único activo. Así los recordatorios dejan de nacer huérfanos.
- Al enviar, el outbox decide si se puede escribir. Mira el interruptor
reminders.cold_outreach.enabled(apagado por defecto), el teléfono y si el número ya es de otro contacto. Así, apagar el interruptor frena también los avisos ya programados. - Consentimiento. Es un interruptor por tenant: encenderlo es afirmar que la clínica recoge el
consentimiento al tomar el teléfono. No se usó la tabla
consentspor contacto, para no añadir un paso en recepción. Si hace falta más granularidad, ese es el sitio.
4. Identidad desde la respuesta de Meta, después de enviar
La identidad y la conversación del contacto sin chat se crean después de que Meta acepta el envío:
- Nada que limpiar si falla. Un envío rechazado no deja conversaciones vacías en la bandeja.
- Se usa el
wa_idque devuelve Meta. No siempre coincide con el número normalizado (México 52/521, Argentina 549). Con el número normalizado, la respuesta del paciente abriría una ficha nueva y la cita no se podría confirmar.
5. Un aviso que no llega es trabajo para una persona
- Estados de entrega. Cada envío guarda el
wamid. El webhook procesastatuses: el estado solo avanza, yfailedgana siempre. - Alertas. Un fallo definitivo levanta una alerta en
core_alerts, sea al enviar (sin teléfono, sin plantilla, interruptor apagado, sin remitente) o después (rechazo de Meta).- La tabla es de plataforma, como
core_reminders: tipo y sujeto son datos, y cada alerta declara el permiso que hace falta para verla (required_permission). - La lee el panel con RLS y se resuelve con
resolve_core_alert(). - La agenda la cierra sola cuando la cita cambia de estado o un aviso posterior llega.
- La tabla es de plataforma, como
Consecuencias
- Costo. Las plantillas se cobran en Meta, así que el aviso a citas sin chat viene apagado. Una vez aprobada la plantilla, los recordatorios fuera de ventana de pacientes del bot también salen como plantilla: antes no llegaban.
- Ofertas de reagendo. Fuera de la ventana no tienen versión de plantilla y se omiten con motivo, sin enviarse a ciegas.
- Pendiente:
- webhook
message_template_status_update: el estado de la plantilla se refresca al abrir Ajustes, cada 5 min al enviar y con los errores132015/132016; - estados de entrega de las respuestas normales del bot en la bandeja.
- webhook
La firma x-hub-signature-256 del webhook —que con estados procesados evita que un POST falso
cree alertas falsas— la resolvió aparte el onboarding con coexistencia (meta-signature.ts).
Cómo encajan los demás canales
- Correo (siguiente). Es el único otro canal que puede escribir primero (
coldStart: 'free').- No tiene botones: la confirmación va por un enlace firmado. Mismo patrón que
HealthDocumentsService.createDelivery: token aleatorio, solo su hash en la base, caducidad. - El enlace lleva a una página pública (
/c/[token], como/d/[token]) y a una RPCSECURITY DEFINERque confirma o cancela. - El dispatcher de correo necesita pintar el
interactive_ctacomo botón real y fijar un asunto. - La plantilla estándar sería otra entrada de
STANDARD_TEMPLATES.appointment_reminder.email.
- No tiene botones: la confirmación va por un enlace firmado. Mismo patrón que
- SMS. Si se integra un proveedor, sería un canal nuevo con
coldStart: 'free',contactField: 'phone'y confirmación por enlace, igual que el correo. No necesita plantillas de Meta. - Messenger e Instagram. Fuera de 24 h solo quedan las etiquetas de mensaje (
HUMAN_AGENT, 7 días y para un humano), que no sirven para avisos automáticos. Sin mensaje entrante no existe ni el PSID/IGSID. Se quedan ennone. - Telegram. Podría ofrecer un enlace
t.me/<bot>?start=<token>en el correo o el SMS para que el paciente abra el chat y quede vinculado. Hasta entonces,none.