From 13bfa355a66d087d406d09d6eae129305c245294 Mon Sep 17 00:00:00 2001 From: Martin Rodriguez Date: Wed, 25 Feb 2026 21:49:19 -0600 Subject: [PATCH 1/2] feat: (update) nix done --- .dockerignore | 13 ++- .env.easypanel.sample | 91 +++++++++++++++ .envrc | 1 + .gitignore | 12 +- EASYPANEL.md | 200 ++++++++++++++++++++++++++++++++ README.md | 16 +++ docker-compose-easypanel.yml | 151 ++++++++++++++++++++++++ easypanel.json | 215 +++++++++++++++++++++++++++++++++++ flake.lock | 61 ++++++++++ flake.nix | 33 ++++++ shell.nix | 16 +++ 11 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 .env.easypanel.sample create mode 100644 .envrc create mode 100644 EASYPANEL.md create mode 100644 docker-compose-easypanel.yml create mode 100644 easypanel.json create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 shell.nix diff --git a/.dockerignore b/.dockerignore index 75036adb..3631e2bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,10 +24,21 @@ API.md LICENSE *.md +# EasyPanel and deployment configs (not needed in container) +easypanel.json +docker-compose*.yml +docker-compose*.yaml + +# Nix files +*.nix +flake.lock + # Others .DS_Store .idea/ .vscode/ *.swp *.swo -tmp/ \ No newline at end of file +tmp/ +*.test +*_test.go \ No newline at end of file diff --git a/.env.easypanel.sample b/.env.easypanel.sample new file mode 100644 index 00000000..55a1c24f --- /dev/null +++ b/.env.easypanel.sample @@ -0,0 +1,91 @@ +# =========================================== +# WuzAPI - EasyPanel Environment Variables +# =========================================== +# Copy this file and configure the values below +# in EasyPanel's Environment Variables section + +# ------------------------------------------- +# REQUIRED - Security Tokens +# ------------------------------------------- + +# Admin token for managing users and sessions +# Generate a secure random string (minimum 32 characters) +WUZAPI_ADMIN_TOKEN=your_secure_admin_token_here_32chars + +# Encryption key for sensitive data storage (exactly 32 characters for AES-256) +# IMPORTANT: Save this key! Losing it means losing access to all encrypted data +WUZAPI_GLOBAL_ENCRYPTION_KEY=your_32_character_encryption_key + +# HMAC key for webhook signature verification (minimum 32 characters) +WUZAPI_GLOBAL_HMAC_KEY=your_hmac_key_minimum_32_characters + +# ------------------------------------------- +# REQUIRED - Database Configuration +# ------------------------------------------- + +# PostgreSQL credentials +DB_USER=wuzapi +DB_PASSWORD=your_secure_postgres_password +DB_NAME=wuzapi + +# These are set automatically by docker-compose +# DB_HOST=db +# DB_PORT=5432 +# DB_SSLMODE=disable + +# ------------------------------------------- +# OPTIONAL - Webhook Configuration +# ------------------------------------------- + +# Global webhook URL to receive all events from all users +# Leave empty to disable global webhook +WUZAPI_GLOBAL_WEBHOOK= + +# Webhook format: json or form +WEBHOOK_FORMAT=json + +# Webhook retry settings +WEBHOOK_RETRY_ENABLED=true +WEBHOOK_RETRY_COUNT=5 +WEBHOOK_RETRY_DELAY_SECONDS=30 + +# ------------------------------------------- +# OPTIONAL - Application Settings +# ------------------------------------------- + +# Timezone for server operations +TZ=America/Sao_Paulo + +# Device name shown in WhatsApp +SESSION_DEVICE_NAME=WuzAPI + +# Your domain (used for Traefik labels in EasyPanel) +DOMAIN=wuzapi.yourdomain.com + +# ------------------------------------------- +# OPTIONAL - RabbitMQ Configuration +# ------------------------------------------- + +# RabbitMQ credentials (if using message queue) +RABBITMQ_USER=wuzapi +RABBITMQ_PASSWORD=your_secure_rabbitmq_password +RABBITMQ_QUEUE=whatsapp_events + +# ------------------------------------------- +# NOTES FOR EASYPANEL +# ------------------------------------------- +# +# 1. Mark these variables as "Secret" in EasyPanel: +# - WUZAPI_ADMIN_TOKEN +# - WUZAPI_GLOBAL_ENCRYPTION_KEY +# - WUZAPI_GLOBAL_HMAC_KEY +# - DB_PASSWORD +# - RABBITMQ_PASSWORD +# +# 2. After deployment, access: +# - Dashboard: https://your-domain/dashboard +# - API Docs: https://your-domain/api +# - QR Login: https://your-domain/login +# +# 3. For SSL, EasyPanel handles Let's Encrypt automatically +# when you configure a domain in the Domains section diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 19e6158c..bb7d1022 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,18 @@ dbdata/ files/ wuzapi .env +.direnv/ .tool-versions +# Nix build outputs (keep source files) +result +result-* + +# Local environment samples (keep .env.sample, ignore others) +.env.* +!.env.sample +!.env.easypanel.sample + # Added by Claude Task Master # Logs logs @@ -39,4 +49,4 @@ NEW_FEATS.md *.code-workspace **/*.code-workspace -.vscode/workspaceStorage/ \ No newline at end of file +.vscode/workspaceStorage/ diff --git a/EASYPANEL.md b/EASYPANEL.md new file mode 100644 index 00000000..07a5bd33 --- /dev/null +++ b/EASYPANEL.md @@ -0,0 +1,200 @@ +# Despliegue de WuzAPI en EasyPanel + +Esta guia explica como desplegar WuzAPI en [EasyPanel](https://easypanel.io). + +## Requisitos Previos + +- Una instancia de EasyPanel funcionando (v1.0+) +- Acceso al panel de administracion de EasyPanel +- Un dominio configurado (opcional pero recomendado) + +## Metodo 1: Despliegue con Docker Compose (Recomendado) + +### Paso 1: Crear un nuevo proyecto + +1. Accede a tu panel de EasyPanel +2. Haz clic en **"Create Project"** +3. Asigna un nombre al proyecto, por ejemplo: `wuzapi` + +### Paso 2: Agregar el servicio + +1. Dentro del proyecto, haz clic en **"+ Service"** +2. Selecciona **"Docker Compose"** +3. Copia el contenido de `docker-compose-easypanel.yml` o apunta al repositorio + +### Paso 3: Configurar variables de entorno + +En la seccion de **Environment Variables** de EasyPanel, configura: + +#### Variables Requeridas + +```env +# Token de administrador (genera uno seguro) +WUZAPI_ADMIN_TOKEN=tu_token_seguro_aqui_32_caracteres + +# Clave de encriptacion (exactamente 32 caracteres para AES-256) +WUZAPI_GLOBAL_ENCRYPTION_KEY=tu_clave_encriptacion_32_chars + +# Clave HMAC para firmar webhooks (minimo 32 caracteres) +WUZAPI_GLOBAL_HMAC_KEY=tu_clave_hmac_minimo_32_caracteres + +# Password de la base de datos +DB_PASSWORD=tu_password_seguro_postgres +``` + +#### Variables Opcionales + +```env +# Webhook global para recibir todos los eventos +WUZAPI_GLOBAL_WEBHOOK=https://tu-servidor.com/webhook + +# Zona horaria +TZ=America/Sao_Paulo + +# Formato de webhook: json o form +WEBHOOK_FORMAT=json + +# Nombre del dispositivo en WhatsApp +SESSION_DEVICE_NAME=WuzAPI + +# Configuracion de reintentos de webhook +WEBHOOK_RETRY_ENABLED=true +WEBHOOK_RETRY_COUNT=5 +WEBHOOK_RETRY_DELAY_SECONDS=30 + +# Password de RabbitMQ (opcional) +RABBITMQ_PASSWORD=tu_password_rabbitmq +``` + +### Paso 4: Configurar dominio + +1. Ve a la seccion **"Domains"** del servicio `wuzapi` +2. Agrega tu dominio: `wuzapi.tudominio.com` +3. EasyPanel configurara automaticamente SSL con Let's Encrypt + +### Paso 5: Desplegar + +1. Haz clic en **"Deploy"** +2. Espera a que todos los servicios inicien (puede tomar 2-3 minutos) +3. Verifica el estado en la seccion **"Logs"** + +## Metodo 2: Despliegue con Template de EasyPanel + +Si prefieres usar el archivo `easypanel.json`: + +1. En EasyPanel, ve a **Settings > Templates** +2. Haz clic en **"Import Template"** +3. Pega el contenido de `easypanel.json` +4. Guarda y despliega desde la seccion de templates + +## Metodo 3: Despliegue desde GitHub + +1. Crea un nuevo servicio en EasyPanel +2. Selecciona **"GitHub"** como fuente +3. Conecta tu repositorio de WuzAPI +4. Selecciona la rama `main` +5. EasyPanel detectara automaticamente el `Dockerfile` +6. Configura las variables de entorno como se indica arriba + +## Estructura de Servicios + +El despliegue incluye tres servicios: + +| Servicio | Puerto | Descripcion | +|----------|--------|-------------| +| `wuzapi` | 8080 | API principal de WhatsApp | +| `db` | 5432 | PostgreSQL para datos persistentes | +| `rabbitmq` | 5672/15672 | Cola de mensajes (opcional) | + +## Acceso a los Servicios + +### API de WuzAPI +- URL: `https://wuzapi.tudominio.com` +- Dashboard: `https://wuzapi.tudominio.com/dashboard` +- Documentacion API: `https://wuzapi.tudominio.com/api` +- Login QR: `https://wuzapi.tudominio.com/login` + +### RabbitMQ Management (opcional) +- URL: `https://rabbitmq.tudominio.com` (si configuraste el subdominio) +- Usuario: `wuzapi` +- Password: El valor de `RABBITMQ_PASSWORD` + +## Verificar el Despliegue + +1. Accede a `https://wuzapi.tudominio.com/api` +2. Deberias ver la documentacion de Swagger +3. Prueba el endpoint de health: `GET /session/status` + +## Persistencia de Datos + +Los siguientes volumenes se crean automaticamente: + +- `wuzapi_data`: Datos de sesion de WhatsApp +- `wuzapi_postgres_data`: Base de datos PostgreSQL +- `wuzapi_rabbitmq_data`: Datos de RabbitMQ + +**Importante**: Realiza backups periodicos de estos volumenes. + +## Escalado + +Para escalar WuzAPI horizontalmente: + +1. Ve a la configuracion del servicio `wuzapi` +2. En **"Deploy"**, ajusta el numero de replicas +3. Nota: Cada replica necesita su propia sesion de WhatsApp + +## Monitoreo + +EasyPanel proporciona: + +- **Logs**: Ver logs en tiempo real de cada servicio +- **Metricas**: CPU, memoria y red +- **Alertas**: Configura notificaciones para caidas + +## Solucion de Problemas + +### El servicio no inicia + +1. Verifica los logs en EasyPanel +2. Asegurate de que las variables de entorno esten configuradas +3. Verifica que el puerto 8080 no este en uso + +### Error de conexion a la base de datos + +1. Espera a que el servicio `db` este saludable +2. Verifica que `DB_HOST=db` este configurado +3. Revisa los logs de PostgreSQL + +### No se puede escanear el QR + +1. Verifica que el servicio este corriendo +2. Accede a `https://wuzapi.tudominio.com/login` +3. Revisa los logs para errores de WhatsApp + +### Webhook no funciona + +1. Verifica que `WUZAPI_GLOBAL_WEBHOOK` este configurado +2. Asegurate de que la URL sea accesible desde el servidor +3. Revisa los logs para errores de conexion + +## Actualizaciones + +Para actualizar WuzAPI: + +1. En EasyPanel, ve al servicio `wuzapi` +2. Haz clic en **"Rebuild"** +3. EasyPanel descargara la ultima version y reiniciara el servicio + +## Recursos + +- [Documentacion de WuzAPI](https://github.com/asternic/wuzapi) +- [Documentacion de EasyPanel](https://easypanel.io/docs) +- [API Reference](/api) + +## Soporte + +Si encuentras problemas: + +1. Revisa los [issues de GitHub](https://github.com/asternic/wuzapi/issues) +2. Consulta la [documentacion de EasyPanel](https://easypanel.io/docs) +3. Abre un nuevo issue con los logs relevantes diff --git a/README.md b/README.md index fcfb0624..d4bff2b2 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,22 @@ The Docker configuration will: **Note:** The `.env` file is already included in `.gitignore` to avoid committing sensitive information to your repository. +### EasyPanel Deployment + +WuzAPI can be easily deployed to [EasyPanel](https://easypanel.io). We provide ready-to-use configuration files: + +1. **Quick Start**: Use `docker-compose-easypanel.yml` with EasyPanel's Docker Compose support +2. **Template**: Import `easypanel.json` as an EasyPanel template + +**Required environment variables for EasyPanel:** +``` +WUZAPI_ADMIN_TOKEN=your_secure_token +WUZAPI_GLOBAL_ENCRYPTION_KEY=your_32_char_key +DB_PASSWORD=your_db_password +``` + +For detailed instructions, see [EASYPANEL.md](EASYPANEL.md). + ## Usage To interact with the API, you must include the `Authorization` header in HTTP requests, containing the user's authentication token. You can have multiple users (different WhatsApp numbers) on the same server. diff --git a/docker-compose-easypanel.yml b/docker-compose-easypanel.yml new file mode 100644 index 00000000..9688960a --- /dev/null +++ b/docker-compose-easypanel.yml @@ -0,0 +1,151 @@ +# Docker Compose for EasyPanel deployment +# This file is optimized for EasyPanel's Docker Compose support +# +# Usage in EasyPanel: +# 1. Create a new project in EasyPanel +# 2. Select "Docker Compose" as the deployment method +# 3. Paste this file or point to the repository +# 4. Configure the environment variables in EasyPanel's UI + +services: + wuzapi: + build: + context: . + dockerfile: Dockerfile + container_name: wuzapi + restart: unless-stopped + ports: + - "8080:8080" + environment: + # Required - Set these in EasyPanel's Environment Variables section + - WUZAPI_ADMIN_TOKEN=${WUZAPI_ADMIN_TOKEN:?WUZAPI_ADMIN_TOKEN is required} + - WUZAPI_GLOBAL_ENCRYPTION_KEY=${WUZAPI_GLOBAL_ENCRYPTION_KEY:?WUZAPI_GLOBAL_ENCRYPTION_KEY is required} + - WUZAPI_GLOBAL_HMAC_KEY=${WUZAPI_GLOBAL_HMAC_KEY:-} + - WUZAPI_GLOBAL_WEBHOOK=${WUZAPI_GLOBAL_WEBHOOK:-} + + # Database Configuration + - DB_USER=${DB_USER:-wuzapi} + - DB_PASSWORD=${DB_PASSWORD:-wuzapi} + - DB_NAME=${DB_NAME:-wuzapi} + - DB_HOST=db + - DB_PORT=5432 + - DB_SSLMODE=disable + + # Application Settings + - TZ=${TZ:-America/Sao_Paulo} + - WEBHOOK_FORMAT=${WEBHOOK_FORMAT:-json} + - SESSION_DEVICE_NAME=${SESSION_DEVICE_NAME:-WuzAPI} + + # Webhook Retry Settings + - WEBHOOK_RETRY_ENABLED=${WEBHOOK_RETRY_ENABLED:-true} + - WEBHOOK_RETRY_COUNT=${WEBHOOK_RETRY_COUNT:-5} + - WEBHOOK_RETRY_DELAY_SECONDS=${WEBHOOK_RETRY_DELAY_SECONDS:-30} + + # RabbitMQ (Optional - remove if not using message queue) + - RABBITMQ_URL=amqp://${RABBITMQ_USER:-wuzapi}:${RABBITMQ_PASSWORD:-wuzapi}@rabbitmq:5672/ + - RABBITMQ_QUEUE=${RABBITMQ_QUEUE:-whatsapp_events} + volumes: + - wuzapi_data:/app/data + networks: + - wuzapi-network + depends_on: + db: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + labels: + # EasyPanel labels for automatic configuration + - "easypanel.enable=true" + - "easypanel.http.routers.wuzapi.rule=Host(`${DOMAIN:-wuzapi.localhost}`)" + - "easypanel.http.routers.wuzapi.entrypoints=websecure" + - "easypanel.http.routers.wuzapi.tls.certresolver=letsencrypt" + - "easypanel.http.services.wuzapi.loadbalancer.server.port=8080" + + db: + image: postgres:16-alpine + container_name: wuzapi-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-wuzapi} + POSTGRES_PASSWORD: ${DB_PASSWORD:-wuzapi} + POSTGRES_DB: ${DB_NAME:-wuzapi} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - wuzapi-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-wuzapi} -d ${DB_NAME:-wuzapi}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.1' + memory: 128M + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: wuzapi-rabbitmq + hostname: rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-wuzapi} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-wuzapi} + RABBITMQ_DEFAULT_VHOST: / + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - wuzapi-network + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 128M + labels: + # Optional: Expose RabbitMQ Management UI + - "easypanel.enable=true" + - "easypanel.http.routers.rabbitmq.rule=Host(`rabbitmq.${DOMAIN:-wuzapi.localhost}`)" + - "easypanel.http.routers.rabbitmq.entrypoints=websecure" + - "easypanel.http.services.rabbitmq.loadbalancer.server.port=15672" + +networks: + wuzapi-network: + driver: bridge + name: wuzapi-network + +volumes: + wuzapi_data: + name: wuzapi_data + postgres_data: + name: wuzapi_postgres_data + rabbitmq_data: + name: wuzapi_rabbitmq_data diff --git a/easypanel.json b/easypanel.json new file mode 100644 index 00000000..450b7c55 --- /dev/null +++ b/easypanel.json @@ -0,0 +1,215 @@ +{ + "$schema": "https://easypanel.io/schema/v1.json", + "name": "wuzapi", + "description": "WuzAPI - WhatsApp REST API with multiple device support", + "services": [ + { + "type": "app", + "name": "wuzapi", + "source": { + "type": "github", + "owner": "asternic", + "repo": "wuzapi", + "ref": "main", + "path": "/" + }, + "build": { + "type": "dockerfile", + "file": "Dockerfile" + }, + "domains": [ + { + "host": "$(EASYPANEL_DOMAIN)" + } + ], + "ports": [ + { + "published": 8080, + "target": 8080, + "protocol": "http" + } + ], + "env": [ + { + "name": "WUZAPI_ADMIN_TOKEN", + "value": "${WUZAPI_ADMIN_TOKEN}", + "secret": true + }, + { + "name": "WUZAPI_GLOBAL_ENCRYPTION_KEY", + "value": "${WUZAPI_GLOBAL_ENCRYPTION_KEY}", + "secret": true + }, + { + "name": "WUZAPI_GLOBAL_HMAC_KEY", + "value": "${WUZAPI_GLOBAL_HMAC_KEY}", + "secret": true + }, + { + "name": "WUZAPI_GLOBAL_WEBHOOK", + "value": "${WUZAPI_GLOBAL_WEBHOOK:-}" + }, + { + "name": "DB_USER", + "value": "wuzapi" + }, + { + "name": "DB_PASSWORD", + "value": "${DB_PASSWORD}", + "secret": true + }, + { + "name": "DB_NAME", + "value": "wuzapi" + }, + { + "name": "DB_HOST", + "value": "wuzapi-db" + }, + { + "name": "DB_PORT", + "value": "5432" + }, + { + "name": "DB_SSLMODE", + "value": "disable" + }, + { + "name": "TZ", + "value": "${TZ:-America/Sao_Paulo}" + }, + { + "name": "WEBHOOK_FORMAT", + "value": "${WEBHOOK_FORMAT:-json}" + }, + { + "name": "SESSION_DEVICE_NAME", + "value": "${SESSION_DEVICE_NAME:-WuzAPI}" + }, + { + "name": "RABBITMQ_URL", + "value": "amqp://wuzapi:${RABBITMQ_PASSWORD:-wuzapi}@wuzapi-rabbitmq:5672/" + }, + { + "name": "RABBITMQ_QUEUE", + "value": "${RABBITMQ_QUEUE:-whatsapp_events}" + } + ], + "mounts": [ + { + "type": "volume", + "name": "wuzapi-data", + "mountPath": "/app/data" + } + ], + "deploy": { + "replicas": 1, + "resources": { + "limits": { + "memory": "512M", + "cpu": "500m" + }, + "reservations": { + "memory": "256M", + "cpu": "250m" + } + } + }, + "healthCheck": { + "path": "/api", + "port": 8080, + "interval": "30s", + "timeout": "10s", + "retries": 3 + } + }, + { + "type": "postgres", + "name": "wuzapi-db", + "image": "postgres:16", + "env": [ + { + "name": "POSTGRES_USER", + "value": "wuzapi" + }, + { + "name": "POSTGRES_PASSWORD", + "value": "${DB_PASSWORD}", + "secret": true + }, + { + "name": "POSTGRES_DB", + "value": "wuzapi" + } + ], + "mounts": [ + { + "type": "volume", + "name": "postgres-data", + "mountPath": "/var/lib/postgresql/data" + } + ], + "deploy": { + "resources": { + "limits": { + "memory": "512M", + "cpu": "500m" + } + } + } + }, + { + "type": "app", + "name": "wuzapi-rabbitmq", + "image": "rabbitmq:3-management", + "ports": [ + { + "published": 15672, + "target": 15672, + "protocol": "http" + } + ], + "env": [ + { + "name": "RABBITMQ_DEFAULT_USER", + "value": "wuzapi" + }, + { + "name": "RABBITMQ_DEFAULT_PASS", + "value": "${RABBITMQ_PASSWORD:-wuzapi}", + "secret": true + }, + { + "name": "RABBITMQ_DEFAULT_VHOST", + "value": "/" + } + ], + "mounts": [ + { + "type": "volume", + "name": "rabbitmq-data", + "mountPath": "/var/lib/rabbitmq" + } + ], + "deploy": { + "resources": { + "limits": { + "memory": "256M", + "cpu": "250m" + } + } + } + } + ], + "volumes": [ + { + "name": "wuzapi-data" + }, + { + "name": "postgres-data" + }, + { + "name": "rabbitmq-data" + } + ] +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..bf24a075 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..b087d6a0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,33 @@ +{ + description = "wuzapi development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go_1_24 + gopls + delve + gotools + golangci-lint + air + ]; + + shellHook = '' + export GOWORK=off + echo "Nix dev shell listo: $(go version)" + ''; + }; + }); +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..c7f8d464 --- /dev/null +++ b/shell.nix @@ -0,0 +1,16 @@ +{pkgs ? import {}}: +pkgs.mkShell { + packages = with pkgs; [ + go_1_24 + gopls + delve + gotools + golangci-lint + air + ]; + + shellHook = '' + export GOWORK=off + echo "Nix dev shell listo: $(go version)" + ''; +} From b2667cccf750dd48de0521cf70c19145f5b1ac28 Mon Sep 17 00:00:00 2001 From: Martin Rodriguez Date: Thu, 19 Mar 2026 15:27:43 -0600 Subject: [PATCH 2/2] feat: (update) new event chat new done --- constants.go | 3 + handlers.go | 103 +++++++++++++++++------------- main.go | 1 + static/dashboard/index.html | 4 ++ static/docs/index.html | 28 ++++++++ wmiau.go | 123 +++++++++++++++++++++++++++++++++++- 6 files changed, 218 insertions(+), 44 deletions(-) diff --git a/constants.go b/constants.go index 5d9de9e2..aabb474d 100644 --- a/constants.go +++ b/constants.go @@ -71,6 +71,9 @@ var supportedEventTypes = []string{ // Facebook/Meta Bridge "FBMessage", + // Chat lifecycle + "ChatNew", + // Special - receives all events "All", } diff --git a/handlers.go b/handlers.go index a44d1b27..385e8833 100644 --- a/handlers.go +++ b/handlers.go @@ -177,18 +177,18 @@ func (s *server) authalice(next http.Handler) http.Handler { log.Debug().Str("userId", txtid).Bool("historyValid", history.Valid).Int64("historyValue", history.Int64).Str("historyStr", historyStr).Msg("User authentication - history debug") v := Values{map[string]string{ - "Id": txtid, - "Name": name, - "Jid": jid, - "Webhook": webhook, - "Token": token, - "Proxy": proxy_url, - "Events": events, - "Qrcode": qrcode, - "History": historyStr, - "HasHmac": strconv.FormatBool(hasHmac), - "S3Enabled": s3Enabled, - "MediaDelivery": mediaDelivery, + "Id": txtid, + "Name": name, + "Jid": jid, + "Webhook": webhook, + "Token": token, + "Proxy": proxy_url, + "Events": events, + "Qrcode": qrcode, + "History": historyStr, + "HasHmac": strconv.FormatBool(hasHmac), + "S3Enabled": s3Enabled, + "MediaDelivery": mediaDelivery, }} userinfocache.Set(token, v, cache.NoExpiration) @@ -813,13 +813,13 @@ func (s *server) GetStatus() http.HandlerFunc { func (s *server) SendDocument() http.HandlerFunc { type documentStruct struct { - Caption string - Phone string - Document string - FileName string - Id string - MimeType string - ContextInfo waE2E.ContextInfo + Caption string + Phone string + Document string + FileName string + Id string + MimeType string + ContextInfo waE2E.ContextInfo QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } @@ -973,15 +973,15 @@ func (s *server) SendDocument() http.HandlerFunc { func (s *server) SendAudio() http.HandlerFunc { type audioStruct struct { - Phone string - Audio string - Caption string - Id string - PTT *bool `json:"ptt,omitempty"` - MimeType string `json:"mimetype,omitempty"` - Seconds uint32 - Waveform []byte - ContextInfo waE2E.ContextInfo + Phone string + Audio string + Caption string + Id string + PTT *bool `json:"ptt,omitempty"` + MimeType string `json:"mimetype,omitempty"` + Seconds uint32 + Waveform []byte + ContextInfo waE2E.ContextInfo QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } @@ -1675,11 +1675,11 @@ func (s *server) SendVideo() http.HandlerFunc { func (s *server) SendContact() http.HandlerFunc { type contactStruct struct { - Phone string - Id string - Name string - Vcard string - ContextInfo waE2E.ContextInfo + Phone string + Id string + Name string + Vcard string + ContextInfo waE2E.ContextInfo QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } @@ -1797,12 +1797,12 @@ func (s *server) SendContact() http.HandlerFunc { func (s *server) SendLocation() http.HandlerFunc { type locationStruct struct { - Phone string - Id string - Name string - Latitude float64 - Longitude float64 - ContextInfo waE2E.ContextInfo + Phone string + Id string + Name string + Latitude float64 + Longitude float64 + ContextInfo waE2E.ContextInfo QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } @@ -2244,8 +2244,8 @@ func (s *server) SendMessage() http.HandlerFunc { LinkPreview bool Id string ContextInfo waE2E.ContextInfo - QuotedText string `json:"QuotedText,omitempty"` - QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` + QuotedText string `json:"QuotedText,omitempty"` + QuotedMessage *waE2E.Message `json:"QuotedMessage,omitempty"` } return func(w http.ResponseWriter, r *http.Request) { txtid := r.Context().Value("userinfo").(Values).Get("Id") @@ -6148,6 +6148,23 @@ func (s *server) syncHistoryForChat(ctx context.Context, userID string, chatJID // save outgoing message to history func (s *server) saveOutgoingMessageToHistory(userID, chatJID, messageID, messageType, textContent, mediaLink string, historyLimit int) { + // Detect new chat before saving to history (query happens before insert) + // Only fires for individual chats (1-to-1), not groups (@g.us) + if mycli := clientManager.GetMyClient(userID); mycli != nil { + if !strings.HasSuffix(chatJID, "@g.us") && isNewChat(mycli, chatJID) { + // Extract phone number from JID (e.g. "5215518426237" from "5215518426237@s.whatsapp.net") + phone := strings.Split(chatJID, "@")[0] + extraData := map[string]interface{}{ + "phone": phone, + "isFromMe": true, + "isGroup": false, + "messageID": messageID, + "messageType": messageType, + } + go fireChatNewEvent(mycli, chatJID, "message", extraData) + } + } + if historyLimit > 0 { err := s.saveMessageToHistory(userID, chatJID, "me", messageID, messageType, textContent, mediaLink, "", "") if err != nil { @@ -6701,14 +6718,14 @@ func (s *server) publishSentMessageEvent(token, userID, txtid string, recipient var recipientLID types.JID if client.Store != nil && client.Store.LIDs != nil { ctx := context.Background() - + // Get sender LID if !senderJID.IsEmpty() { if lid, err := client.Store.LIDs.GetLIDForPN(ctx, senderJID); err == nil && !lid.IsEmpty() { senderLID = lid } } - + // Get recipient LID (only for non-group chats) if !isGroup && !recipient.IsEmpty() { if lid, err := client.Store.LIDs.GetLIDForPN(ctx, recipient); err == nil && !lid.IsEmpty() { diff --git a/main.go b/main.go index 27bbfe82..2406a381 100755 --- a/main.go +++ b/main.go @@ -75,6 +75,7 @@ var ( killchannel = make(map[string](chan bool)) userinfocache = cache.New(5*time.Minute, 10*time.Minute) lastMessageCache = cache.New(24*time.Hour, 24*time.Hour) + knownChatsCache = cache.New(cache.NoExpiration, cache.NoExpiration) // Tracks known chat JIDs per user for ChatNew event detection globalHTTPClient = newSafeHTTPClient() ) diff --git a/static/dashboard/index.html b/static/dashboard/index.html index 621fe66c..5b2aea7b 100644 --- a/static/dashboard/index.html +++ b/static/dashboard/index.html @@ -379,6 +379,8 @@

