🐾

CultivaBot — Twilio Webhook Handler

PataVerde Clínica Veterinaria · Asistente SMS/WhatsApp automatizado · Generado con twilio-webhooks skill
Node.js 20 Express 4 Twilio SDK 5.x Firma HMAC-SHA1 ✓ TwiML Responses
webhook.js
Flujo
Eventos
Tests
Flujo de verificación y despacho
📨
Request
POST /webhooks/twilio
🔐
Verificar firma
X-Twilio-Signature HMAC-SHA1
🔍
Detectar tipo
SMS / Voice / Status
🤖
Keyword bot
CITA / URGENCIA / VACUNA / BAJA
📤
TwiML
Response XML
src/webhook.js Generado por twilio-webhooks skill
Handler Express completo con verificación de firma, detección de tipo de evento y respuesta TwiML automática para PataVerde.
src/webhook.js javascript
// Generated with: twilio-webhooks skill
// https://github.com/hookdeck/webhook-skills
// Cliente: PataVerde Clínica Veterinaria — CultivaBot SMS/WhatsApp

const express = require('express');
const twilio  = require('twilio');

const app       = express();
const authToken = process.env.TWILIO_AUTH_TOKEN;

// ── 1. Keywords → respuesta automática TwiML ──────────────────────────────
const KEYWORD_RESPONSES = {
  CITA:     '<Response><Message>Para agendar cita responde con tu nombre y la fecha deseada. Te contactaremos en <24h 🐶</Message></Response>',
  URGENCIA: '<Response><Message>🚨 Estamos atendiendo tu urgencia. Llama AHORA al 91-000-1234 o dirígete a Calle del Pez 12, Madrid.</Message></Response>',
  VACUNA:   '<Response><Message>Te enviamos el calendario de vacunas por email. ¿Cuál es tu dirección de correo? 💉</Message></Response>',
  BAJA:     '<Response><Message>Lamentamos que te vayas. Tu número ha sido eliminado de nuestras notificaciones. ¡Cuídate! 🐾</Message></Response>',
};
const DEFAULT_REPLY = '<Response><Message>¡Hola! Soy CultivaBot de PataVerde 🐾. Responde CITA, VACUNA o URGENCIA para continuar.</Message></Response>';

// ── 2. Middleware de verificación de firma Twilio ─────────────────────────
function verifyTwilioSignature(req, res, next) {
  const signature = req.headers['x-twilio-signature'];
  const url       = `https://${req.headers.host}${req.originalUrl}`;

  const isValid = twilio.validateRequest(authToken, signature, url, req.body);

  if (!isValid) {
    console.warn(`[SECURITY] Firma inválida – IP: ${req.ip} URL: ${url}`);
    return res.status(403).json({ error: 'Firma de Twilio inválida' });
  }
  next();
}

// ── 3. Parsear cuerpos form-encoded (SMS/voice) y JSON (APIs) ─────────────
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

// ── 4a. SMS / WhatsApp entrante ───────────────────────────────────────────
app.post('/webhooks/twilio/sms', verifyTwilioSignature, (req, res) => {
  const { MessageSid, From, Body } = req.body;
  const keyword  = (Body || '').trim().toUpperCase();

  console.info(JSON.stringify({
    event: 'sms.received', MessageSid, From, keyword,
    ts: new Date().toISOString(),
  }));

  const twiml = KEYWORD_RESPONSES[keyword] ?? DEFAULT_REPLY;
  res.type('text/xml').send(twiml);
});

// ── 4b. Llamada de voz entrante ───────────────────────────────────────────
app.post('/webhooks/twilio/voice', verifyTwilioSignature, (req, res) => {
  const { CallSid, From, CallStatus } = req.body;

  console.info(JSON.stringify({
    event: 'call.received', CallSid, From, CallStatus,
    ts: new Date().toISOString(),
  }));

  // Responder con TwiML: mensaje de espera + desvío a recepción
  res.type('text/xml').send(`
    <Response>
      <Say voice="Polly.Conchita" language="es-ES">
        Bienvenido a PataVerde Clínica Veterinaria.
        Estamos atendiendo otras consultas.
        Le transferimos con nuestra recepción.
      </Say>
      <Dial timeout="20">+34910001234</Dial>
      <Say voice="Polly.Conchita" language="es-ES">
        No hemos podido atenderle. Por favor envíe CITA por WhatsApp al mismo número.
      </Say>
    </Response>
  `);
});

