Saltar al contenido principal
intermediatePart 1

Crea un asistente de IA para WhatsApp desde cero con Evolution y la API de WEC

· 16 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
WhatsApp++Evolution
0/2
🎯 Skill path0/2 earned
WhatsApp automation on WEC
  • 1Self-host a WhatsApp AI bridge
  • 🏆Production delivery via Cloud API

La mayoría de las guías de "autoalbergar un asistente de IA para WhatsApp" se detienen en "el contenedor se inició". Esta va hasta el final: despliegas una puerta de enlace de WhatsApp programable real (API de Evolution), luego escribes el puente tú mismo — las ~50 líneas que convierten un mensaje entrante en una respuesta de LLM y la envían de vuelta. Ese puente (webhook → modelo → respuesta) es el patrón reutilizable detrás de cada integración de chat-AI: SMS, Slack, Telegram, voz — cambia el canal, la forma es idéntica.

Y porque esto es una construcción real, encontramos — y solucionamos — cada detalle: una imagen que movió a los editores, un bucle de versión de Baileys, un bucle de respuesta infinito, spam en grupos de chat, la nueva dirección LID de WhatsApp, y una genuina pared de entrega que la mayoría de los tutoriales pretenden que no existe. Cada comando, error y salida a continuación es de una ejecución real.

Lee esto antes de usar tu número personal

Evolution se vincula a un número de WhatsApp como un dispositivo compañero (como WhatsApp Web) — el bot actúa como esa cuenta y puede leer cada DM y grupo que recibe. Para una demostración en un número que controlas está bien; para cualquier cosa real, usa un número dedicado. Y consulta la limitación de entrega al final antes de construir esto en producción.


Lo que construirás

Cuatro contenedores: Evolution + su Postgres + Redis, y el puente que escribirás. El puente es el objetivo principal — todo lo demás es estándar.

Requisitos previos: una instancia WEC con Docker + Compose, una clave de API de Inferencia WEC (Inferencia → Claves de API), un número de WhatsApp para la demostración, y ~2 GB de espacio libre en disco.


Paso 1 — Desplegar la pila de Evolution

Evolution necesita su propio Postgres y Redis. Le damos un conjunto dedicado en una red interna y exponemos solo el puerto de la API (8080). docker-compose.yml:

~/evolution-api/docker-compose.yml
services:
evolution-api:
image: evoapicloud/evolution-api:v2.2.3
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- evolution_instances:/evolution/instances
depends_on: [evolution-postgres, evolution-redis]
logging:
driver: json-file
options: { max-size: "50m", max-file: "3" }

evolution-postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: evolution
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: evolution
volumes:
- evolution_pgdata:/var/lib/postgresql/data

evolution-redis:
image: redis:7
restart: unless-stopped
volumes:
- evolution_redis:/data

volumes:
evolution_instances:
evolution_pgdata:
evolution_redis:
Rotación de logs desde el primer día

El bloque logging: limita los logs del contenedor a 3×50 MB. Si lo omites, un contenedor ruidoso puede llenar el disco y hacer que la máquina se caiga — una lección aprendida de la manera difícil en esta VM. Configúralo antes de necesitarlo.

El archivo .env (secretos generados, URIs de conexión y — lo más importante — una versión de WhatsApp-Web de Baileys fijada):

Generar el .env
cd ~/evolution-api && cat > .env << EOF
AUTHENTICATION_API_KEY=$(openssl rand -hex 24)
SERVER_URL=http://<tu-ip-vm>:8080
POSTGRES_PASSWORD=$(openssl rand -hex 16)
DATABASE_ENABLED=true
DATABASE_PROVIDER=postgresql
DATABASE_CONNECTION_URI=postgresql://evolution:\${POSTGRES_PASSWORD}@evolution-postgres:5432/evolution?schema=public
CACHE_REDIS_ENABLED=true
CACHE_REDIS_URI=redis://evolution-redis:6379/0
CACHE_REDIS_PREFIX_KEY=evolution
CACHE_LOCAL_ENABLED=false
QRCODE_LIMIT=30
EOF

Levántalo:

docker compose up -d
sleep 15
curl -s http://localhost:8080 | jq
Salida
{
"status": 200,
"message": "¡Bienvenido a la API de Evolution, está funcionando!",
"version": "2.2.3",
...
}

