# Conexión de WhatsApp para el CRM del sistema ISP

Guía y código base para que el CRM de mensajes se conecte a WhatsApp de dos formas: **vinculando por código/QR** (el cliente conserva su número y su app) y **por número con la API oficial** (WhatsApp Cloud API).

---

## 1. Modos de conexión

Lo ideal es ofrecer **dos modos por empresa (tenant)** y que cada empresa elija el suyo. Así se cubre tanto a quienes ya tienen su número en uso como a quienes empiezan de cero.

### Modo A: vincular por código o QR (dispositivo vinculado)

Es lo mismo que "WhatsApp Web". La empresa sigue usando su número en su celular y el CRM se conecta como un dispositivo más.

- **QR:** el usuario escanea el código desde WhatsApp > Dispositivos vinculados.
- **Código de emparejamiento (pairing code):** el usuario escribe su número en el CRM, este le muestra un código de 8 caracteres y lo ingresa en WhatsApp > Dispositivos vinculados > Vincular con número de teléfono. Es útil cuando se configura desde el mismo celular y no se puede escanear un QR.
- **Cómo se implementa:** con una librería no oficial como Baileys (Node.js) o whatsapp-web.js. Lo habitual es un microservicio Node que mantiene una sesión por empresa, expone una API interna y avisa a Laravel por webhook. Laravel solo se comunica con ese servicio.
- **Ventajas:** no requiere aprobación de Meta, es gratis y la empresa conserva su número y su app.
- **Riesgos:** no es oficial, así que WhatsApp puede bloquear números que hacen envíos masivos. Hay que limitar la velocidad de envío (delays aleatorios, lotes pequeños), y la sesión puede caerse y requerir revincular. Hay que decirle esto claramente al cliente.

### Modo B: conexión por número (API oficial, WhatsApp Cloud API)

- La empresa registra su número en Meta (WhatsApp Business Platform), idealmente con un Business Manager verificado.
- Es la opción estable y segura para envíos masivos. Para iniciar conversaciones se usan plantillas aprobadas por Meta, con costo por conversación o mensaje.
- **El problema de los números ya establecidos:** un número normalmente no puede estar a la vez en la app y en la API. Hay que migrarlo, y el celular deja de usar WhatsApp. Meta tiene una función de **coexistencia** que permite usar la app WhatsApp Business y la API con el mismo número, pero tiene requisitos y limitaciones. Conviene confirmarla en la documentación oficial antes de prometérsela a clientes.
- Se puede facilitar el registro con **Embedded Signup**, un flujo de Meta dentro de tu propia pantalla, para que el cliente conecte su cuenta sin intervención tuya.

---

## 2. Cómo estructurarlo en el sistema

Crear una abstracción de proveedor para que el resto del CRM no dependa del modo de conexión:

```
WhatsAppProvider (interfaz)
 ├─ BaileysProvider   → sesión por QR / código
 └─ CloudApiProvider  → token + phone_number_id
```

Métodos comunes: `sendText`, `sendImage`, `sendTemplate` y `getStatus`, y un único formato de evento entrante (mensaje recibido, estado de entrega, conexión caída).

Tabla `whatsapp_connections` por empresa:

- `company_id`
- `driver` (`baileys` o `cloud_api`)
- `phone_number`
- `status` (conectado, desconectado, esperando vinculación)
- `credentials` (cifradas)
- `last_connected_at`

Pantalla de conexión con el selector "Vincular con QR", "Vincular con código" o "API oficial". Muestra el QR o el código en pantalla y actualiza el estado en tiempo real, por ejemplo con websockets.

Otros puntos importantes:

- Una cola (Laravel Queues) para los envíos masivos programados, con límite de velocidad distinto por driver.
- El agente IA y la derivación a departamentos se mantienen igual, porque trabajan sobre el formato unificado de mensajes.
- Para las sesiones de Baileys en el VPS, usar PM2 o Docker y guardar la sesión en disco o base de datos, para que sobreviva a los reinicios.

---

## 3. Microservicio Node con Baileys

Estructura de archivos:

```
whatsapp-service/
├─ package.json
├─ .env.example
└─ src/
   ├─ server.js
   └─ sessions.js
```

### package.json