// ── 4c. Callback de estado de mensaje ─────────────────────────────────────
app.post('/webhooks/twilio/status', verifyTwilioSignature, (req, res) => {
  const { MessageSid, MessageStatus, To, ErrorCode } = req.body;

  const logEntry = {
    event: `message.status.${MessageStatus}`,
    MessageSid, MessageStatus, To,
    ts: new Date().toISOString(),
  };

  if (ErrorCode) {
    logEntry.ErrorCode = ErrorCode;
    console.error(JSON.stringify(logEntry));  // delivered → silencioso, failed → alerta
  } else {
    console.info(JSON.stringify(logEntry));
  }

  res.status(204).send();  // 204 No Content — Twilio no espera body
});

// ── 5. Arranque ───────────────────────────────────────────────────────────
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.info(JSON.stringify({
    event: 'server.started',
    port: PORT,
    endpoints: ['/webhooks/twilio/sms', '/webhooks/twilio/voice', '/webhooks/twilio/status'],
  }));
});

module.exports = app;  // exportar para tests
Flujo de keywords automáticas
El bot detecta la primera palabra del mensaje en mayúsculas y responde con el TwiML correspondiente.
Keyword Caso de uso Respuesta TwiML HTTP
CITA Agendar consulta veterinaria <Response><Message>Para agendar cita...</Message></Response> 200 XML
URGENCIA Emergencia médica de mascota <Response><Message>🚨 Llama AHORA al 91-000-1234...</Message></Response> 200 XML
VACUNA Consulta calendario de vacunación <Response><Message>Te enviamos el calendario...</Message></Response> 200 XML
BAJA Dar de baja notificaciones <Response><Message>Tu número ha sido eliminado...</Message></Response> 200 XML
(default) Cualquier otro mensaje <Response><Message>¡Hola! Soy CultivaBot...</Message></Response> 200 XML
voice call Llamada entrante <Say> + <Dial> recepción + <Say> fallback 200 XML
status cb Callback entregado/fallido Log estructurado JSON (no TwiML) 204 Empty
Eventos Twilio reconocidos
Twilio no envía un campo "event" — el tipo se infiere por los parámetros del payload.
SMS / WhatsApp entrante
Mensaje del cliente
Enviado cuando un usuario manda un SMS o mensaje de WhatsApp al número de PataVerde.
MessageSid From To Body NumMedia
Llamada de voz entrante
Llamada al número de la clínica
Twilio llama al webhook cuando alguien marca el número. Se responde con TwiML <Dial> a recepción.
CallSid From To CallStatus
Status callback
Estado de entrega del mensaje
Notificación de Twilio sobre si el mensaje fue entregado, fallido o no entregado. Se registra en log estructurado.
MessageSid MessageStatus To ErrorCode?
Firma inválida
Request no autorizado
Si la firma X-Twilio-Signature no coincide con HMAC-SHA1, se rechaza con 403 y se registra la IP del intento.
403 Forbidden IP logged
Salida de tests
Simulación de npm test con el test suite de verificación de firma y respuestas TwiML.
npm test — webhook.test.js
PataVerde CultivaBot — Twilio Webhook Handler
 
✓ rechaza request sin firma Twilio (403)
✓ rechaza firma HMAC inválida (403)
✓ acepta firma HMAC-SHA1 válida
 
SMS keyword routing
✓ CITA → TwiML con instrucciones de cita
✓ URGENCIA → TwiML con número de emergencia
✓ VACUNA → TwiML pidiendo email
✓ BAJA → TwiML confirmando baja
✓ mensaje desconocido → respuesta default CultivaBot
✓ keyword case-insensitive (cita = CITA)
 
Voice call
✓ llamada entrante → TwiML con <Say> + <Dial>
✓ <Dial> apunta a +34910001234
 
Status callbacks
✓ delivered → 204 No Content
✓ failed → 204 + log de error con ErrorCode
✓ undelivered → 204 + log estructurado JSON
 
14 passing (87ms)