+ + @@ -640,6 +642,8 @@

Basic Configuration

+ + diff --git a/static/docs/index.html b/static/docs/index.html index f2032a45..462e3e8a 100644 --- a/static/docs/index.html +++ b/static/docs/index.html @@ -666,6 +666,11 @@

Presence and Activity

  • ChatPresence - Chat presence (typing)
  • +

    Chat Lifecycle

    +
      +
    • ChatNew - New chat detected (first message or call with a contact/group)
    • +
    +

    Others

    • IdentityChange - Identity change
    • @@ -734,6 +739,29 @@

      Presence

      } } +

      Chat New

      +

      Fired when a new chat is detected for the first time (incoming/outgoing message or call with a previously unknown contact or group).

      +
      {
      +  "type": "ChatNew",
      +  "chat": "5491199999999@s.whatsapp.net",
      +  "trigger": "message",
      +  "sender": "5491199999999@s.whatsapp.net",
      +  "pushName": "Contact Name",
      +  "isGroup": false,
      +  "isFromMe": false,
      +  "timestamp": "2026-03-18T10:30:00Z",
      +  "messageID": "3EB0ABCD123456789"
      +}
      +

      When triggered by a call:

      +
      {
      +  "type": "ChatNew",
      +  "chat": "5491199999999@s.whatsapp.net",
      +  "trigger": "call",
      +  "caller": "5491199999999@s.whatsapp.net",
      +  "callID": "ABCDEF123456",
      +  "timestamp": "2026-03-18T10:30:00Z"
      +}
      +
      Note: Make sure your webhook server responds to requests with a 200 code in a reasonable time. WuzAPI considers a successful response as confirmation that the event was processed correctly.
      diff --git a/wmiau.go b/wmiau.go index 302c2dda..0bda41aa 100644 --- a/wmiau.go +++ b/wmiau.go @@ -156,6 +156,80 @@ func getUserWebhookUrl(token string) string { return webhookurl } +// preloadKnownChats loads all distinct chat JIDs from message_history into the +// knownChatsCache so that existing chats are not incorrectly flagged as new. +func preloadKnownChats(s *server, userID string) { + var chatJIDs []string + var query string + if s.db.DriverName() == "sqlite" { + query = `SELECT DISTINCT chat_jid FROM message_history WHERE user_id = ?` + } else { + query = `SELECT DISTINCT chat_jid FROM message_history WHERE user_id = $1` + } + err := s.db.Select(&chatJIDs, query, userID) + if err != nil { + log.Error().Err(err).Str("userID", userID).Msg("Failed to preload known chats from message_history") + return + } + for _, chatJID := range chatJIDs { + knownChatsCache.Set(userID+":"+chatJID, true, cache.NoExpiration) + } + log.Info().Str("userID", userID).Int("count", len(chatJIDs)).Msg("Preloaded known chats into cache") +} + +// isNewChat checks if a chat JID has been seen before for a given user. +// It first checks the in-memory cache, then falls back to querying message_history in the DB. +// If the chat is new (not seen before), it registers it in the cache and returns true. +func isNewChat(mycli *MyClient, chatJID string) bool { + cacheKey := mycli.userID + ":" + chatJID + + // Check if already known in cache + if _, found := knownChatsCache.Get(cacheKey); found { + return false + } + + // Check if chat exists in message_history (persistent storage) + var count int + var query string + if mycli.db.DriverName() == "sqlite" { + query = `SELECT COUNT(*) FROM message_history WHERE user_id = ? AND chat_jid = ? LIMIT 1` + } else { + query = `SELECT COUNT(*) FROM message_history WHERE user_id = $1 AND chat_jid = $2 LIMIT 1` + } + err := mycli.db.Get(&count, query, mycli.userID, chatJID) + if err != nil { + log.Error().Err(err).Str("userID", mycli.userID).Str("chatJID", chatJID).Msg("Failed to check if chat exists in history") + // On error, register it and don't fire the event to avoid false positives + knownChatsCache.Set(cacheKey, true, cache.NoExpiration) + return false + } + + if count > 0 { + // Chat already exists in DB, register in cache and return false + knownChatsCache.Set(cacheKey, true, cache.NoExpiration) + return false + } + + // Chat is new! Register it in cache + knownChatsCache.Set(cacheKey, true, cache.NoExpiration) + return true +} + +// fireChatNewEvent dispatches a ChatNew webhook event when a new chat is detected. +// The triggerEvent parameter describes what triggered the new chat (e.g. "message", "call"). +func fireChatNewEvent(mycli *MyClient, chatJID string, triggerEvent string, extraData map[string]interface{}) { + chatNewPostmap := make(map[string]interface{}) + chatNewPostmap["type"] = "ChatNew" + chatNewPostmap["chat"] = chatJID + chatNewPostmap["trigger"] = triggerEvent + for k, v := range extraData { + chatNewPostmap[k] = v + } + + log.Info().Str("userID", mycli.userID).Str("chatJID", chatJID).Str("trigger", triggerEvent).Msg("New chat detected, firing ChatNew event") + sendEventWithWebHook(mycli, chatNewPostmap, "") +} + func sendEventWithWebHook(mycli *MyClient, postmap map[string]interface{}, path string) { webhookurl := getUserWebhookUrl(mycli.token) @@ -333,7 +407,7 @@ func parseJID(arg string) (types.JID, bool) { // Returns DESKTOP as default if the string doesn't match any known type func getPlatformTypeEnum(platformType string) *waCompanionReg.DeviceProps_PlatformType { platformType = strings.ToUpper(strings.TrimSpace(platformType)) - + switch platformType { case "UNKNOWN": return waCompanionReg.DeviceProps_UNKNOWN.Enum() @@ -437,6 +511,9 @@ func (s *server) startClient(userID string, textjid string, token string, subscr // Store the MyClient in clientManager clientManager.SetMyClient(userID, &mycli) + // Pre-load known chats from message_history into cache for ChatNew event detection + go preloadKnownChats(s, userID) + httpClient := resty.New() httpClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(15)) if *waDebug == "DEBUG" { @@ -848,6 +925,23 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["type"] = "Message" dowebhook = 1 + + // Detect new chat (must be checked BEFORE saving to history, as isNewChat queries message_history) + // Only fires for individual chats (1-to-1) with real phone numbers (@s.whatsapp.net), not groups or LIDs + chatJIDStr := evt.Info.Chat.String() + if !evt.Info.IsGroup && strings.HasSuffix(chatJIDStr, "@s.whatsapp.net") && isNewChat(mycli, chatJIDStr) { + extraData := map[string]interface{}{ + "phone": evt.Info.Chat.User, + "sender": evt.Info.Sender.String(), + "pushName": evt.Info.PushName, + "isGroup": false, + "isFromMe": evt.Info.IsFromMe, + "timestamp": evt.Info.Timestamp, + "messageID": evt.Info.ID, + } + go fireChatNewEvent(mycli, chatJIDStr, "message", extraData) + } + metaParts := []string{fmt.Sprintf("pushname: %s", evt.Info.PushName), fmt.Sprintf("timestamp: %s", evt.Info.Timestamp)} if evt.Info.Type != "" { metaParts = append(metaParts, fmt.Sprintf("type: %s", evt.Info.Type)) @@ -1492,6 +1586,9 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { continue } + // Register this chat as known so it does not trigger a ChatNew event later + knownChatsCache.Set(mycli.userID+":"+chatJID.String(), true, cache.NoExpiration) + for _, msg := range conv.Messages { if msg == nil || msg.Message == nil { continue @@ -1762,6 +1859,18 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["type"] = "CallOffer" dowebhook = 1 log.Info().Str("event", fmt.Sprintf("%+v", evt)).Msg("Got call offer") + + // Detect new chat from incoming call (only real phone numbers @s.whatsapp.net) + callChatJID := evt.CallCreator.String() + if callChatJID != "" && strings.HasSuffix(callChatJID, "@s.whatsapp.net") && isNewChat(mycli, callChatJID) { + extraData := map[string]interface{}{ + "phone": evt.CallCreator.User, + "caller": evt.CallCreator.String(), + "callID": evt.CallID, + "timestamp": evt.Timestamp, + } + go fireChatNewEvent(mycli, callChatJID, "call", extraData) + } case *events.CallAccept: postmap["type"] = "CallAccept" dowebhook = 1 @@ -1774,6 +1883,18 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["type"] = "CallOfferNotice" dowebhook = 1 log.Info().Str("event", fmt.Sprintf("%+v", evt)).Msg("Got call offer notice") + + // Detect new chat from incoming call notice (only real phone numbers @s.whatsapp.net) + callNoticeChatJID := evt.CallCreator.String() + if callNoticeChatJID != "" && strings.HasSuffix(callNoticeChatJID, "@s.whatsapp.net") && isNewChat(mycli, callNoticeChatJID) { + extraData := map[string]interface{}{ + "phone": evt.CallCreator.User, + "caller": evt.CallCreator.String(), + "callID": evt.CallID, + "timestamp": evt.Timestamp, + } + go fireChatNewEvent(mycli, callNoticeChatJID, "call", extraData) + } case *events.CallRelayLatency: postmap["type"] = "CallRelayLatency" dowebhook = 1