```json
{
  "name": "whatsapp-service",
  "version": "1.0.0",
  "type": "module",
  "main": "src/server.js",
  "scripts": {
    "start": "node --env-file=.env src/server.js"
  },
  "dependencies": {
    "@whiskeysockets/baileys": "latest",
    "express": "^4.19.2",
    "pino": "^9.0.0",
    "qrcode": "^1.5.4"
  },
  "engines": {
    "node": ">=20"
  }
}
```

### .env.example

```env
PORT=3001
# Clave que Laravel envía en el header X-API-KEY
API_KEY=cambia_esta_clave_larga
# URL de Laravel que recibe los eventos
WEBHOOK_URL=https://tu-dominio.com/api/whatsapp/webhook
# Secreto que este servicio envía a Laravel en el header X-Webhook-Secret
WEBHOOK_SECRET=cambia_este_secreto
SESSIONS_DIR=./sessions
# Pausa aleatoria entre envíos (ms) para reducir riesgo de bloqueo
SEND_DELAY_MIN=1500
SEND_DELAY_MAX=4000
```

### src/sessions.js

```js
import makeWASocket, {
  useMultiFileAuthState,
  DisconnectReason,
  fetchLatestBaileysVersion,
  Browsers,
} from '@whiskeysockets/baileys';
import QRCode from 'qrcode';
import pino from 'pino';
import fs from 'node:fs';
import path from 'node:path';

const logger = pino({ level: 'silent' });
const SESSIONS_DIR = process.env.SESSIONS_DIR || './sessions';
const DELAY_MIN = Number(process.env.SEND_DELAY_MIN || 1500);
const DELAY_MAX = Number(process.env.SEND_DELAY_MAX || 4000);

/**
 * sessions: Map<companyId, {
 *   sock, status, mode, phone, qr, pairingCode, queue, ready, resolveReady
 * }>
 * status: 'connecting' | 'waiting_link' | 'connected' | 'disconnected'
 */
const sessions = new Map();

fs.mkdirSync(SESSIONS_DIR, { recursive: true });

// ---------- Webhook hacia Laravel ----------
export async function sendWebhook(event, companyId, data = {}) {
  if (!process.env.WEBHOOK_URL) return;
  try {
    await fetch(process.env.WEBHOOK_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Secret': process.env.WEBHOOK_SECRET || '',
      },
      body: JSON.stringify({ event, company_id: companyId, data }),
    });
  } catch (err) {
    console.error(`[webhook] error enviando ${event}:`, err.message);
  }
}

// ---------- Helpers ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const randomDelay = () => DELAY_MIN + Math.random() * (DELAY_MAX - DELAY_MIN);

const toJid = (to) => {
  if (to.includes('@')) return to;
  return `${to.replace(/\D/g, '')}@s.whatsapp.net`;
};

const sessionPath = (id) => path.join(SESSIONS_DIR, String(id));

function extractText(msg) {
  const m = msg.message;
  if (!m) return null;
  return (
    m.conversation ||
    m.extendedTextMessage?.text ||
    m.imageMessage?.caption ||
    m.videoMessage?.caption ||
    null
  );
}

// ---------- Conexión ----------
async function createSocket(id) {
  const state = sessions.get(id);
  const { state: authState, saveCreds } = await useMultiFileAuthState(sessionPath(id));
  const { version } = await fetchLatestBaileysVersion();

  const sock = makeWASocket({
    version,
    auth: authState,
    logger,
    browser: Browsers.ubuntu('Chrome'),
    printQRInTerminal: false,
    syncFullHistory: false,
  });

  state.sock = sock;
  sock.ev.on('creds.update', saveCreds);

  sock.ev.on('connection.update', async (update) => {
    const { connection, lastDisconnect, qr } = update;

    // Aparece "qr" cuando el socket está listo para vincular
    if (qr && !authState.creds.registered) {
      state.status = 'waiting_link';

      if (state.mode === 'code') {
        if (!state.pairingCode) {
          try {
            const code = await sock.requestPairingCode(state.phone);
            state.pairingCode = code; // formato XXXXXXXX
            await sendWebhook('pairing_code', id, { code });
          } catch (err) {
            console.error(`[${id}] error pidiendo código:`, err.message);
          }
        }
      } else {
        state.qr = await QRCode.toDataURL(qr); // data:image/png;base64,...
        await sendWebhook('qr', id, { qr: state.qr });
      }
      state.resolveReady?.();
    }

    if (connection === 'open') {
      state.status = 'connected';
      state.qr = null;
      state.pairingCode = null;
      state.phone = sock.user?.id?.split(':')[0]?.split('@')[0] || state.phone;
      await sendWebhook('connected', id, { phone: state.phone });
      state.resolveReady?.();
    }

    if (connection === 'close') {
      const statusCode = lastDisconnect?.error?.output?.statusCode;
      const loggedOut = statusCode === DisconnectReason.loggedOut;

      if (loggedOut) {
        // El usuario cerró la sesión desde su celular: limpiar y avisar
        fs.rmSync(sessionPath(id), { recursive: true, force: true });
        sessions.delete(id);
        await sendWebhook('logged_out', id);
        state.resolveReady?.();
      } else {
        // Caída normal o reinicio requerido tras vincular (515): reconectar
        state.status = 'connecting';
        await sendWebhook('disconnected', id, { reason: statusCode });
        setTimeout(() => createSocket(id).catch(console.error), 3000);
      }
    }
  });

  // Mensajes entrantes -> Laravel
  sock.ev.on('messages.upsert', async ({ messages, type }) => {
    if (type !== 'notify') return;
    for (const msg of messages) {
      if (msg.key.fromMe || !msg.message) continue;
      if (msg.key.remoteJid?.endsWith('@g.us')) continue; // ignorar grupos
      await sendWebhook('message', id, {
        message_id: msg.key.id,
        from: msg.key.remoteJid,
        push_name: msg.pushName || null,
        text: extractText(msg),
        has_image: !!msg.message.imageMessage,
        timestamp: Number(msg.messageTimestamp),
      });
    }
  });

  // Estados de entrega/lectura -> Laravel
  sock.ev.on('messages.update', async (updates) => {
    for (const u of updates) {
      if (u.update?.status) {
        await sendWebhook('message_status', id, {
          message_id: u.key.id,
          status: u.update.status, // 2=enviado, 3=entregado, 4=leído
        });
      }
    }
  });
}

/**
 * Inicia (o reutiliza) una sesión.
 * mode: 'qr' | 'code'  — para 'code' hace falta phone con código de país, ej: 593991234567
 */
export async function startSession(id, { mode = 'qr', phone } = {}) {
  const existing = sessions.get(id);
  if (existing && existing.status === 'connected') return publicState(existing);

  if (existing?.sock) {
    try { existing.sock.end(undefined); } catch {}
  }

  if (mode === 'code') {
    phone = String(phone || '').replace(/\D/g, '');
    if (!phone) throw new Error('Se requiere "phone" con código de país para vincular por código');
  }

  const state = {
    sock: null,
    status: 'connecting',
    mode,
    phone,
    qr: null,
    pairingCode: null,
    queue: Promise.resolve(),
  };
  state.ready = new Promise((resolve) => (state.resolveReady = resolve));
  sessions.set(id, state);

  await createSocket(id);

  // Esperar (máx. 20 s) a que haya QR/código o conexión directa
  await Promise.race([state.ready, sleep(20000)]);
  return publicState(state);
}

function publicState(s) {
  return {
    status: s.status,
    mode: s.mode,
    phone: s.phone || null,
    qr: s.qr,
    pairing_code: s.pairingCode,
  };
}

export function getStatus(id) {
  const s = sessions.get(id);
  if (!s) return { status: 'disconnected', mode: null, phone: null, qr: null, pairing_code: null };
  return publicState(s);
}

// ---------- Envío con cola y pausa aleatoria ----------
function enqueue(id, job) {
  const s = sessions.get(id);
  if (!s || s.status !== 'connected') throw new Error('Sesión no conectada');
  const run = s.queue.then(async () => {
    await sleep(randomDelay());
    return job(s.sock);
  });
  s.queue = run.catch(() => {}); // que un error no rompa la cola
  return run;
}

export function sendText(id, to, text) {
  return enqueue(id, async (sock) => {
    const jid = toJid(to);
    await sock.sendPresenceUpdate('composing', jid);
    await sleep(600 + Math.random() * 900);
    const res = await sock.sendMessage(jid, { text });
    return { message_id: res.key.id };
  });
}

export function sendImage(id, to, imageUrl, caption = '') {
  return enqueue(id, async (sock) => {
    const res = await sock.sendMessage(toJid(to), {
      image: { url: imageUrl },
      caption,
    });
    return { message_id: res.key.id };
  });
}

export async function logout(id) {
  const s = sessions.get(id);
  if (s?.sock) {
    try { await s.sock.logout(); } catch {}
  }
  fs.rmSync(sessionPath(id), { recursive: true, force: true });
  sessions.delete(id);
  return { status: 'disconnected' };
}

// ---------- Restaurar sesiones al reiniciar el servicio ----------
export async function restoreSessions() {
  const dirs = fs
    .readdirSync(SESSIONS_DIR, { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => d.name);

  for (const id of dirs) {
    console.log(`[restore] reconectando sesión ${id}`);
    const state = {
      sock: null,
      status: 'connecting',
      mode: 'qr',
      phone: null,
      qr: null,
      pairingCode: null,
      queue: Promise.resolve(),
    };
    state.ready = new Promise((resolve) => (state.resolveReady = resolve));
    sessions.set(id, state);
    createSocket(id).catch((e) => console.error(`[restore] ${id}:`, e.message));
    await sleep(1500); // escalonar para no saturar
  }
}
```