La API de Evolution responde en el puerto 8080 con la carga de bienvenida Figura 1. La pila está activa: Evolution responde en :8080 con su versión y estado.

Dos problemas reales al extraer la imagen

(1) El editor se mudó. La imagen a la que la mayoría de las guías hacen referencia — atendai/evolution-api — ahora devuelve "acceso denegado al pull / el repositorio no existe." La imagen actual es evoapicloud/evolution-api. La lista de etiquetas del antiguo repositorio aún se resuelve, lo que te envía por un camino sin salida; confirma con un pull fresco de hello-world que no está limitando la tasa. (2) Fija una etiqueta real. v2.1.1 (de un blog popular) nunca fue publicada — verifica https://hub.docker.com/v2/repositories/evoapicloud/evolution-api/tags y fija una que exista (usamos v2.2.3).


Paso 2 — Crear una instancia y vincular WhatsApp

Establece tu clave de API como una variable de shell (cada solicitud la necesita en un encabezado apikey:):

APIKEY=$(grep '^AUTHENTICATION_API_KEY=' .env | cut -d= -f2)

Crea la instancia:

curl -s -X POST http://localhost:8080/instance/create \
-H "apikey: $APIKEY" -H "Content-Type: application/json" \
-d '{"instanceName":"wec-demo","integration":"WHATSAPP-BAILEYS","qrcode":true}' | jq '.instance'

Luego abre el Administrador integrado en http://<tu-ip-vm>:8080/manager, ingresa tu URL de servidor + clave de API, haz clic en wec-demo, y escanea el QR desde WhatsApp → Configuración → Dispositivos vinculados → Vincular un dispositivo. Confirma que está conectado:

curl -s http://localhost:8080/instance/fetchInstances -H "apikey: $APIKEY" \
| jq '.[] | {name, connectionStatus}'
Salida
{ "name": "wec-demo", "connectionStatus": "open" }

El Administrador de Evolution muestra la instancia wec-demo conectada Figura 2. El panel de control del Administrador de Evolution una vez que la instancia se vincula: Conectado, con conteos de contactos, chats y mensajes en vivo.

El bucle de versión de Baileys — el fallo número 1 de autoalbergado

Si la instancia nunca sale de conectando y los logs muestran ChannelStartupService reinicializándose cada pocos segundos con una línea Baileys version env: 2,3000,..., la versión de WhatsApp-Web fijada está obsoleta y WhatsApp rechaza el apretón de manos antes de que se genere un QR. Obtén la versión actual y fíjala, luego recrea:

curl -s "https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/baileys-version.json"
# {"version":[2,3000,1035194821]}
echo "CONFIG_SESSION_PHONE_VERSION=2.3000.1035194821" >> .env
docker compose up -d --force-recreate evolution-api

Paso 3 — El puente, v1: ver el webhook

Ahora la parte que realmente escribes. Regla: nunca conectes la lógica antes de haber visto los datos reales. Así que v1 no hace nada más que imprimir lo que Evolution envía.

El puente se ejecuta como otro contenedor en el mismo Compose, por lo que Evolution lo alcanza por nombre (http://bridge:8090) y el puente alcanza a Evolution en http://evolution-api:8080. Crea bridge/main.py:

~/evolution-api/bridge/main.py (v1)
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
print("=== WEBHOOK RECIBIDO ===", flush=True)
print(data, flush=True)
return {"received": True}
flush=True es importante

Sin él, la salida de print se almacena en búfer y nunca aparece en docker compose logs — estarás mirando un log vacío convencido de que está roto.

bridge/Dockerfile:

~/evolution-api/bridge/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn requests
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]

Agrega el servicio a docker-compose.yml (dentro de services:), luego constrúyelo:

docker-compose.yml (agregar bajo services:)
bridge:
build: ./bridge
restart: unless-stopped
env_file: .env
depends_on: [evolution-api]
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
docker compose up -d --build bridge

Ahora registra el webhook para que Evolution envíe mensajes al puente:

curl -s -X POST "http://localhost:8080/webhook/set/wec-demo" \
-H "apikey: $APIKEY" -H "Content-Type: application/json" \
-d '{"webhook":{"enabled":true,"url":"http://bridge:8090/webhook","webhookByEvents":false,"events":["MESSAGES_UPSERT"]}}' | jq '.enabled, .url'

