Cultiva IA — Slack Webhook Handler

skill: slack-webhooks-verificacion-eventos  •  cliente: Cultiva IA Agency  •  stack: Node.js + Express

HMAC-SHA256 Anti-replay < 3s

Flujo de Verificacion de Cada Peticion Slack

📥
POST /webhooks/slack
Slack Events API
🕐
Anti-Replay
|now - ts| ≤ 300s
🔒
HMAC-SHA256
v0:{ts}:{raw body}
🔍
Tipo de Evento
url_verification / event_callback
Handler
switch(event.type)
200 OK
respuesta rapida

webhook-cultiva.js — Handler Express Completo

// Generated with: slack-webhooks skill — Cultiva IA Agency
// Endpoint: POST /webhooks/slack en api.cultivaia.com

const crypto = require('crypto');
const express = require('express');
const app = express();

// ─── Verificacion HMAC-SHA256 ──────────────────────────────────────────────
function verifySlackRequest(rawBody, signatureHeader, timestampHeader, signingSecret) {
  if (!signatureHeader || !timestampHeader || !signingSecret) return false;

  const timestamp = parseInt(timestampHeader, 10);
  if (Number.isNaN(timestamp)) return false;

  // Proteccion anti-replay: rechazar peticiones con mas de 5 minutos
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 60 * 5) return false;

  // Slack firma la cadena literal: "v0:" + timestamp + ":" + raw body
  const basestring = `v0:${timestamp}:${rawBody}`;
  const expected = 'v0=' + crypto
    .createHmac('sha256', signingSecret)
    .update(basestring, 'utf8')
    .digest('hex');

  try {
    // timingSafeEqual previene timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(signatureHeader),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

// ─── Handlers de eventos Cultiva IA ───────────────────────────────────────
async function handleAppMention(event) {
  console.log(`[@cultiva-bot] Mencion en #${event.channel} por ${event.user}: "${event.text}"`);
  // Enqueue tarea pesada (no bloquear los 3s) — ej. bull/pg
  await taskQueue.add('mention-response', { event });
}

async function handleReactionAdded(event) {
  if (event.reaction === 'white_check_mark') {
    console.log(`[Tareas] Completada por ${event.user} en msg ${event.item.ts}`);
    await db.markTaskComplete(event.item.ts, event.user);
  }
}

async function handleTeamJoin(event) {
  console.log(`[Bienvenida] Nuevo miembro: ${event.user.id}`);
  await slack.chat.postMessage({
    channel: event.user.id,
    text: `Bienvenido/a a Cultiva IA! Soy @cultiva-bot. Escribe /ayuda para empezar.`
  });
}

// ─── Endpoint principal ────────────────────────────────────────────────────
app.post('/webhooks/slack',
  express.raw({ type: 'application/json' }),  // CRITICO: body RAW para verificacion
  async (req, res) => {
    const signature  = req.headers['x-slack-signature'];
    const timestamp  = req.headers['x-slack-request-timestamp'];
    const rawBody    = req.body.toString('utf8');

    if (!verifySlackRequest(rawBody, signature, timestamp, process.env.SLACK_SIGNING_SECRET)) {
      return res.status(401).send('Firma invalida');
    }

    const payload = JSON.parse(rawBody);

    // Challenge de verificacion inicial (configuracion del endpoint en Slack)
    if (payload.type === 'url_verification') {
      return res.status(200).json({ challenge: payload.challenge });
    }

    // Responder 200 inmediatamente — Slack requiere respuesta en < 3 segundos
    res.status(200).send('OK');

    if (payload.type === 'event_callback') {
      const event = payload.event;
      switch (event.type) {
        case 'app_mention':    await handleAppMention(event);    break;
        case 'reaction_added': await handleReactionAdded(event); break;
        case 'team_join':      await handleTeamJoin(event);      break;
        case 'app_home_opened':
          console.log(`[Home] Abierto por ${event.user}`); break;
        default:
          console.log(`[Webhook] Evento no manejado: ${event.type}`);
      }
    }
  }
);

app.listen(3000, () => console.log('cultiva-bot escuchando en :3000'));

Eventos Suscritos — Cultiva IA Bot

📢
app_mention
@cultiva-bot mencionado en #proyectos
→ Encola tarea de respuesta
reaction_added
Reaccion :white_check_mark: en mensaje
→ Marca tarea completada en DB
👤
team_join
Nuevo miembro en el workspace
→ DM de bienvenida automatico
🏠
app_home_opened
Usuario abre tab Home del bot
→ Muestra panel de tareas

Headers HTTP de Slack

Header Descripcion Uso
X-Slack-Signature Firma HMAC-SHA256 v0=<hex> Verificar autenticidad
X-Slack-Request-Timestamp Unix epoch incluido en el basestring Anti-replay (≤5 min)
X-Slack-Retry-Num Intento de reintento (1, 2 o 3) Detectar duplicados
X-Slack-Retry-Reason http_timeout / http_error Diagnosticar timeouts

Puntos Criticos de Implementacion

🔒
express.raw() es obligatorio — NO express.json() Slack firma el cuerpo RAW byte a byte. Si parseas el JSON antes de verificar, el orden de las claves puede cambiar y la firma NUNCA coincidira. Usar siempre express.raw({ type: 'application/json' }) y parsear manualmente tras verificar.
🕐
Responde 200 OK ANTES de procesar Slack espera respuesta en menos de 3 segundos o reintentara hasta 3 veces. Responde inmediatamente con 200 y delega el trabajo pesado a una cola asincrona (Bull, pg-boss, SQS, etc.). Detecta reintentos con X-Slack-Retry-Num para evitar procesamiento duplicado (idempotencia).
⚠️
crypto.timingSafeEqual() — obligatorio contra timing attacks Nunca compares firmas con === o !==. Un atacante puede medir el tiempo de respuesta para adivinar el secret caracter por caracter. crypto.timingSafeEqual() garantiza tiempo constante independientemente del numero de caracteres que coincidan.

Desarrollo Local con Hookdeck CLI

# Exponer el servidor local a Slack sin cuenta ni configuracion de red
npx hookdeck-cli listen 3000 slack --path /webhooks/slack

# Output:
# Signing Secret  sk_cultiva_abc123xyz789
# Forwarding     https://events.hookdeck.com/e/src_xxx → localhost:3000/webhooks/slack
#
# Pegar esta URL en: Slack App → Event Subscriptions → Request URL
# Slack enviara el challenge url_verification automaticamente

# Variable de entorno necesaria
SLACK_SIGNING_SECRET=sk_cultiva_abc123xyz789  # Slack App → Basic Information → App Credentials