### src/server.js

```js
import express from 'express';
import {
  startSession,
  getStatus,
  sendText,
  sendImage,
  logout,
  restoreSessions,
} from './sessions.js';

const app = express();
app.use(express.json({ limit: '1mb' }));

// Autenticación simple por API key (solo Laravel debe poder llamar este servicio)
app.use((req, res, next) => {
  if (req.header('X-API-KEY') !== process.env.API_KEY) {
    return res.status(401).json({ error: 'No autorizado' });
  }
  next();
});

const wrap = (fn) => (req, res) =>
  Promise.resolve(fn(req, res)).catch((err) =>
    res.status(400).json({ error: err.message })
  );

// Iniciar vinculación
// body: { "mode": "qr" }  o  { "mode": "code", "phone": "593991234567" }
app.post('/sessions/:id/connect', wrap(async (req, res) => {
  const { mode = 'qr', phone } = req.body;
  res.json(await startSession(req.params.id, { mode, phone }));
}));

// Estado actual (Laravel puede consultarlo por polling mientras el usuario vincula)
app.get('/sessions/:id/status', (req, res) => {
  res.json(getStatus(req.params.id));
});

// Enviar texto: { "to": "593991234567", "text": "Hola" }
app.post('/sessions/:id/send-text', wrap(async (req, res) => {
  const { to, text } = req.body;
  if (!to || !text) throw new Error('Faltan "to" y "text"');
  res.json(await sendText(req.params.id, to, text));
}));

// Enviar imagen: { "to": "...", "image_url": "https://...", "caption": "..." }
app.post('/sessions/:id/send-image', wrap(async (req, res) => {
  const { to, image_url, caption } = req.body;
  if (!to || !image_url) throw new Error('Faltan "to" y "image_url"');
  res.json(await sendImage(req.params.id, to, image_url, caption));
}));

// Cerrar sesión y borrar credenciales
app.post('/sessions/:id/logout', wrap(async (req, res) => {
  res.json(await logout(req.params.id));
}));

const port = process.env.PORT || 3001;
// Escuchar solo en localhost: Laravel está en el mismo VPS
app.listen(port, '127.0.0.1', async () => {
  console.log(`WhatsApp service en http://127.0.0.1:${port}`);
  await restoreSessions();
});
```

---

## 4. Integración en Laravel

### app/Services/WhatsAppService.php

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class WhatsAppService
{
    protected function http()
    {
        return Http::baseUrl(config('services.whatsapp.url'))   // http://127.0.0.1:3001
            ->withHeaders(['X-API-KEY' => config('services.whatsapp.key')])
            ->timeout(40)
            ->acceptJson();
    }

    // Vincular por QR
    public function connectQr(int $companyId): array
    {
        return $this->http()->post("/sessions/{$companyId}/connect", ['mode' => 'qr'])->throw()->json();
    }

    // Vincular por código (el cliente ingresa el código en su WhatsApp)
    public function connectCode(int $companyId, string $phone): array
    {
        return $this->http()->post("/sessions/{$companyId}/connect", [
            'mode'  => 'code',
            'phone' => $phone, // con código de país, ej: 593991234567
        ])->throw()->json();
    }

    public function status(int $companyId): array
    {
        return $this->http()->get("/sessions/{$companyId}/status")->json();
    }

    public function sendText(int $companyId, string $to, string $text): array
    {
        return $this->http()->post("/sessions/{$companyId}/send-text", compact('to', 'text'))->throw()->json();
    }

    public function sendImage(int $companyId, string $to, string $imageUrl, string $caption = ''): array
    {
        return $this->http()->post("/sessions/{$companyId}/send-image", [
            'to' => $to, 'image_url' => $imageUrl, 'caption' => $caption,
        ])->throw()->json();
    }

    public function logout(int $companyId): array
    {
        return $this->http()->post("/sessions/{$companyId}/logout")->json();
    }
}
```