Sigue los logs (docker compose logs -f bridge) y envíate un mensaje de WhatsApp. La carga real aparece:

Un webhook MESSAGES_UPSERT real
{'event': 'messages.upsert', 'instance': 'wec-demo',
'data': {'key': {'remoteJid': '123456789012345@lid', 'fromMe': False, 'id': '...'},
'pushName': 'Contacto de Prueba',
'message': {'conversation': 'Oii'},
'messageType': 'conversation'},
'sender': '[email protected]', ...}

Tres cosas que ahora sabemos — y cada una impulsa el código siguiente:

  • text está en data.message.conversation (plano) o data.message.extendedTextMessage.text (citado)
  • data.key.fromMeTrue para nuestros propios mensajes (el guardia de bucle)
  • remoteJid termina en @lid — el identificador de privacidad de WhatsApp, no un número de teléfono (esta es una saga; Paso 5)

Paso 4 — v2: leer, pensar, responder (con el guardia de bucle)

Dale al puente la clave de Inferencia WEC para que pueda llamar al modelo:

echo "WEC_API_KEY=<tu-clave-de-inferencia-wec>" >> ~/evolution-api/.env

Ahora la lógica — main.py v2:

~/evolution-api/bridge/main.py (v2)
import os, requests
from fastapi import FastAPI, Request

app = FastAPI()
EVOLUTION_URL = "http://evolution-api:8080"
INSTANCE = "wec-demo"
EVOLUTION_APIKEY = os.environ["AUTHENTICATION_API_KEY"]
WEC_API_KEY = os.environ["WEC_API_KEY"]
WEC_URL = "https://inference.wiline.com/v1/chat/completions"
MODEL = "Qwen2.5-3B-Instruct"

def ask_llm(text: str) -> str:
r = requests.post(WEC_URL,
headers={"Authorization": f"Bearer {WEC_API_KEY}"},
json={"model": MODEL, "messages": [{"role": "user", "content": text}]},
timeout=60)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]

def send_whatsapp(number: str, text: str):
r = requests.post(f"{EVOLUTION_URL}/message/sendText/{INSTANCE}",
headers={"apikey": EVOLUTION_APIKEY, "Content-Type": "application/json"},
json={"number": number, "text": text}, timeout=60)
print(f"ENVIAR -> {r.status_code}", flush=True)

@app.post("/webhook")
async def webhook(request: Request):
data = (await request.json()).get("data", {})
key = data.get("key", {})

if key.get("fromMe"): # (1) GUARDIA DE BUCLE
return {"skipped": "fromMe"}

msg = data.get("message", {}) # (2) extraer texto
text = msg.get("conversation") or msg.get("extendedTextMessage", {}).get("text")
if not text:
return {"skipped": "no text"}

to = key.get("remoteJid")
print(f"ENTRADA {to}: {text}", flush=True)
answer = ask_llm(text) # (3) pensar
send_whatsapp(to, answer) # (4) responder
print(f"SALIDA {to}: {answer[:60]}...", flush=True)
return {"ok": True}

La única línea que los principiantes siempre pasan por alto es (1) el guardia de bucle. Cuando el puente envía una respuesta, ese mensaje saliente dispara otro webhook MESSAGES_UPSERT con fromMe: true — sin el guardia, el puente se responde a sí mismo, para siempre. Reconstruye:

docker compose up -d --build bridge

El puente registrando ENTRADA y SALIDA para un DM en vivo Figura 3. v2 en acción: un DM llega, el modelo responde, la respuesta se envía.

Ahora responde a DMs. Pero observa lo que sucede a continuación.


Paso 5 — Las dos verdaderas paredes: spam en grupos, luego LID

Sigue los logs con el bot en vivo y verás que intenta responder a cada grupo en el que estás, incluidos grupos de promoción/spam, y Evolution se agota al enviarles — errores de inundación:

Los grupos inundan el puente
ENTRADA [email protected]: *ALÔ CORREDORES 🏃 ... pechin.co/135449*
requests.exceptions.ReadTimeout: HTTPConnectionPool(host='evolution-api', port=8080): Read timed out.

Una cuenta compañera recibe todo. Solución: responder solo a DMs — los grupos terminan en @g.us:

to = key.get("remoteJid", "")
if to.endswith("@g.us") or "broadcast" in to: # Solo DMs
return {"skipped": "not a DM"}

Ahora la pared más sutil. Con los grupos filtrados, un DM real llega — pero la respuesta es rechazada:

ENVIAR -> 400
{"status":400,"error":"Bad Request","response":{"message":[
{"exists":false,"jid":"123456789012345@lid","name":"Contacto de Prueba","number":"123456789012345@lid"}]}}

Evolution rechazando una respuesta a una dirección @lid con exists Figura 4. La pared LID en los logs: el DM entrante llega como @lid, y la respuesta es rechazada con exists:false.

exists: false. Esta es la dirección LID de WhatsApp (un cambio de privacidad de 2025): los DMs entrantes llegan con un identificador @lid, y no puedes enviar de vuelta a un @lid — Evolution necesita el verdadero JID de teléfono @s.whatsapp.net. Sin embargo, el almacén de contactos de Evolution tiene ambos:

curl -s -X POST "http://localhost:8080/chat/findContacts/wec-demo" \
-H "apikey: $APIKEY" -H "Content-Type: application/json" -d '{}' \
| jq '[.[] | select(.pushName=="Contacto de Prueba")]'
Salida — la misma persona, dos identidades
[ { "remoteJid": "123456789012345@lid", "pushName": "Contacto de Prueba" },
{ "remoteJid": "[email protected]", "pushName": "Contacto de Prueba" } ]

findContacts devolviendo el LID y el JID de teléfono para un contacto Figura 5. Evolution almacena ambas identidades para la misma persona — el @lid y el verdadero JID de teléfono. Esa es la clave para resolver la dirección de respuesta.

Así que resolvemos el @lid al número de teléfono antes de responder. Agrega un resolvedor y úsalo:

def resolve_number(remote_jid: str, push_name: str):
if remote_jid.endswith("@s.whatsapp.net"):
return remote_jid.split("@")[0]
# LID: encontrar el número real del contacto haciendo coincidir el nombre
contacts = requests.post(f"{EVOLUTION_URL}/chat/findContacts/{INSTANCE}",
headers={"apikey": EVOLUTION_APIKEY, "Content-Type": "application/json"},
json={}, timeout=30).json()
for c in contacts:
if c.get("pushName") == push_name and c.get("remoteJid", "").endswith("@s.whatsapp.net"):
return c["remoteJid"].split("@")[0]
return None

En el manejador, resuelve antes de enviar:

number = resolve_number(to, data.get("pushName", ""))
if not number:
return {"skipped": "unresolved lid"}
...
send_whatsapp(number, answer)

Ahora ENVIAR -> 201. (Hacer coincidir por nombre es un heurístico — dos contactos que comparten un nombre colisionarían; la producción mantiene un mapa adecuado LID→teléfono. Pero funciona, y es el estado honesto del soporte LID de Evolution en v2.2.3.)


Paso 6 — Habla WhatsApp, no Markdown

Los LLM emiten Markdown (**negrita**, [texto](url)), pero WhatsApp tiene su propio formato: la negrita es *asterisco simple*, y los enlaces de Markdown no se renderizan. Así que **Compute** aparece con asteriscos literales. Traduce la salida del modelo antes de enviar, y agrega las fuentes del RAG como URLs simples:

import re

def to_whatsapp(md: str) -> str:
md = re.sub(r"\*\*(.+?)\*\*", r"*\1*", md) # **negrita** -> *negrita*
md = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1 (\2)", md) # [t](url) -> t (url)
md = re.sub(r"(?m)^#{1,6}\s*", "", md) # eliminar encabezados #
return md

Verificado contra una respuesta real — exactamente lo que WhatsApp renderizará:

Salida formateada (determinista, no se necesita entrega)
Para crear una instancia de computación en WiLine Edge Cloud (WEC), sigue estos pasos:

1. Inicia sesión en WiLine Edge Cloud.
2. En la barra lateral, haz clic en *Compute*.
3. Selecciona *Instances*.
4. Haz clic en el asistente "Launch Virtual Machine" para comenzar el proceso de despliegue.

_Fuentes:_
https://wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/

La respuesta formateada de Markdown a WhatsApp Figura 6. La salida del modelo después de to_whatsapp(): negrita con asterisco simple, enlaces simples, fuentes añadidas.


Mejora — apunta el puente a tu documentación (RAG)