### config/services.php (agregar)

```php
'whatsapp' => [
    'url'    => env('WHATSAPP_SERVICE_URL', 'http://127.0.0.1:3001'),
    'key'    => env('WHATSAPP_SERVICE_KEY'),
    'secret' => env('WHATSAPP_WEBHOOK_SECRET'),
],
```

### routes/api.php

```php
Route::post('/whatsapp/webhook', [WhatsAppWebhookController::class, 'handle']);
```

### app/Http/Controllers/WhatsAppWebhookController.php

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class WhatsAppWebhookController extends Controller
{
    public function handle(Request $request)
    {
        abort_unless(
            hash_equals(config('services.whatsapp.secret'), (string) $request->header('X-Webhook-Secret')),
            401
        );

        $event     = $request->input('event');
        $companyId = $request->input('company_id');
        $data      = $request->input('data', []);

        match ($event) {
            'qr', 'pairing_code' => null, // opcional: broadcast por websockets a la pantalla de conexión
            'connected'    => null,       // actualizar whatsapp_connections: status=connected, phone
            'disconnected' => null,       // status=disconnected (el servicio reintenta solo)
            'logged_out'   => null,       // status=logged_out: pedir revincular
            'message'      => null,       // guardar mensaje, asignar departamento, disparar agente IA
            'message_status' => null,     // actualizar entregado/leído
            default => null,
        };

        return response()->json(['ok' => true]);
    }
}
```

Variables a agregar en el `.env` de Laravel:

```env
WHATSAPP_SERVICE_URL=http://127.0.0.1:3001
WHATSAPP_SERVICE_KEY=la_misma_API_KEY_del_microservicio
WHATSAPP_WEBHOOK_SECRET=el_mismo_WEBHOOK_SECRET_del_microservicio
```

---

## 5. Instalación en el VPS

```bash
cd whatsapp-service
npm install
cp .env.example .env   # edita API_KEY, WEBHOOK_URL y WEBHOOK_SECRET
npm start              # en producción: pm2 start src/server.js --name whatsapp
```

Requiere Node 20 o superior.

---

## 6. Flujo de vinculación por código

1. Laravel llama a `POST /sessions/{empresaId}/connect` con `{"mode":"code","phone":"593991234567"}`. El número va sin `+` y con código de país.
2. El servicio responde con un `pairing_code` de 8 caracteres. También lo envía por webhook y se puede consultar en `GET /sessions/{id}/status`.
3. El cliente abre su WhatsApp, entra a Dispositivos vinculados, elige Vincular con número de teléfono e ingresa el código.
4. Cuando se vincula, llega el evento `connected` a Laravel y la sesión queda guardada en `sessions/{empresaId}`. Si se reinicia el servicio, se restaura sola.

Para vincular por QR se usa `{"mode":"qr"}`. La respuesta trae `qr` como imagen en base64 (`data:image/png;base64,...`), que se puede mostrar directamente en un `<img>`.

---

## 7. Antes de pasar a producción

- Respaldar la carpeta `sessions/`. Si se pierde, todas las empresas tienen que revincular.
- Cuando el cliente cierra la sesión desde su celular, llega el evento `logged_out`; mostrar un aviso para revincular.
- Mantener los delays y limitar los lotes masivos. Es una librería no oficial y un envío agresivo puede bloquear el número del cliente.
- El puerto 3001 no debe quedar expuesto a internet. Con Laravel en el mismo VPS basta con localhost.
- Baileys cambia con frecuencia. Si algo falla tras actualizar, revisar su changelog, sobre todo en el manejo de los identificadores `@lid`.
- El código no fue ejecutado todavía: probarlo primero en local.

## 8. Próximos pasos

- Esquema de la tabla `whatsapp_connections` y pantalla de conexión.
- Modo B con la Cloud API oficial, usando la misma interfaz de proveedor.