Todo hasta ahora usa el modelo en bruto, así que una pregunta de WEC obtiene una respuesta general. Para hacer que responda desde tu documentación, cambia ask_llm para llamar al servicio RAG de la serie de evalsuna función, nada más cambia:

RAG_URL = "http://<tu-ip-vm>:8000/ask"

def ask_llm(text: str) -> str:
r = requests.post(RAG_URL, json={"question": text}, timeout=120)
r.raise_for_status()
d = r.json()
answer = to_whatsapp(d["answer"])
sources = d.get("sources", [])[:2]
if sources:
answer += "\n\n_Fuentes:_\n" + "\n".join(sources)
return answer

Ese es el beneficio del patrón de puente: cambia el cerebro, mantén la plomería. Ahora WhatsApp responde a preguntas de WEC desde la documentación real, con fuentes:

WhatsApp entregando una respuesta fundamentada en documentos de WEC Figura 7. Un mensaje real en → el puente → RAG sobre la documentación de WEC → una respuesta fundamentada entregada en WhatsApp, fuentes incluidas.

Habilidad desbloqueada 🏅

Has autoalbergado una puerta de enlace programable de WhatsApp y escribiste el puente que lo convierte en un asistente de IA — webhook entrante, modelo saliente, respuesta de vuelta — fundamentado en tu propia documentación.


La pared de entrega

Aquí está la parte que otros tutoriales no te dirán. Incluso con ENVIAR -> 201, las respuestas a una cuenta migrada a LID frecuentemente llegan como "Esperando este mensaje" en el teléfono del destinatario — WhatsApp aceptó el texto cifrado pero ningún dispositivo puede descifrarlo. Es una limitación conocida del dispositivo compañero de Baileys, y el despliegue de LID de WhatsApp lo empeora.

Lo que observamos, honestamente:

  • Un nuevo re-vinculo (cierra sesión en el dispositivo, escanea un nuevo QR) compra una ventana corta donde la entrega funciona sin problemas.
  • Después de un tiempo, la sesión se degrada y "Esperando este mensaje" regresa — aunque cada envío aún informa 201.

Así que el puente es correcto de extremo a extremo; el transporte no oficial de Baileys es el eslabón débil. Si estás construyendo esto para algo real:

  • Usa un número dedicado, no LID — el problema está vinculado a cuentas migradas a LID.
  • Para una fiabilidad de grado de producción, usa la API oficial de WhatsApp Cloud, o — si tu objetivo es un agente de IA en lugar de una puerta de enlace programable — el canal nativo de WhatsApp de OpenClaw, que maneja la sesión por ti.

Evolution brilla para automatización saliente a números que controlas (notificaciones, alertas, flujos). Como asistente de IA bidireccional en una cuenta personal de LID, trata la entrega como un esfuerzo mejor.


Resumen de solución de problemas

  • acceso denegado al pull en atendai/evolution-api → imagen movida a evoapicloud/evolution-api.
  • Instancia atascada en conectando, bucle de reinicio → versión de Baileys obsoleta; fija CONFIG_SESSION_PHONE_VERSION.
  • El puente se responde a sí mismo para siempre → falta el guardia de bucle fromMe.
  • Inundación de mensajes de grupo / tiempos de espera de envío → filtra @g.us; responde solo a DMs.
  • ENVIAR -> 400 exists:false ...@lid → resuelve el LID al JID de teléfono a través de findContacts.
  • **asteriscos** en respuestas → traduce Markdown al formato de WhatsApp.
  • "Esperando este mensaje" → pared de cifrado de Baileys/LID; un nuevo re-vinculo es una ventana temporal; usa un número no LID / API Cloud para producción.

Desmantelamiento

cd ~/evolution-api && docker compose down # agrega -v para también borrar la DB/sesión

Desvincula el dispositivo en WhatsApp → Dispositivos vinculados si has terminado.

Finished this tutorial?
Mark it complete to earn Self-host a WhatsApp AI bridge on your skill path.

¿Qué sigue?

Has construido el patrón de puente reutilizable — webhook → resolver → modelo → respuesta. Apúntalo a un cerebro diferente, a un canal diferente, o agrega herramientas. Si deseas un agente de IA en WhatsApp sin las advertencias de Baileys, el canal nativo de WhatsApp de OpenClaw es el camino gestionado; para respuestas fundamentadas en documentos, la serie de evals y observabilidad construye el servicio RAG en el que se conecta este tutorial.