Compare commits

...

29 Commits

Author SHA1 Message Date
Claude AI
5dd3499097 feat: Major WhatsApp integration update with Odoo and pause/resume
## Frontend
- Add media display (images, audio, video, docs) in Inbox
- Add pause/resume functionality for WhatsApp accounts
- Fix media URLs to use nginx proxy (relative URLs)

## API Gateway
- Add /accounts/:id/pause and /accounts/:id/resume endpoints
- Fix media URL handling for browser access

## WhatsApp Core
- Add pauseSession() - disconnect without logout
- Add resumeSession() - reconnect using saved credentials
- Add media download and storage for incoming messages
- Serve media files via /media/ static route

## Odoo Module (odoo_whatsapp_hub)
- Add Chat Hub interface with DOLLARS theme (dark, 3-column layout)
- Add WhatsApp/DRRR theme switcher for chat view
- Add "ABRIR CHAT" button in conversation form
- Add send_message_from_chat() method
- Add security/ir.model.access.csv
- Fix CSS scoping to avoid breaking Odoo UI
- Update webhook to handle message events properly

## Documentation
- Add docs/CONTEXTO_DESARROLLO.md with complete project context

## Infrastructure
- Add whatsapp_media Docker volume
- Configure nginx proxy for /media/ route
- Update .gitignore to track src/sessions/ source files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:48:56 +00:00
Claude AI
1040debe2e fix(odoo): compatibilidad completa con Odoo 19
- Cambiar tree a list en todas las vistas
- Eliminar vista kanban (requiere formato diferente en Odoo 17+)
- Simplificar vista de búsqueda
- Simplificar herencia de res.partner (quitar xpaths problemáticos)
- Agregar store=True a campos computados para filtros
- Importar post_init_hook en __init__.py
- Usar @api.model_create_multi para método create

Probado y funcionando en Odoo 19.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:48:35 +00:00
Claude AI
28592254b2 fix(odoo): agregar store=True a campos computados
- unread_count: necesario para filtros de búsqueda
- last_message_preview: necesario para vistas

Campos computados sin store=True no pueden usarse en dominios de filtros.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:38:13 +00:00
Claude AI
0c5fe0e3bb fix(odoo): cambiar tree a list para Odoo 17+
- Reemplazar <tree> por <list> en todas las vistas
- Cambiar mode="tree" a mode="list"
- Actualizar view_partner_tree a view_partner_list
- Actualizar view_mode de tree,form a list,form

En Odoo 17+, el tipo de vista "tree" fue renombrado a "list".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:33:53 +00:00
Claude AI
f305f6495a fix(odoo): corregir orden de carga de archivos en manifest
- Cargar vistas con acciones ANTES del menú
- Mover whatsapp_data.xml al final (necesita modelos registrados)

El menú referencia acciones que deben existir primero.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:30:06 +00:00
Claude AI
ece4398807 fix(odoo): corregir método create para Odoo 17+
- Usar @api.model_create_multi en lugar de @api.model
- Manejar vals_list como lista de diccionarios

En Odoo 17+, create() recibe una lista de valores, no un diccionario único.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:24:40 +00:00
Claude AI
a5914a164b chore: agregar ZIP v2 del módulo Odoo (sin security CSV)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:04:54 +00:00
Claude AI
3c36701abc chore: remover ZIP, descargar repo completo
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:04:46 +00:00
Claude AI
4877dc23d7 fix(odoo): usar post_init_hook para crear permisos
- Crear hooks.py con post_init_hook que crea permisos programáticamente
- Eliminar security/ directory (XML/CSV fallan en importación ZIP)
- Los permisos se crean después de que los modelos estén registrados

Esto garantiza que los permisos se creen correctamente independientemente
del método de instalación (ZIP o addons path).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:03:31 +00:00
Claude AI
626236c6dd fix(odoo): cambiar permisos de CSV a XML para importación ZIP
- Eliminar ir.model.access.csv (falla en importación ZIP)
- Crear ir_model_access.xml con registros de acceso
- Mover security al final del manifest (después de cargar modelos)
- Regenerar ZIP del módulo

El problema era que al importar vía ZIP, Odoo procesa el CSV
antes de registrar los modelos Python, causando error de external ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:02:50 +00:00
Claude AI
357987844e chore: agregar ZIP del módulo Odoo para descarga directa
Archivo ZIP listo para instalar en Odoo 19 vía Apps > Import Module

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:00:58 +00:00
Claude AI
de48f0177a fix(api-gateway): corregir errores de SQLAlchemy y dependencias
- Renombrar campo 'metadata' a 'extra_data' (palabra reservada SQLAlchemy)
- Agregar email-validator para pydantic[email]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:18:28 +00:00
Claude AI
9b27eddb5f fix(odoo): corregir external IDs en security CSV
- Cambiar model_id:id a model_id/id (formato correcto)
- Agregar prefijo odoo_whatsapp_hub. a todas las referencias de modelo
- Necesario para que Odoo resuelva correctamente los IDs externos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:13:59 +00:00
Claude AI
b747b6c49a fix: resolve TypeScript build errors and update Dockerfiles
- Changed npm ci to npm install in Dockerfiles (no lock files)
- Added git to whatsapp-core alpine image for npm dependencies
- Fixed TypeScript type errors in routes.ts (string | string[])
- Fixed SessionManager.ts type compatibility with Baileys
- Disabled noUnusedLocals/noUnusedParameters in frontend tsconfig

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:05:33 +00:00
Claude AI
991b8ddfe8 docs: update README with completed phases and add Odoo module install guide
- Updated README with all 6 completed phases
- Added access URLs for local network
- Added default credentials section
- Created comprehensive Odoo module installation guide
- Added project structure documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:53:29 +00:00
Claude AI
e10d67b19d chore(odoo): update module version to Odoo 19
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:48:46 +00:00
Claude AI
0c2e2f1b7a feat(odoo): add static description directory for module icon
- Add placeholder .gitkeep for icon.png location
- Required for Odoo Apps store compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:46:45 +00:00
Claude AI
48db1a94f7 feat(odoo): add conversation and partner views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:44 +00:00
Claude AI
f1933cf0d0 feat(odoo): add security and data files
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:40 +00:00
Claude AI
ad218ecccf feat(odoo): add menu and account views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:33 +00:00
Claude AI
5f61a815e5 feat(odoo): add wizard views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:29 +00:00
Claude AI
13dedaf48d feat(odoo): add WhatsApp webhook controller
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:19 +00:00
Claude AI
cf424b1f37 feat(odoo): add OWL chat widget
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:14 +00:00
Claude AI
c8c6deb4de feat(odoo): add WhatsApp account model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:08 +00:00
Claude AI
e85c9c10b5 feat(odoo): add WhatsApp conversation model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:06 +00:00
Claude AI
218c137564 feat(odoo): add WhatsApp CSS styles
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:05 +00:00
Claude AI
1074bf6739 feat(odoo): extend res.partner with WhatsApp fields
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:03 +00:00
Claude AI
7551a3d8b7 feat(odoo): add WhatsApp message model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:02 +00:00
Claude AI
87d59ca433 feat(odoo): create module structure and manifest
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:40:07 +00:00
53 changed files with 5564 additions and 81 deletions

5
.gitignore vendored
View File

@@ -105,12 +105,13 @@ docker-compose.override.yml
# -----------------------------------------------------------------------------
# Sessions / Data
# -----------------------------------------------------------------------------
sessions/
/sessions/
*.session
*.session.json
# WhatsApp Baileys sessions
# WhatsApp Baileys sessions (data, not source code)
services/whatsapp-core/sessions/
!services/whatsapp-core/src/sessions/
auth_info*/
# -----------------------------------------------------------------------------

233
README.md
View File

@@ -2,6 +2,19 @@
Plataforma de mensajería centralizada con automatización de chatbots, gestión multi-agente e integración profunda con Odoo.
## Estado del Proyecto
| Fase | Descripción | Estado |
|------|-------------|--------|
| Fase 1 | Fundación (WhatsApp Core + API + Frontend) | Completada |
| Fase 2 | Flow Engine Básico | Completada |
| Fase 3 | Inbox Avanzado + Multi-agente | Completada |
| Fase 4 | Flow Engine Avanzado | Completada |
| Fase 5 | Integración Odoo Completa | Completada |
| Fase 6 | Módulo Odoo (odoo_whatsapp_hub) | Completada |
| Fase 7 | Reportes y Analytics | Pendiente |
| Fase 8 | Multi-canal (Email, SMS) | Futuro |
## Descripción
WhatsApp Centralizado es una solución empresarial similar a Kommo, Wasapi, ManyChat y Brevo, diseñada para:
@@ -11,15 +24,16 @@ WhatsApp Centralizado es una solución empresarial similar a Kommo, Wasapi, Many
- **Integrar con Odoo** de forma bidireccional (CRM, Ventas, Inventario, Helpdesk, etc.)
- **Conectar múltiples números** de WhatsApp desde una sola plataforma
## Características Principales
## Características Implementadas
### Flow Builder Visual
- 30+ tipos de nodos (mensajes, lógica, validación, acciones)
- Editor drag & drop con React Flow
- Variables y contexto de conversación
- Variables globales y contexto de conversación
- A/B Testing integrado
- Integración con IA (GPT, Claude, Ollama)
- Integración con IA (DeepSeek)
- Sub-flujos reutilizables
- Plantillas predefinidas
### Gestión Multi-Agente
- Sistema de colas (Ventas, Soporte, etc.)
@@ -27,38 +41,53 @@ WhatsApp Centralizado es una solución empresarial similar a Kommo, Wasapi, Many
- Transferencia bot → humano → bot
- Panel de supervisor en tiempo real
- SLA tracking con alertas
- Encuestas CSAT integradas
- Notas internas y respuestas rápidas
### Integración Odoo
- Conexión bidireccional via XML-RPC
- 8 módulos soportados (Contactos, CRM, Ventas, Inventario, Helpdesk, Facturación, Calendario, Productos)
- 20+ acciones disponibles en flujos
- Automatizaciones Odoo → WhatsApp
- Módulo Odoo con widget de chat
### Integración Odoo (Fase 5)
- Conexión via XML-RPC con autenticación
- Sincronización bidireccional de contactos
- 8 nodos de flujo para Odoo:
- Buscar/Crear Partner
- Consultar Saldo
- Buscar/Ver Pedidos
- Buscar Productos
- Verificar Stock
- Crear Lead CRM
- Webhooks para eventos de Odoo
### Módulo Odoo - odoo_whatsapp_hub (Fase 6)
- Módulo nativo para Odoo 19
- Gestión de cuentas WhatsApp
- Historial de conversaciones en contactos
- Widget de chat OWL en tiempo real
- Envío individual y masivo de mensajes
- Webhooks bidireccionales
- Integración completa con res.partner
## Stack Tecnológico
| Componente | Tecnología |
|------------|------------|
| WhatsApp Core | Node.js + TypeScript + Baileys |
| API Gateway | Python + FastAPI |
| Flow Engine | Python |
| Frontend | React + TypeScript |
| Base de Datos | PostgreSQL + Redis |
| Despliegue | Docker + Docker Compose |
| Componente | Tecnología | Puerto |
|------------|------------|--------|
| WhatsApp Core | Node.js + TypeScript + Baileys | 3001 |
| API Gateway | Python + FastAPI | 8000 |
| Flow Engine | Python + FastAPI | 8001 |
| Integrations | Python + FastAPI | 8002 |
| Frontend | React + TypeScript + Vite | 3000 |
| Base de Datos | PostgreSQL 16 | 5432 |
| Cache/PubSub | Redis 7 | 6379 |
## Arquitectura
```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
Dashboard │ Inbox Chat │ Flow Builder (React Flow)
│ FRONTEND (React)
│ Dashboard │ Inbox Chat │ Flow Builder │ Odoo Config
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ API GATEWAY (FastAPI) │
JWT Auth │ REST API │ WebSocket (tiempo real)
│ JWT Auth │ REST API │ WebSocket │ Odoo Config API
└─────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
@@ -66,76 +95,188 @@ WhatsApp Centralizado es una solución empresarial similar a Kommo, Wasapi, Many
┌──────────────────┐ ┌──────────────┐ ┌──────────────────────────┐
│ WHATSAPP CORE │ │ FLOW ENGINE │ │ INTEGRATIONS │
│ (Node.js) │ │ (Python) │ │ (Python) │
│ Baileys │ │ Motor bot │ │ Odoo, Webhooks
│ Baileys │ │ 30+ Nodos │ │ Odoo XML-RPC
│ Multi-número │ │ AI Response │ │ Webhooks │
└──────────────────┘ └──────────────┘ └──────────────────────────┘
│ │ │
└───────────────┼───────────────┘
┌─────────┴─────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Redis │
│ :5432 │ │ :6379 │
└──────────┘ └──────────┘
┌──────────────────┐
│ ODOO 19 │
│ odoo_whatsapp_hub│
└──────────────────┘
```
## Inicio Rápido
## Instalación
### Requisitos
- Docker 24.0+
- Docker Compose 2.20+
- 4GB RAM mínimo (8GB recomendado)
- Odoo 19 (para el módulo)
### Instalación
### Instalación Rápida
```bash
# Clonar repositorio
git clone https://git.consultoria-as.com/tu-usuario/WhatsAppCentralizado.git
git clone https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado.git
cd WhatsAppCentralizado
# Copiar configuración
cp .env.example .env
# Editar variables de entorno
# Editar variables de entorno (ver sección Configuración)
nano .env
# Iniciar servicios
docker-compose up -d
# Aplicar migraciones
docker-compose exec api-gateway alembic upgrade head
# Ver logs
docker-compose logs -f
# Crear usuario admin
docker-compose exec api-gateway python scripts/create_admin.py
# Aplicar migraciones de base de datos
docker-compose exec api-gateway alembic upgrade head
```
### Acceso
- Frontend: http://localhost:3000
- API: http://localhost:8000
- Docs API: http://localhost:8000/docs
## Configuración
### Variables de Entorno Principales
```bash
# Base de Datos
DB_USER=whatsapp_admin
DB_PASSWORD=tu_password_seguro
DB_NAME=whatsapp_central
# JWT (generar con: openssl rand -base64 64)
JWT_SECRET=tu_secreto_jwt
# Odoo
ODOO_URL=https://tu-empresa.odoo.com
ODOO_DB=nombre_base_datos
ODOO_USER=usuario@empresa.com
ODOO_API_KEY=tu_api_key
# DeepSeek AI (opcional, para nodos AI)
DEEPSEEK_API_KEY=tu_api_key
```
## Acceso
### URLs de Acceso Local
| Servicio | URL Local | URL Red Local |
|----------|-----------|---------------|
| Frontend | http://localhost:3000 | http://192.168.10.221:3000 |
| API Gateway | http://localhost:8000 | http://192.168.10.221:8000 |
| API Docs (Swagger) | http://localhost:8000/docs | http://192.168.10.221:8000/docs |
| WhatsApp Core | http://localhost:3001 | http://192.168.10.221:3001 |
| Integrations API | http://localhost:8002 | http://192.168.10.221:8002 |
### Credenciales por Defecto
```
Usuario: admin@whatsapp.local
Password: admin123
(Cambiar inmediatamente en producción)
```
## Instalación del Módulo Odoo
Ver guía completa en: [docs/odoo-module-install.md](docs/odoo-module-install.md)
### Instalación Rápida
1. Copiar el módulo a tu instancia de Odoo:
```bash
cp -r odoo_whatsapp_hub /ruta/a/odoo/addons/
```
2. Reiniciar Odoo y actualizar lista de apps
3. Buscar "WhatsApp Hub" e instalar
4. Configurar la cuenta en WhatsApp > Configuración > Cuentas
## Estructura del Proyecto
```
WhatsAppCentralizado/
├── services/
│ ├── whatsapp-core/ # Node.js + Baileys
│ ├── api-gateway/ # FastAPI principal
│ ├── flow-engine/ # Motor de flujos
│ └── integrations/ # Odoo XML-RPC
├── frontend/ # React + Vite
├── database/
│ └── migrations/ # Alembic migrations
├── odoo_whatsapp_hub/ # Módulo Odoo 19
│ ├── models/
│ ├── views/
│ ├── wizards/
│ ├── controllers/
│ └── static/
├── nginx/ # Configuración reverse proxy
├── docs/ # Documentación
└── docker-compose.yml
```
## Documentación
- [Diseño del Sistema](docs/plans/2026-01-29-whatsapp-centralizado-design.md)
- [Arquitectura](docs/architecture/README.md)
- [Arquitectura de Base de Datos](docs/database/README.md)
- [API Reference](docs/api/README.md)
- [Flow Builder](docs/flow-builder/README.md)
- [Integración Odoo](docs/odoo-integration/README.md)
- [Instalación Módulo Odoo](docs/odoo-module-install.md)
- [Guía de Despliegue](docs/deployment/README.md)
## Roadmap
## Servicios Docker
- [x] Diseño y arquitectura
- [ ] Fase 1: Fundación (WhatsApp Core + API + Frontend básico)
- [ ] Fase 2: Flow Engine Básico
- [ ] Fase 3: Inbox Avanzado + Multi-agente
- [ ] Fase 4: Flow Engine Avanzado
- [ ] Fase 5: Integración Odoo Completa
- [ ] Fase 6: Módulo Odoo
- [ ] Fase 7: Reportes y Analytics
- [ ] Fase 8: Multi-canal (Email, SMS)
```bash
# Ver estado de servicios
docker-compose ps
# Reiniciar un servicio
docker-compose restart api-gateway
# Ver logs de un servicio
docker-compose logs -f flow-engine
# Escalar servicios (si es necesario)
docker-compose up -d --scale whatsapp-core=2
```
## Próximas Fases
### Fase 7: Reportes y Analytics (Pendiente)
- Dashboard de analytics
- Métricas por agente/cola/flujo
- Reportes CSAT
- Exportación de datos
- Reportes programados
### Fase 8: Multi-canal (Futuro)
- Integración Email (SMTP/IMAP)
- Integración SMS (Twilio)
- WhatsApp Business API oficial
- Inbox unificado
## Licencia
Propietario - Todos los derechos reservados.
Desarrollado por Consultoria AS.
## Contacto
Desarrollado para uso interno empresarial.
- Repositorio: https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado
- Desarrollado para uso interno empresarial.

View File

@@ -51,6 +51,7 @@ services:
WS_PORT: 3001
volumes:
- whatsapp_sessions:/app/sessions
- whatsapp_media:/app/media
ports:
- "3001:3001"
depends_on:
@@ -70,6 +71,9 @@ services:
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
WHATSAPP_CORE_URL: http://whatsapp-core:3001
WHATSAPP_CORE_PUBLIC_URL: ${WHATSAPP_CORE_PUBLIC_URL:-http://localhost:3001}
FLOW_ENGINE_URL: http://flow-engine:8001
ODOO_WEBHOOK_URL: ${ODOO_WEBHOOK_URL:-http://192.168.10.188:8069/whatsapp/webhook}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
ports:
- "8000:8000"
@@ -139,6 +143,7 @@ volumes:
postgres_data:
redis_data:
whatsapp_sessions:
whatsapp_media:
networks:
wac_network:

344
docs/CONTEXTO_DESARROLLO.md Normal file
View File

@@ -0,0 +1,344 @@
# WhatsApp Centralizado - Contexto de Desarrollo
> **Fecha de última actualización:** 2026-01-30
> **Estado:** En desarrollo activo
---
## Resumen del Proyecto
Sistema centralizado de WhatsApp para gestión de múltiples números, integrado con Odoo y un frontend React. Permite:
- Conectar múltiples números de WhatsApp vía QR
- Recibir y enviar mensajes (texto, imágenes, audio, video, documentos)
- Gestionar conversaciones desde frontend web o Odoo
- Automatizar respuestas con flujos de bot
- Pausar/reanudar conexiones sin perder sesión
---
## Arquitectura del Sistema
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │────▶│ API Gateway │────▶│ WhatsApp Core │
│ React + Ant │ │ FastAPI │ │ Node + Baileys │
│ Puerto: 3000 │ │ Puerto: 8000 │ │ Puerto: 3001 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ │
┌─────────────────┐ │
│ PostgreSQL │◀────────────┘
│ Puerto: 5432 │
└─────────────────┘
┌─────────────────┐
│ Odoo (CAS) │◀── Webhooks
│ 192.168.10.188 │
│ Puerto: 8069 │
└─────────────────┘
```
---
## Servicios Docker
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| `frontend` | 3000 | React + Ant Design |
| `api-gateway` | 8000 | FastAPI - API principal |
| `whatsapp-core` | 3001 | Node.js + Baileys - Conexión WhatsApp |
| `flow-engine` | 8001 | Motor de flujos de bot |
| `integrations` | 8002 | Integraciones externas |
| `postgres` | 5432 | Base de datos PostgreSQL |
| `redis` | 6379 | Cache y colas |
### Comandos Docker útiles
```bash
# Reconstruir servicios
docker compose build whatsapp-core frontend api-gateway
# Reiniciar servicios
docker compose up -d
# Ver logs
docker compose logs -f whatsapp-core
docker compose logs -f api-gateway
# Entrar a un contenedor
docker exec -it wac_whatsapp sh
docker exec -it wac_api bash
```
---
## Servidor Odoo
- **IP:** 192.168.10.188
- **Puerto:** 8069
- **Base de datos:** `cas` (NO usar "odoo")
- **Usuario SSH:** root / Aasi940812
- **Ruta módulo:** `/opt/odoo/addons/odoo_whatsapp_hub/`
### Comandos Odoo útiles
```bash
# Conectar por SSH
sshpass -p 'Aasi940812' ssh root@192.168.10.188
# Actualizar módulo
systemctl stop odoo
/usr/bin/odoo -c /etc/odoo/odoo.conf -d cas -u odoo_whatsapp_hub --stop-after-init
systemctl start odoo
# Instalar módulo
/usr/bin/odoo -c /etc/odoo/odoo.conf -d cas -i odoo_whatsapp_hub --stop-after-init
# Ver logs
tail -f /var/log/odoo/odoo-server.log
# Consultas a PostgreSQL
sudo -u postgres psql -d cas -c "SELECT * FROM whatsapp_conversation;"
```
### Configuración WhatsApp Account en Odoo
```sql
-- Ver cuentas
SELECT id, name, external_id, api_url FROM whatsapp_account;
-- Configurar external_id (debe coincidir con el UUID del frontend)
UPDATE whatsapp_account
SET external_id = '33ce868e-1aa0-4795-9d44-a389e8ade0de',
api_url = 'http://192.168.10.221:8000'
WHERE id = 1;
```
---
## Módulo Odoo (odoo_whatsapp_hub)
### Estructura
```
odoo_whatsapp_hub/
├── __manifest__.py
├── __init__.py
├── models/
│ ├── whatsapp_account.py # Cuentas WhatsApp
│ ├── whatsapp_conversation.py # Conversaciones
│ └── whatsapp_message.py # Mensajes
├── controllers/
│ └── webhook.py # Recibe eventos del sistema
├── views/
│ ├── whatsapp_account_views.xml
│ ├── whatsapp_conversation_views.xml
│ ├── dollars_action.xml # Interfaz DOLLARS
│ └── whatsapp_menu.xml
├── wizards/
│ └── send_whatsapp_wizard.xml
├── static/src/
│ ├── css/
│ │ ├── whatsapp.css # Estilos WhatsApp/DRRR
│ │ └── dollars_theme.css # Tema DOLLARS oscuro
│ ├── js/
│ │ ├── chat_action.js # Chat con temas
│ │ └── dollars_chat.js # Chat Hub DOLLARS
│ └── xml/
│ ├── chat_template.xml
│ └── dollars_template.xml
└── security/
└── ir.model.access.csv
```
### Menú en Odoo
- **WhatsApp Hub > Chat Hub** - Interfaz DOLLARS (oscura, 3 columnas)
- **WhatsApp Hub > Conversaciones (Lista)** - Vista clásica de Odoo
- **WhatsApp Hub > Cuentas** - Gestión de números
- **WhatsApp Hub > Configuración** - Ajustes
### Webhook
```
POST http://192.168.10.188:8069/whatsapp/webhook
Header: X-Odoo-Database: cas
Header: Content-Type: application/json
Body:
{
"type": "message", // message, status_update, conversation_update, account_status
"account_id": "uuid-de-la-cuenta",
"data": { ... }
}
```
---
## Frontend React
### Estructura principal
```
frontend/src/
├── pages/
│ ├── WhatsAppAccounts.tsx # Gestión de números (pausar/reanudar)
│ ├── Inbox.tsx # Bandeja de entrada
│ ├── Dashboard.tsx
│ └── ...
├── api/
│ └── client.ts # Cliente API
└── App.tsx
```
### Funcionalidades implementadas
1. **Gestión de cuentas WhatsApp**
- Crear cuenta y escanear QR
- Pausar conexión (mantiene sesión)
- Reanudar conexión (sin QR)
- Eliminar cuenta
2. **Bandeja de entrada (Inbox)**
- Lista de conversaciones
- Visualización de mensajes
- Envío de mensajes
- Soporte para imágenes, audio, video, documentos
### Proxy Nginx para media
```nginx
location /media/ {
proxy_pass http://whatsapp-core:3001/media/;
proxy_set_header Host $host;
}
```
---
## API Endpoints Principales
### WhatsApp Accounts
```
GET /api/whatsapp/accounts # Listar cuentas
POST /api/whatsapp/accounts # Crear cuenta
GET /api/whatsapp/accounts/:id # Obtener cuenta
DELETE /api/whatsapp/accounts/:id # Eliminar cuenta
POST /api/whatsapp/accounts/:id/pause # Pausar conexión
POST /api/whatsapp/accounts/:id/resume # Reanudar conexión
```
### Conversaciones y Mensajes
```
GET /api/whatsapp/conversations # Listar conversaciones
GET /api/whatsapp/conversations/:id # Detalle con mensajes
POST /api/whatsapp/conversations/:id/send # Enviar mensaje
```
### WhatsApp Core (interno)
```
POST /api/sessions # Crear sesión
GET /api/sessions/:id # Info de sesión
POST /api/sessions/:id/disconnect # Cerrar sesión (logout)
POST /api/sessions/:id/pause # Pausar (sin logout)
POST /api/sessions/:id/resume # Reanudar
POST /api/sessions/:id/messages # Enviar mensaje
```
---
## Temas de Chat en Odoo
### Tema WhatsApp (clásico)
- Fondo beige con patrón
- Burbujas verdes (salientes) y blancas (entrantes)
- Estilo familiar de WhatsApp
### Tema DRRR (Durarara/Dollars)
- Fondo negro (#0a0a0a)
- Texto verde neón (#00ff88) para salientes
- Texto cyan (#00ccff) para entrantes
- Nombres en corchetes [Usuario]
- Fuente monospace
### Tema DOLLARS (Chat Hub)
- Interfaz oscura 3 columnas
- Sidebar con lista de conversaciones
- Chat central
- Panel de detalles a la derecha
- Acentos ámbar/naranja (#f59e0b)
---
## Problemas Conocidos y Soluciones
### 1. Imágenes no se ven en frontend
**Causa:** URLs absolutas con hostname Docker interno
**Solución:** Usar URLs relativas (`/media/uuid.jpg`) + proxy nginx
### 2. Odoo no muestra cambios
**Causa:** Base de datos incorrecta (odoo vs cas)
**Solución:** Siempre usar `-d cas` y header `X-Odoo-Database: cas`
### 3. CSS rompe todo Odoo
**Causa:** Estilos globales no encapsulados
**Solución:** Prefijar todo con `.o_dollars_chat` o `.o_whatsapp_chat_fullscreen`
### 4. Webhook retorna 404
**Causa:** Falta header de base de datos
**Solución:** Agregar `X-Odoo-Database: cas` a todas las peticiones
### 5. Account not found en webhook
**Causa:** `external_id` no configurado en Odoo
**Solución:** Actualizar tabla whatsapp_account con el UUID correcto
---
## Próximos Pasos Sugeridos
1. **Testing de pausar/reanudar** - Verificar que funciona correctamente
2. **Notificaciones en tiempo real** - WebSocket para nuevos mensajes
3. **Mejoras al Chat Hub DOLLARS** - Indicador de typing, scroll automático
4. **Integración completa Odoo** - Sincronizar contactos con res.partner
5. **Panel de métricas** - Dashboard con estadísticas de uso
---
## Credenciales y Accesos
| Servicio | URL | Usuario | Contraseña |
|----------|-----|---------|------------|
| Frontend | http://192.168.10.221:3000 | admin | (ver BD) |
| API Gateway | http://192.168.10.221:8000 | - | - |
| Odoo | http://192.168.10.188:8069 | ialcarazsalazar@consultoria-as.com | (conocida) |
| SSH Odoo | 192.168.10.188:22 | root | Aasi940812 |
| Gitea | https://git.consultoria-as.com | consultoria-as | (token en remote) |
---
## Comandos de Desarrollo
```bash
# Clonar repositorio
git clone https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado.git
# Levantar todo
cd WhatsAppCentralizado
docker compose up -d
# Desplegar módulo Odoo
sshpass -p 'Aasi940812' scp -r odoo_whatsapp_hub root@192.168.10.188:/opt/odoo/addons/
sshpass -p 'Aasi940812' ssh root@192.168.10.188 "systemctl stop odoo && /usr/bin/odoo -c /etc/odoo/odoo.conf -d cas -u odoo_whatsapp_hub --stop-after-init && systemctl start odoo"
# Ver estado
docker compose ps
docker compose logs -f
```
---
*Documento generado automáticamente. Última sesión de trabajo: 2026-01-30*

245
docs/odoo-module-install.md Normal file
View File

@@ -0,0 +1,245 @@
# Guía de Instalación: Módulo Odoo WhatsApp Hub
Esta guía explica cómo instalar y configurar el módulo `odoo_whatsapp_hub` en tu instancia de Odoo 19.
## Requisitos Previos
- Odoo 19 Community o Enterprise
- Acceso de administrador a la instancia de Odoo
- WhatsApp Centralizado en ejecución y accesible desde Odoo
- Python 3.10+ (incluido en Odoo 19)
## Método 1: Instalación Manual (Recomendado)
### Paso 1: Copiar el Módulo
Copia la carpeta `odoo_whatsapp_hub` al directorio de addons de tu instalación de Odoo:
```bash
# Si Odoo está instalado localmente
cp -r /ruta/a/WhatsAppCentralizado/odoo_whatsapp_hub /ruta/a/odoo/addons/
# Si usas Docker
docker cp odoo_whatsapp_hub odoo_container:/mnt/extra-addons/
# Si usas Odoo.sh
# Sube el módulo via Git al repositorio de tu proyecto
```
### Paso 2: Agregar Ruta de Addons (si es necesario)
Si usas un directorio custom, asegúrate de que esté en la configuración de Odoo:
```ini
# /etc/odoo/odoo.conf
[options]
addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons
```
### Paso 3: Reiniciar Odoo
```bash
# Systemd
sudo systemctl restart odoo
# Docker
docker-compose restart odoo
# Manual
./odoo-bin -c /etc/odoo/odoo.conf
```
### Paso 4: Actualizar Lista de Aplicaciones
1. Ir a **Aplicaciones**
2. Hacer clic en **Actualizar lista de aplicaciones**
3. Confirmar la actualización
### Paso 5: Instalar el Módulo
1. En **Aplicaciones**, quitar el filtro "Apps"
2. Buscar "**WhatsApp Hub**"
3. Hacer clic en **Instalar**
## Método 2: Instalación via Git (Odoo.sh o desarrollo)
```bash
# En el directorio de tu proyecto Odoo
cd /ruta/a/tu/proyecto/odoo
# Clonar o copiar como submódulo
git submodule add https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado.git external/whatsapp
# Crear enlace simbólico
ln -s ../external/whatsapp/odoo_whatsapp_hub addons/odoo_whatsapp_hub
# Commit y push
git add .
git commit -m "Add WhatsApp Hub module"
git push
```
## Configuración Post-Instalación
### 1. Configurar Cuenta WhatsApp
1. Ir a **WhatsApp > Configuración > Cuentas WhatsApp**
2. Editar la cuenta "WhatsApp Principal" o crear una nueva
3. Configurar:
| Campo | Valor | Descripción |
|-------|-------|-------------|
| Nombre | Mi WhatsApp | Nombre descriptivo |
| URL API | http://192.168.10.221:8000 | URL de WhatsApp Centralizado |
| API Key | (tu token JWT) | Token de autenticación |
| ID Externo | (ID de la cuenta) | ID de la cuenta en WhatsApp Central |
| Cuenta por Defecto | ✓ | Marcar si es la principal |
### 2. Obtener el API Key
Para obtener el token JWT:
```bash
# Hacer login en la API
curl -X POST http://192.168.10.221:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@whatsapp.local", "password": "admin123"}'
# Respuesta:
# {"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."}
```
Copia el `access_token` y pégalo en el campo **API Key** de la cuenta WhatsApp.
### 3. Vincular Cuenta Externa
El **ID Externo** es el ID de la cuenta de WhatsApp en el sistema central:
1. Ir a WhatsApp Centralizado > Cuentas
2. Copiar el ID de la cuenta conectada
3. Pegarlo en el campo **ID Externo** en Odoo
### 4. Configurar Webhook en WhatsApp Central
Para recibir mensajes en Odoo, configura el webhook:
**URL del Webhook:** `https://tu-odoo.com/whatsapp/webhook`
En WhatsApp Centralizado, ve a Configuración y agrega:
- URL: `https://tu-odoo.com/whatsapp/webhook`
- Eventos: `message`, `status_update`, `conversation_update`
## Uso del Módulo
### Ver Conversaciones
1. Ir a **WhatsApp > Conversaciones**
2. Vista Kanban agrupada por estado
3. Filtros: No leídos, Activas, Mis conversaciones
### Enviar WhatsApp desde Contacto
1. Abrir un contacto en **Contactos**
2. Hacer clic en **Enviar WhatsApp** (botón verde)
3. Escribir mensaje y enviar
### Envío Masivo
1. Seleccionar múltiples contactos en la lista
2. Acción > **Enviar WhatsApp**
3. Escribir mensaje con variables opcionales (`{{name}}`, `{{email}}`)
4. Confirmar envío
### Widget de Chat
El widget de chat OWL está disponible en la vista de conversaciones para enviar mensajes en tiempo real.
## Estructura del Módulo
```
odoo_whatsapp_hub/
├── __manifest__.py # Manifest del módulo (v19.0.1.0.0)
├── __init__.py
├── models/
│ ├── whatsapp_account.py # Cuentas WhatsApp
│ ├── whatsapp_conversation.py # Conversaciones
│ ├── whatsapp_message.py # Mensajes
│ └── res_partner.py # Extensión de contactos
├── controllers/
│ └── webhook.py # Endpoint para webhooks
├── wizards/
│ ├── send_whatsapp.py # Wizard envío individual
│ └── mass_whatsapp.py # Wizard envío masivo
├── views/
│ ├── whatsapp_menu.xml
│ ├── whatsapp_account_views.xml
│ ├── whatsapp_conversation_views.xml
│ ├── res_partner_views.xml
│ └── send_whatsapp_wizard.xml
├── security/
│ └── ir.model.access.csv # Permisos de acceso
├── data/
│ └── whatsapp_data.xml # Datos iniciales
└── static/src/
├── css/whatsapp.css # Estilos WhatsApp
├── js/chat_widget.js # Widget OWL
└── xml/chat_widget.xml # Template OWL
```
## Permisos
| Modelo | Usuario Normal | Administrador |
|--------|----------------|---------------|
| whatsapp.account | Solo lectura | CRUD completo |
| whatsapp.conversation | Leer, Crear, Escribir | CRUD completo |
| whatsapp.message | Leer, Crear, Escribir | CRUD completo |
## Solución de Problemas
### El módulo no aparece en la lista
1. Verificar que la carpeta esté en `addons_path`
2. Reiniciar Odoo con `-u all` o `--update=all`
3. Verificar logs: `tail -f /var/log/odoo/odoo.log`
### Error de conexión a la API
1. Verificar que WhatsApp Centralizado esté corriendo
2. Probar conexión: `curl http://192.168.10.221:8000/health`
3. Verificar firewall permite conexión desde Odoo
### Webhooks no funcionan
1. Verificar que Odoo sea accesible desde WhatsApp Central
2. Probar endpoint: `curl https://tu-odoo.com/whatsapp/webhook/test`
3. Revisar logs de Odoo para errores
### Mensajes no se envían
1. Verificar API Key válido
2. Verificar ID Externo correcto
3. Verificar cuenta conectada en WhatsApp Central
4. Revisar logs del servicio integrations
## Actualizaciones
Para actualizar el módulo:
```bash
# Copiar nueva versión
cp -r /nueva/version/odoo_whatsapp_hub /ruta/a/odoo/addons/
# Reiniciar y actualizar
./odoo-bin -c /etc/odoo/odoo.conf -u odoo_whatsapp_hub
```
O desde la interfaz:
1. Ir a **Aplicaciones**
2. Buscar "WhatsApp Hub"
3. Menú ⋮ > **Actualizar**
## Soporte
Para problemas o consultas:
- Repositorio: https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado
- Issues: Crear issue en el repositorio

View File

@@ -3,7 +3,7 @@ FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm install
COPY . .
RUN npm run build

View File

@@ -8,21 +8,23 @@ server {
try_files $uri $uri/ /index.html;
}
location /api {
location /api/ {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth {
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
}
location /ws {
proxy_pass http://whatsapp-core:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /media/ {
proxy_pass http://whatsapp-core:3001/media/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View File

@@ -49,6 +49,7 @@ interface Message {
direction: 'inbound' | 'outbound';
type: string;
content: string | null;
media_url: string | null;
created_at: string;
is_internal_note: boolean;
sent_by: string | null;
@@ -368,6 +369,63 @@ export default function Inbox(): JSX.Element {
);
}
function renderMessageContent(msg: Message): JSX.Element {
// Render media based on type
if (msg.media_url) {
const mediaType = msg.type?.toUpperCase();
if (mediaType === 'IMAGE') {
return (
<>
<img
src={msg.media_url}
alt="Imagen"
style={{
maxWidth: '100%',
maxHeight: 300,
borderRadius: 4,
marginBottom: msg.content ? 8 : 0,
}}
onClick={() => window.open(msg.media_url!, '_blank')}
/>
{msg.content && msg.content !== '[Image]' && (
<Text style={{ color: 'inherit', display: 'block' }}>{msg.content}</Text>
)}
</>
);
}
if (mediaType === 'AUDIO') {
return (
<audio controls style={{ maxWidth: '100%' }}>
<source src={msg.media_url} type="audio/ogg" />
Tu navegador no soporta audio.
</audio>
);
}
if (mediaType === 'VIDEO') {
return (
<video controls style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }}>
<source src={msg.media_url} type="video/mp4" />
Tu navegador no soporta video.
</video>
);
}
if (mediaType === 'DOCUMENT') {
return (
<a href={msg.media_url} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>
📄 {msg.content || 'Documento'}
</a>
);
}
}
// Default text content
return <Text style={{ color: 'inherit' }}>{msg.content}</Text>;
}
function renderMessage(msg: Message): JSX.Element {
return (
<div
@@ -391,7 +449,7 @@ export default function Inbox(): JSX.Element {
<FileTextOutlined /> Nota interna
</Text>
)}
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
{renderMessageContent(msg)}
</div>
<Text
type="secondary"

View File

@@ -18,6 +18,8 @@ import {
ReloadOutlined,
DeleteOutlined,
QrcodeOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
@@ -28,7 +30,7 @@ interface WhatsAppAccount {
id: string;
name: string;
phone_number: string | null;
status: 'connecting' | 'connected' | 'disconnected';
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
qr_code: string | null;
created_at: string;
}
@@ -76,6 +78,32 @@ export default function WhatsAppAccounts() {
},
});
const pauseMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.post(`/api/whatsapp/accounts/${id}/pause`);
},
onSuccess: () => {
message.success('Conexión pausada');
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
},
onError: () => {
message.error('Error al pausar');
},
});
const resumeMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.post(`/api/whatsapp/accounts/${id}/resume`);
},
onSuccess: () => {
message.success('Reconectando...');
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
},
onError: () => {
message.error('Error al reanudar');
},
});
const handleShowQR = async (account: WhatsAppAccount) => {
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
setQrModal(data);
@@ -102,13 +130,15 @@ export default function WhatsAppAccounts() {
connected: 'green',
connecting: 'orange',
disconnected: 'red',
paused: 'gold',
};
const labels: Record<string, string> = {
connected: 'Conectado',
connecting: 'Conectando',
disconnected: 'Desconectado',
paused: 'Pausado',
};
return <Tag color={colors[status]}>{labels[status]}</Tag>;
return <Tag color={colors[status]}>{labels[status] || status}</Tag>;
},
},
{
@@ -116,7 +146,7 @@ export default function WhatsAppAccounts() {
key: 'actions',
render: (_: any, record: WhatsAppAccount) => (
<Space>
{record.status !== 'connected' && (
{record.status !== 'connected' && record.status !== 'paused' && (
<Button
icon={<QrcodeOutlined />}
onClick={() => handleShowQR(record)}
@@ -124,6 +154,33 @@ export default function WhatsAppAccounts() {
Ver QR
</Button>
)}
{record.status === 'connected' && (
<Button
icon={<PauseCircleOutlined />}
onClick={() => {
Modal.confirm({
title: '¿Pausar conexión?',
content: 'La conexión se pausará pero podrás reanudarla después sin escanear QR.',
okText: 'Pausar',
onOk: () => pauseMutation.mutate(record.id),
});
}}
style={{ color: '#faad14', borderColor: '#faad14' }}
>
Pausar
</Button>
)}
{(record.status === 'paused' || record.status === 'disconnected') && (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => resumeMutation.mutate(record.id)}
loading={resumeMutation.isPending}
style={{ background: '#25D366', borderColor: '#25D366' }}
>
Reanudar
</Button>
)}
<Button
danger
icon={<DeleteOutlined />}

View File

@@ -12,8 +12,8 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {

View File

@@ -0,0 +1,4 @@
from . import models
from . import controllers
from . import wizards
from .hooks import post_init_hook

View File

@@ -0,0 +1,42 @@
{
'name': 'WhatsApp Hub',
'version': '19.0.1.0.0',
'category': 'Marketing',
'summary': 'Integración WhatsApp Central para envío y recepción de mensajes',
'description': '''
Módulo de integración con WhatsApp Central:
- Enviar mensajes WhatsApp desde cualquier registro
- Ver historial de conversaciones en contactos
- Widget de chat en tiempo real
- Envío masivo a múltiples contactos
- Automatizaciones basadas en eventos
''',
'author': 'Consultoria AS',
'website': 'https://consultoria-as.com',
'license': 'LGPL-3',
'depends': ['base', 'contacts', 'mail'],
'data': [
'security/ir.model.access.csv',
'views/whatsapp_account_views.xml',
'views/whatsapp_conversation_views.xml',
'views/res_partner_views.xml',
'views/dollars_action.xml',
'wizards/send_whatsapp_wizard.xml',
'views/whatsapp_menu.xml',
'data/whatsapp_data.xml',
],
'post_init_hook': 'post_init_hook',
'assets': {
'web.assets_backend': [
'odoo_whatsapp_hub/static/src/css/whatsapp.css',
'odoo_whatsapp_hub/static/src/css/dollars_theme.css',
'odoo_whatsapp_hub/static/src/js/chat_action.js',
'odoo_whatsapp_hub/static/src/js/dollars_chat.js',
'odoo_whatsapp_hub/static/src/xml/chat_template.xml',
'odoo_whatsapp_hub/static/src/xml/dollars_template.xml',
],
},
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1 @@
from . import webhook

View File

@@ -0,0 +1,144 @@
from odoo import http
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class WhatsAppWebhookController(http.Controller):
@http.route('/whatsapp/webhook', type='http', auth='public', methods=['POST'], csrf=False)
def webhook(self, **kwargs):
"""
Receive webhooks from WhatsApp Central.
Events: message, status_update, conversation_update
"""
try:
# Parse JSON from request body
data = json.loads(request.httprequest.data.decode('utf-8'))
event_type = data.get('type')
_logger.info(f'WhatsApp webhook received: {event_type}')
handlers = {
'message': self._handle_message,
'status_update': self._handle_status_update,
'conversation_update': self._handle_conversation_update,
'account_status': self._handle_account_status,
}
handler = handlers.get(event_type)
if handler:
result = handler(data)
return request.make_json_response(result)
return request.make_json_response({'status': 'ignored', 'reason': f'Unknown event type: {event_type}'})
except Exception as e:
_logger.error(f'WhatsApp webhook error: {e}')
return request.make_json_response({'status': 'error', 'message': str(e)})
def _handle_message(self, data):
"""Handle incoming message"""
msg_data = data.get('data', {})
account_external_id = data.get('account_id')
conversation_external_id = msg_data.get('conversation_id')
account = request.env['whatsapp.account'].sudo().search([
('external_id', '=', account_external_id)
], limit=1)
if not account:
return {'status': 'ignored', 'reason': 'Account not found'}
conversation = request.env['whatsapp.conversation'].sudo().search([
('external_id', '=', conversation_external_id)
], limit=1)
if not conversation:
phone = msg_data.get('from', '').split('@')[0]
conversation = request.env['whatsapp.conversation'].sudo().create({
'external_id': conversation_external_id,
'account_id': account.id,
'phone_number': phone,
'contact_name': msg_data.get('contact_name'),
'status': 'bot',
})
# Try to find partner by phone
partner = request.env['res.partner'].sudo().search([
('phone', 'ilike', phone[-10:]),
], limit=1)
if partner:
conversation.partner_id = partner
# Get direction from webhook data (inbound or outbound)
direction = msg_data.get('direction', 'inbound')
request.env['whatsapp.message'].sudo().create({
'external_id': msg_data.get('id'),
'conversation_id': conversation.id,
'direction': direction,
'message_type': msg_data.get('type', 'text'),
'content': msg_data.get('content'),
'media_url': msg_data.get('media_url'),
'status': 'delivered' if direction == 'inbound' else 'sent',
})
return {'status': 'ok'}
def _handle_status_update(self, data):
"""Handle message status update"""
msg_data = data.get('data', {})
external_id = msg_data.get('message_id')
new_status = msg_data.get('status')
message = request.env['whatsapp.message'].sudo().search([
('external_id', '=', external_id)
], limit=1)
if message:
message.write({'status': new_status})
return {'status': 'ok'}
def _handle_conversation_update(self, data):
"""Handle conversation status update"""
conv_data = data.get('data', {})
external_id = conv_data.get('conversation_id')
new_status = conv_data.get('status')
conversation = request.env['whatsapp.conversation'].sudo().search([
('external_id', '=', external_id)
], limit=1)
if conversation:
conversation.write({'status': new_status})
return {'status': 'ok'}
def _handle_account_status(self, data):
"""Handle account status change"""
acc_data = data.get('data', {})
external_id = data.get('account_id')
new_status = acc_data.get('status')
account = request.env['whatsapp.account'].sudo().search([
('external_id', '=', external_id)
], limit=1)
if account:
account.write({
'status': new_status,
'phone_number': acc_data.get('phone_number'),
'qr_code': acc_data.get('qr_code'),
})
return {'status': 'ok'}
@http.route('/whatsapp/webhook/test', type='http', auth='public', methods=['GET'])
def webhook_test(self):
"""Test endpoint to verify webhook connectivity"""
return request.make_json_response({'status': 'ok', 'message': 'WhatsApp webhook is active'})

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- Default WhatsApp Account (to be configured) -->
<record id="default_whatsapp_account" model="whatsapp.account">
<field name="name">WhatsApp Principal</field>
<field name="api_url">http://localhost:8000</field>
<field name="is_default">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,59 @@
from odoo import api, SUPERUSER_ID
def post_init_hook(env):
"""Crear permisos de acceso después de instalar el módulo."""
_create_access_rights(env)
def _create_access_rights(env):
"""Crear los registros ir.model.access para los modelos del módulo."""
Access = env['ir.model.access'].sudo()
# Definir permisos: (model_name, group_xmlid, read, write, create, unlink)
permissions = [
# WhatsApp Account
('whatsapp.account', 'base.group_user', 1, 0, 0, 0),
('whatsapp.account', 'base.group_system', 1, 1, 1, 1),
# WhatsApp Conversation
('whatsapp.conversation', 'base.group_user', 1, 1, 1, 0),
('whatsapp.conversation', 'base.group_system', 1, 1, 1, 1),
# WhatsApp Message
('whatsapp.message', 'base.group_user', 1, 1, 1, 0),
('whatsapp.message', 'base.group_system', 1, 1, 1, 1),
# Wizards
('whatsapp.send.wizard', 'base.group_user', 1, 1, 1, 1),
('whatsapp.mass.wizard', 'base.group_user', 1, 1, 1, 1),
]
for model_name, group_xmlid, read, write, create, unlink in permissions:
# Buscar el modelo
model = env['ir.model'].sudo().search([('model', '=', model_name)], limit=1)
if not model:
continue
# Buscar el grupo
group = env.ref(group_xmlid, raise_if_not_found=False)
if not group:
continue
# Verificar si ya existe
existing = Access.search([
('model_id', '=', model.id),
('group_id', '=', group.id)
], limit=1)
if existing:
continue
# Crear el permiso
access_name = f'{model_name}.{group_xmlid.split(".")[-1]}'
Access.create({
'name': access_name,
'model_id': model.id,
'group_id': group.id,
'perm_read': read,
'perm_write': write,
'perm_create': create,
'perm_unlink': unlink,
})

View File

@@ -0,0 +1,4 @@
from . import res_partner
from . import whatsapp_account
from . import whatsapp_conversation
from . import whatsapp_message

View File

@@ -0,0 +1,116 @@
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
whatsapp_conversation_ids = fields.One2many(
'whatsapp.conversation',
'partner_id',
string='Conversaciones WhatsApp',
)
whatsapp_conversation_count = fields.Integer(
string='Conversaciones',
compute='_compute_whatsapp_conversation_count',
)
whatsapp_last_conversation_id = fields.Many2one(
'whatsapp.conversation',
string='Última Conversación',
compute='_compute_whatsapp_last_conversation',
)
whatsapp_unread_count = fields.Integer(
string='Mensajes No Leídos',
compute='_compute_whatsapp_unread_count',
)
@api.depends('whatsapp_conversation_ids')
def _compute_whatsapp_conversation_count(self):
for partner in self:
partner.whatsapp_conversation_count = len(partner.whatsapp_conversation_ids)
@api.depends('whatsapp_conversation_ids.last_message_at')
def _compute_whatsapp_last_conversation(self):
for partner in self:
conversations = partner.whatsapp_conversation_ids.sorted(
'last_message_at', reverse=True
)
partner.whatsapp_last_conversation_id = conversations[:1].id if conversations else False
@api.depends('whatsapp_conversation_ids.unread_count')
def _compute_whatsapp_unread_count(self):
for partner in self:
partner.whatsapp_unread_count = sum(
partner.whatsapp_conversation_ids.mapped('unread_count')
)
def action_open_whatsapp_conversations(self):
"""Open WhatsApp conversations for this partner"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Conversaciones - {self.name}',
'res_model': 'whatsapp.conversation',
'view_mode': 'tree,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id},
}
def action_send_whatsapp(self):
"""Open wizard to send WhatsApp message"""
self.ensure_one()
phone = self.mobile or self.phone
if not phone:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'El contacto no tiene número de teléfono',
'type': 'warning',
}
}
return {
'type': 'ir.actions.act_window',
'name': 'Enviar WhatsApp',
'res_model': 'whatsapp.send.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_id': self.id,
'default_phone': phone,
},
}
def action_open_whatsapp_chat(self):
"""Open or create WhatsApp conversation"""
self.ensure_one()
phone = self.mobile or self.phone
if not phone:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'El contacto no tiene número de teléfono',
'type': 'warning',
}
}
account = self.env['whatsapp.account'].get_default_account()
if not account:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'No hay cuenta WhatsApp configurada',
'type': 'warning',
}
}
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
phone=phone,
account_id=account.id,
contact_name=self.name,
)
conversation.partner_id = self.id
return conversation.action_open_chat()

View File

@@ -0,0 +1,110 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
import requests
import logging
_logger = logging.getLogger(__name__)
class WhatsAppAccount(models.Model):
_name = 'whatsapp.account'
_description = 'WhatsApp Account'
_order = 'name'
name = fields.Char(string='Nombre', required=True)
phone_number = fields.Char(string='Número de Teléfono')
status = fields.Selection([
('disconnected', 'Desconectado'),
('connecting', 'Conectando'),
('connected', 'Conectado'),
], string='Estado', default='disconnected', readonly=True)
qr_code = fields.Text(string='Código QR')
external_id = fields.Char(string='ID Externo', help='ID en WhatsApp Central')
api_url = fields.Char(
string='URL API',
default='http://localhost:8000',
required=True,
)
api_key = fields.Char(string='API Key')
is_default = fields.Boolean(string='Cuenta por Defecto')
company_id = fields.Many2one(
'res.company',
string='Compañía',
default=lambda self: self.env.company,
)
conversation_count = fields.Integer(
string='Conversaciones',
compute='_compute_conversation_count',
)
@api.depends()
def _compute_conversation_count(self):
for account in self:
account.conversation_count = self.env['whatsapp.conversation'].search_count([
('account_id', '=', account.id)
])
@api.model
def get_default_account(self):
"""Get default WhatsApp account"""
account = self.search([('is_default', '=', True)], limit=1)
if not account:
account = self.search([], limit=1)
return account
def action_sync_status(self):
"""Sync status from WhatsApp Central"""
self.ensure_one()
if not self.external_id:
raise UserError('Esta cuenta no está vinculada a WhatsApp Central')
try:
# Use internal endpoint (no auth required)
response = requests.get(
f'{self.api_url}/api/whatsapp/internal/odoo/accounts/{self.external_id}',
timeout=10,
)
if response.status_code == 200:
data = response.json()
self.write({
'status': data.get('status', 'disconnected'),
'phone_number': data.get('phone_number'),
'qr_code': data.get('qr_code'),
})
else:
raise UserError(f'Error del servidor: {response.status_code}')
except requests.exceptions.RequestException as e:
_logger.error(f'Error syncing WhatsApp account: {e}')
raise UserError(f'Error de conexión: {e}')
def action_view_conversations(self):
"""Open conversations for this account"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Conversaciones',
'res_model': 'whatsapp.conversation',
'view_mode': 'tree,form',
'domain': [('account_id', '=', self.id)],
'context': {'default_account_id': self.id},
}
def _get_headers(self):
"""Get API headers"""
headers = {'Content-Type': 'application/json'}
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
return headers
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('is_default'):
self.search([('is_default', '=', True)]).write({'is_default': False})
break
return super().create(vals_list)
def write(self, vals):
if vals.get('is_default'):
self.search([('is_default', '=', True), ('id', 'not in', self.ids)]).write({'is_default': False})
return super().write(vals)

View File

@@ -0,0 +1,214 @@
from odoo import models, fields, api
import logging
_logger = logging.getLogger(__name__)
class WhatsAppConversation(models.Model):
_name = 'whatsapp.conversation'
_description = 'WhatsApp Conversation'
_order = 'last_message_at desc'
_rec_name = 'display_name'
external_id = fields.Char(string='ID Externo', index=True)
account_id = fields.Many2one(
'whatsapp.account',
string='Cuenta WhatsApp',
required=True,
ondelete='cascade',
)
partner_id = fields.Many2one(
'res.partner',
string='Contacto',
ondelete='set null',
)
phone_number = fields.Char(string='Teléfono', required=True, index=True)
contact_name = fields.Char(string='Nombre del Contacto')
status = fields.Selection([
('bot', 'Bot'),
('waiting', 'En Espera'),
('active', 'Activa'),
('resolved', 'Resuelta'),
], string='Estado', default='bot')
assigned_user_id = fields.Many2one(
'res.users',
string='Agente Asignado',
)
last_message_at = fields.Datetime(string='Último Mensaje')
last_message_preview = fields.Char(
string='Último Mensaje',
compute='_compute_last_message',
store=True,
)
message_ids = fields.One2many(
'whatsapp.message',
'conversation_id',
string='Mensajes',
)
message_count = fields.Integer(
string='Mensajes',
compute='_compute_message_count',
)
display_name = fields.Char(
string='Nombre',
compute='_compute_display_name',
store=True,
)
unread_count = fields.Integer(
string='No Leídos',
compute='_compute_unread_count',
store=True,
)
@api.depends('partner_id', 'contact_name', 'phone_number')
def _compute_display_name(self):
for conv in self:
if conv.partner_id:
conv.display_name = conv.partner_id.name
elif conv.contact_name:
conv.display_name = conv.contact_name
else:
conv.display_name = conv.phone_number
@api.depends('message_ids')
def _compute_message_count(self):
for conv in self:
conv.message_count = len(conv.message_ids)
@api.depends('message_ids.content')
def _compute_last_message(self):
for conv in self:
last_msg = conv.message_ids[:1]
if last_msg:
content = last_msg.content or ''
conv.last_message_preview = content[:50] + '...' if len(content) > 50 else content
else:
conv.last_message_preview = ''
@api.depends('message_ids.is_read')
def _compute_unread_count(self):
for conv in self:
conv.unread_count = len(conv.message_ids.filtered(
lambda m: m.direction == 'inbound' and not m.is_read
))
def action_open_chat(self):
"""Open chat view for this conversation"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': self.display_name,
'res_model': 'whatsapp.conversation',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
}
def action_mark_resolved(self):
"""Mark conversation as resolved"""
self.write({'status': 'resolved'})
def action_assign_to_me(self):
"""Assign conversation to current user"""
self.write({
'assigned_user_id': self.env.user.id,
'status': 'active',
})
def action_open_send_wizard(self):
"""Open wizard to send WhatsApp message"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Enviar WhatsApp',
'res_model': 'whatsapp.send.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_phone': self.phone_number,
'default_account_id': self.account_id.id,
'default_partner_id': self.partner_id.id if self.partner_id else False,
},
}
def action_open_chat(self):
"""Open fullscreen chat interface"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'whatsapp_chat',
'name': self.display_name,
'context': {
'active_id': self.id,
},
}
def send_message_from_chat(self, message):
"""Send a message from the chat interface"""
self.ensure_one()
import requests
account = self.account_id
if not account or not account.api_url:
raise Exception("Cuenta WhatsApp no configurada")
# Send via API
api_url = account.api_url.rstrip('/')
response = requests.post(
f"{api_url}/whatsapp/send",
json={
'account_id': account.external_id,
'phone': self.phone_number,
'message': message,
},
timeout=30,
)
if response.status_code != 200:
raise Exception(f"Error al enviar: {response.text}")
result = response.json()
# Create local message record
self.env['whatsapp.message'].create({
'conversation_id': self.id,
'direction': 'outbound',
'message_type': 'text',
'content': message,
'status': 'sent',
'sent_by_id': self.env.user.id,
'external_id': result.get('message_id'),
})
# Update conversation
self.write({
'last_message_at': fields.Datetime.now(),
'status': 'active' if self.status == 'bot' else self.status,
})
return True
@api.model
def find_or_create_by_phone(self, phone, account_id, contact_name=None):
"""Find or create conversation by phone number"""
conversation = self.search([
('phone_number', '=', phone),
('account_id', '=', account_id),
('status', '!=', 'resolved'),
], limit=1)
if not conversation:
partner = self.env['res.partner'].search([
'|',
('phone', 'ilike', phone[-10:]),
('mobile', 'ilike', phone[-10:]),
], limit=1)
conversation = self.create({
'phone_number': phone,
'account_id': account_id,
'contact_name': contact_name,
'partner_id': partner.id if partner else False,
})
return conversation

View File

@@ -0,0 +1,124 @@
from odoo import models, fields, api
import requests
import logging
_logger = logging.getLogger(__name__)
class WhatsAppMessage(models.Model):
_name = 'whatsapp.message'
_description = 'WhatsApp Message'
_order = 'create_date desc'
external_id = fields.Char(string='ID Externo', index=True)
conversation_id = fields.Many2one(
'whatsapp.conversation',
string='Conversación',
required=True,
ondelete='cascade',
)
direction = fields.Selection([
('inbound', 'Entrante'),
('outbound', 'Saliente'),
], string='Dirección', required=True)
message_type = fields.Selection([
('text', 'Texto'),
('image', 'Imagen'),
('audio', 'Audio'),
('video', 'Video'),
('document', 'Documento'),
('location', 'Ubicación'),
('contact', 'Contacto'),
('sticker', 'Sticker'),
], string='Tipo', default='text')
content = fields.Text(string='Contenido')
media_url = fields.Char(string='URL Media')
status = fields.Selection([
('pending', 'Pendiente'),
('sent', 'Enviado'),
('delivered', 'Entregado'),
('read', 'Leído'),
('failed', 'Fallido'),
], string='Estado', default='pending')
is_read = fields.Boolean(string='Leído', default=False)
sent_by_id = fields.Many2one(
'res.users',
string='Enviado por',
)
error_message = fields.Text(string='Error')
@api.model
def create(self, vals):
message = super().create(vals)
if message.conversation_id:
message.conversation_id.write({
'last_message_at': fields.Datetime.now(),
})
return message
def action_resend(self):
"""Resend failed message"""
self.ensure_one()
if self.status != 'failed':
return
self._send_to_whatsapp_central()
def _send_to_whatsapp_central(self):
"""Send message via WhatsApp Central API"""
self.ensure_one()
account = self.conversation_id.account_id
phone_number = self.conversation_id.phone_number
if not account.external_id:
self.write({
'status': 'failed',
'error_message': 'La cuenta no está vinculada a WhatsApp Central',
})
return
try:
# Use internal endpoint (no auth required)
response = requests.post(
f'{account.api_url}/api/whatsapp/internal/odoo/send',
json={
'phone_number': phone_number,
'message': self.content,
'account_id': account.external_id,
},
timeout=30,
)
if response.status_code == 200:
data = response.json()
self.write({
'external_id': data.get('message_id'),
'status': 'sent',
'error_message': False,
})
else:
self.write({
'status': 'failed',
'error_message': response.text,
})
except Exception as e:
_logger.error(f'Error sending WhatsApp message: {e}')
self.write({
'status': 'failed',
'error_message': str(e),
})
@api.model
def send_message(self, conversation_id, content, message_type='text', media_url=None):
"""Helper to send a new message"""
message = self.create({
'conversation_id': conversation_id,
'direction': 'outbound',
'message_type': message_type,
'content': content,
'media_url': media_url,
'sent_by_id': self.env.user.id,
})
message._send_to_whatsapp_central()
return message

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_whatsapp_account_user,whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
access_whatsapp_account_admin,whatsapp.account.admin,model_whatsapp_account,base.group_system,1,1,1,1
access_whatsapp_conversation_user,whatsapp.conversation.user,model_whatsapp_conversation,base.group_user,1,1,1,0
access_whatsapp_conversation_admin,whatsapp.conversation.admin,model_whatsapp_conversation,base.group_system,1,1,1,1
access_whatsapp_message_user,whatsapp.message.user,model_whatsapp_message,base.group_user,1,1,1,0
access_whatsapp_message_admin,whatsapp.message.admin,model_whatsapp_message,base.group_system,1,1,1,1
access_whatsapp_send_wizard_user,whatsapp.send.wizard.user,model_whatsapp_send_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_whatsapp_account_user whatsapp.account.user model_whatsapp_account base.group_user 1 0 0 0
3 access_whatsapp_account_admin whatsapp.account.admin model_whatsapp_account base.group_system 1 1 1 1
4 access_whatsapp_conversation_user whatsapp.conversation.user model_whatsapp_conversation base.group_user 1 1 1 0
5 access_whatsapp_conversation_admin whatsapp.conversation.admin model_whatsapp_conversation base.group_system 1 1 1 1
6 access_whatsapp_message_user whatsapp.message.user model_whatsapp_message base.group_user 1 1 1 0
7 access_whatsapp_message_admin whatsapp.message.admin model_whatsapp_message base.group_system 1 1 1 1
8 access_whatsapp_send_wizard_user whatsapp.send.wizard.user model_whatsapp_send_wizard base.group_user 1 1 1 1

View File

@@ -0,0 +1,774 @@
/* DOLLARS WhatsApp Theme - Dark Mode with Amber Accents */
/* All styles are scoped to .o_dollars_chat to avoid affecting other Odoo views */
/* ============================================
MAIN CONTAINER - All styles scoped here
============================================ */
.o_dollars_chat {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a24;
--bg-hover: #22222e;
--bg-active: #2a2a38;
--accent-primary: #f59e0b;
--accent-secondary: #fbbf24;
--accent-glow: rgba(245, 158, 11, 0.3);
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--border-color: #27272a;
--border-light: #3f3f46;
--success: #22c55e;
--danger: #ef4444;
--info: #3b82f6;
--msg-inbound-bg: #1e1e2a;
--msg-outbound-bg: #2d2a1f;
--msg-outbound-border: rgba(245, 158, 11, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
--shadow-glow: 0 0 20px var(--accent-glow);
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--text-primary);
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
/* Header */
.o_dollars_chat .o_dollars_header {
background: var(--bg-secondary);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
position: relative;
}
.o_dollars_chat .o_dollars_header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
animation: dollarsHeaderGlow 3s ease-in-out infinite;
}
@keyframes dollarsHeaderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.o_dollars_chat .o_dollars_logo {
display: flex;
align-items: center;
gap: 12px;
}
.o_dollars_chat .o_dollars_logo_icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: var(--shadow-glow);
color: #000;
}
.o_dollars_chat .o_dollars_logo_text {
font-size: 20px;
font-weight: 700;
letter-spacing: 2px;
color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_status {
display: flex;
align-items: center;
gap: 16px;
}
.o_dollars_chat .o_dollars_status_item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
font-family: monospace;
}
.o_dollars_chat .o_dollars_status_dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: dollarsPulse 2s infinite;
}
@keyframes dollarsPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.o_dollars_chat .o_dollars_header_actions {
display: flex;
align-items: center;
gap: 8px;
}
.o_dollars_chat .o_dollars_header_btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.o_dollars_chat .o_dollars_header_btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-primary);
}
/* Main Layout */
.o_dollars_chat .o_dollars_main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.o_dollars_chat .o_dollars_sidebar {
width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.o_dollars_chat .o_dollars_sidebar_header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.o_dollars_chat .o_dollars_search {
position: relative;
}
.o_dollars_chat .o_dollars_search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 12px 10px 40px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_search input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.o_dollars_chat .o_dollars_search input::placeholder {
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_search_icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_conversations {
flex: 1;
overflow-y: auto;
}
.o_dollars_chat .o_dollars_conv_item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.o_dollars_chat .o_dollars_conv_item:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_conv_item.active {
background: var(--bg-active);
border-left-color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_conv_avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
flex-shrink: 0;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_conv_avatar.online::after {
content: '';
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background: var(--success);
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
.o_dollars_chat .o_dollars_conv_info {
flex: 1;
min-width: 0;
}
.o_dollars_chat .o_dollars_conv_name {
font-weight: 600;
font-size: 14px;
margin-bottom: 2px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_conv_preview {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_dollars_chat .o_dollars_conv_meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.o_dollars_chat .o_dollars_conv_time {
font-size: 11px;
color: var(--text-muted);
font-family: monospace;
}
.o_dollars_chat .o_dollars_conv_badge {
background: var(--accent-primary);
color: #000;
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: var(--radius-full);
min-width: 20px;
text-align: center;
}
/* Chat Area */
.o_dollars_chat .o_dollars_chat_area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
min-width: 0;
}
.o_dollars_chat .o_dollars_chat_header {
padding: 16px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 16px;
}
.o_dollars_chat .o_dollars_chat_avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #000;
}
.o_dollars_chat .o_dollars_chat_info {
flex: 1;
}
.o_dollars_chat .o_dollars_chat_name {
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
margin-bottom: 2px;
}
.o_dollars_chat .o_dollars_chat_status {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.o_dollars_chat .o_dollars_chat_actions {
display: flex;
gap: 8px;
}
.o_dollars_chat .o_dollars_chat_btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_chat_btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
border-color: var(--accent-primary);
}
/* Messages */
.o_dollars_chat .o_dollars_messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar {
width: 6px;
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar-track {
background: var(--bg-primary);
}
.o_dollars_chat .o_dollars_messages::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.o_dollars_chat .o_dollars_message {
display: flex;
gap: 12px;
max-width: 70%;
animation: dollarsMessageIn 0.2s ease-out;
}
@keyframes dollarsMessageIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.o_dollars_chat .o_dollars_message.inbound {
align-self: flex-start;
}
.o_dollars_chat .o_dollars_message.outbound {
align-self: flex-end;
flex-direction: row-reverse;
}
.o_dollars_chat .o_dollars_msg_avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_message.outbound .o_dollars_msg_avatar {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: #000;
}
.o_dollars_chat .o_dollars_msg_content {
display: flex;
flex-direction: column;
gap: 4px;
}
.o_dollars_chat .o_dollars_msg_header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.o_dollars_chat .o_dollars_msg_sender {
font-weight: 600;
color: var(--accent-primary);
}
.o_dollars_chat .o_dollars_message.inbound .o_dollars_msg_sender {
color: var(--info);
}
.o_dollars_chat .o_dollars_msg_time {
color: var(--text-muted);
font-family: monospace;
font-size: 11px;
}
.o_dollars_chat .o_dollars_msg_bubble {
background: var(--msg-inbound-bg);
padding: 12px 16px;
border-radius: var(--radius-lg);
border-top-left-radius: var(--radius-sm);
line-height: 1.5;
font-size: 14px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_message.outbound .o_dollars_msg_bubble {
background: var(--msg-outbound-bg);
border: 1px solid var(--msg-outbound-border);
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-sm);
}
.o_dollars_chat .o_dollars_msg_status {
font-size: 11px;
color: var(--text-muted);
text-align: right;
}
.o_dollars_chat .o_dollars_msg_status.read {
color: var(--info);
}
/* Media */
.o_dollars_chat .o_dollars_msg_image {
max-width: 280px;
max-height: 200px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
margin-bottom: 8px;
}
.o_dollars_chat .o_dollars_msg_audio {
width: 240px;
height: 40px;
}
.o_dollars_chat .o_dollars_msg_video {
max-width: 320px;
border-radius: var(--radius-md);
}
.o_dollars_chat .o_dollars_msg_doc {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
margin-bottom: 8px;
}
.o_dollars_chat .o_dollars_msg_doc:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_msg_doc i {
font-size: 24px;
color: var(--accent-primary);
}
/* Input Area */
.o_dollars_chat .o_dollars_input_area {
padding: 16px 24px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
align-items: flex-end;
gap: 12px;
}
.o_dollars_chat .o_dollars_input_wrapper {
flex: 1;
}
.o_dollars_chat .o_dollars_input_wrapper textarea {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 12px 16px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
min-height: 44px;
max-height: 120px;
transition: all 0.15s ease;
}
.o_dollars_chat .o_dollars_input_wrapper textarea:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.o_dollars_chat .o_dollars_input_wrapper textarea::placeholder {
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_send_btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
color: #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
font-size: 18px;
box-shadow: var(--shadow-glow);
}
.o_dollars_chat .o_dollars_send_btn:hover {
transform: scale(1.05);
}
.o_dollars_chat .o_dollars_send_btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Details Panel */
.o_dollars_chat .o_dollars_details {
width: 280px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.o_dollars_chat .o_dollars_details_header {
padding: 24px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
.o_dollars_chat .o_dollars_details_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin: 0 auto 16px;
box-shadow: var(--shadow-glow);
color: #000;
}
.o_dollars_chat .o_dollars_details_name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_details_phone {
font-size: 14px;
color: var(--text-secondary);
font-family: monospace;
}
.o_dollars_chat .o_dollars_details_status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-full);
font-size: 12px;
color: var(--text-secondary);
}
.o_dollars_chat .o_dollars_details_content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.o_dollars_chat .o_dollars_details_section {
margin-bottom: 24px;
}
.o_dollars_chat .o_dollars_details_section_title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 12px;
font-weight: 600;
}
.o_dollars_chat .o_dollars_details_item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: 8px;
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_details_item:hover {
background: var(--bg-hover);
}
.o_dollars_chat .o_dollars_details_item i {
color: var(--accent-primary);
width: 20px;
text-align: center;
}
.o_dollars_chat .o_dollars_details_item span {
font-size: 13px;
}
/* Empty State */
.o_dollars_chat .o_dollars_empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.o_dollars_chat .o_dollars_empty_icon {
width: 80px;
height: 80px;
background: var(--bg-tertiary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
margin-bottom: 24px;
color: var(--text-muted);
}
.o_dollars_chat .o_dollars_empty_title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.o_dollars_chat .o_dollars_empty_text {
color: var(--text-secondary);
font-size: 14px;
}
/* Loading */
.o_dollars_chat .o_dollars_loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.o_dollars_chat .o_dollars_spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: dollarsSpin 1s linear infinite;
}
@keyframes dollarsSpin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 1200px) {
.o_dollars_chat .o_dollars_details {
display: none;
}
}
@media (max-width: 768px) {
.o_dollars_chat .o_dollars_sidebar {
width: 100%;
position: absolute;
z-index: 10;
}
}

View File

@@ -0,0 +1,377 @@
/* WhatsApp Hub - Scoped Styles */
/* All styles use specific class prefixes to avoid affecting other Odoo views */
/* ============================================
WHATSAPP CHAT FULLSCREEN (Theme selector)
============================================ */
.o_whatsapp_chat_fullscreen {
--whatsapp-green: #25D366;
--whatsapp-dark-green: #128C7E;
--whatsapp-light-green: #DCF8C6;
--whatsapp-bg: #E5DDD5;
}
/* WhatsApp Theme */
.o_whatsapp_chat_fullscreen.theme-whatsapp {
--chat-bg: #E5DDD5;
--header-bg: #128C7E;
--header-text: #ffffff;
--input-bg: #F0F2F5;
--input-field-bg: #ffffff;
--input-text: #333333;
--input-border: #ddd;
--msg-inbound-bg: #ffffff;
--msg-inbound-text: #333333;
--msg-outbound-bg: #DCF8C6;
--msg-outbound-text: #333333;
--msg-meta-text: #667781;
--btn-primary-bg: #25D366;
--btn-primary-hover: #128C7E;
--status-read: #53bdeb;
--avatar-bg: rgba(255,255,255,0.2);
--avatar-text: #ffffff;
}
/* DRRR Theme */
.o_whatsapp_chat_fullscreen.theme-drrr {
--chat-bg: #0a0a0a;
--header-bg: #1a1a1a;
--header-text: #00ff88;
--input-bg: #1a1a1a;
--input-field-bg: #0d0d0d;
--input-text: #00ff88;
--input-border: rgba(0, 255, 136, 0.2);
--msg-inbound-bg: transparent;
--msg-inbound-text: #00ccff;
--msg-outbound-bg: transparent;
--msg-outbound-text: #00ff88;
--msg-meta-text: #666666;
--btn-primary-bg: #00ff88;
--btn-primary-hover: #00cc6a;
--status-read: #00ccff;
--avatar-bg: rgba(0, 255, 136, 0.13);
--avatar-text: #00ff88;
}
.o_whatsapp_chat_fullscreen {
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
background: var(--chat-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.3s ease;
}
.o_whatsapp_chat_fullscreen.theme-drrr {
font-family: 'Courier New', monospace;
}
/* Header */
.o_whatsapp_chat_fullscreen .o_whatsapp_chat_header {
background: var(--header-bg);
color: var(--header-text);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.o_whatsapp_chat_fullscreen .avatar {
width: 45px;
height: 45px;
border-radius: 50%;
background: var(--avatar-bg);
color: var(--avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 20px;
}
.o_whatsapp_chat_fullscreen .contact-info {
flex: 1;
}
.o_whatsapp_chat_fullscreen .contact-name {
font-weight: 600;
font-size: 17px;
}
.o_whatsapp_chat_fullscreen .contact-status {
font-size: 13px;
opacity: 0.85;
}
/* Theme Toggle */
.o_whatsapp_chat_fullscreen .theme-toggle {
background: transparent;
border: 1px solid var(--header-text);
color: var(--header-text);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.o_whatsapp_chat_fullscreen .theme-toggle:hover {
background: var(--header-text);
color: var(--header-bg);
}
/* Messages Container */
.o_whatsapp_chat_fullscreen .o_whatsapp_messages_container {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* WhatsApp Theme Messages */
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message {
max-width: 65%;
padding: 8px 12px;
border-radius: 8px;
position: relative;
word-wrap: break-word;
box-shadow: 0 1px 1px rgba(0,0,0,0.1);
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.inbound {
align-self: flex-start;
background: var(--msg-inbound-bg);
color: var(--msg-inbound-text);
border-top-left-radius: 0;
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.outbound {
align-self: flex-end;
background: var(--msg-outbound-bg);
color: var(--msg-outbound-text);
border-top-right-radius: 0;
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-sender {
display: none;
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-content {
margin-bottom: 4px;
line-height: 1.4;
white-space: pre-wrap;
}
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-meta {
font-size: 11px;
color: var(--msg-meta-text);
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
/* DRRR Theme Messages */
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_messages_container {
padding: 20px;
gap: 2px;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message {
max-width: 100%;
padding: 4px 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
background: transparent;
box-shadow: none;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.inbound,
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.outbound {
align-self: flex-start;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.inbound {
color: var(--msg-inbound-text);
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_message.outbound {
color: var(--msg-outbound-text);
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender {
display: inline;
font-weight: bold;
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender::before {
content: '[';
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-sender::after {
content: ']';
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-content {
display: inline;
margin: 0;
}
.o_whatsapp_chat_fullscreen.theme-drrr .message-meta {
display: inline;
font-size: 10px;
color: var(--msg-meta-text);
margin-left: auto;
}
/* Input Area */
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area {
background: var(--input-bg);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
border-top: 1px solid var(--input-border);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area input {
flex: 1;
border: none;
border-radius: 24px;
padding: 12px 20px;
outline: none;
font-size: 15px;
background: var(--input-field-bg);
color: var(--input-text);
font-family: inherit;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_input_area input {
border-radius: 4px;
border: 1px solid var(--input-border);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area input:focus {
box-shadow: 0 0 0 2px var(--btn-primary-bg);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: var(--btn-primary-bg);
border-color: var(--btn-primary-bg);
color: white;
}
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_input_area .btn {
color: #0a0a0a;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn:hover {
background: var(--btn-primary-hover);
}
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area .btn:disabled {
opacity: 0.5;
}
/* Media */
.o_whatsapp_chat_fullscreen .o_whatsapp_media_image {
max-width: 100%;
max-height: 250px;
border-radius: 4px;
cursor: pointer;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_audio {
max-width: 100%;
height: 40px;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_video {
max-width: 100%;
max-height: 200px;
border-radius: 4px;
}
.o_whatsapp_chat_fullscreen .o_whatsapp_media_doc {
display: flex;
align-items: center;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
color: inherit;
text-decoration: none;
}
/* ============================================
FORM VIEW STYLES (for Odoo standard views)
============================================ */
.o_whatsapp_chat_wrapper {
background: #e5ddd5 !important;
border-radius: 8px;
padding: 16px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
}
/* Status badges in list/form views */
.o_whatsapp_status_connected {
color: #25D366;
}
.o_whatsapp_status_disconnected {
color: #dc3545;
}
/* WhatsApp button style */
.btn-whatsapp {
background-color: #25D366;
border-color: #25D366;
color: white;
}
.btn-whatsapp:hover {
background-color: #128C7E;
border-color: #128C7E;
color: white;
}
/* Unread badge */
.o_whatsapp_unread_badge {
background: #dc3545;
color: white;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: bold;
}
/* QR Code display */
.o_whatsapp_qr_code {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.o_whatsapp_qr_code img {
max-width: 300px;
border: 1px solid #ddd;
border-radius: 8px;
}

View File

@@ -0,0 +1,232 @@
/** @odoo-module **/
import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
const THEMES = {
whatsapp: {
name: "WhatsApp",
class: "theme-whatsapp",
icon: "fa-whatsapp",
},
drrr: {
name: "DRRR",
class: "theme-drrr",
icon: "fa-terminal",
},
};
export class WhatsAppChat extends Component {
static template = "odoo_whatsapp_hub.WhatsAppChat";
static props = {
action: { type: Object },
actionId: { type: [Number, Boolean], optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
// Load saved theme or default to whatsapp
const savedTheme = localStorage.getItem("whatsapp_chat_theme") || "whatsapp";
this.state = useState({
messages: [],
newMessage: "",
conversation: null,
loading: true,
sending: false,
currentTheme: savedTheme,
});
this.messagesEndRef = useRef("messagesEnd");
this.inputRef = useRef("messageInput");
onWillStart(async () => {
await this.loadConversation();
});
onMounted(() => {
this.scrollToBottom();
if (this.inputRef.el) {
this.inputRef.el.focus();
}
});
}
get conversationId() {
return this.props.action.context?.active_id ||
this.props.action.params?.conversation_id;
}
get themeClass() {
return THEMES[this.state.currentTheme]?.class || "theme-whatsapp";
}
get currentThemeName() {
return THEMES[this.state.currentTheme]?.name || "WhatsApp";
}
get nextThemeName() {
const nextTheme = this.state.currentTheme === "whatsapp" ? "drrr" : "whatsapp";
return THEMES[nextTheme]?.name || "DRRR";
}
toggleTheme() {
const newTheme = this.state.currentTheme === "whatsapp" ? "drrr" : "whatsapp";
this.state.currentTheme = newTheme;
localStorage.setItem("whatsapp_chat_theme", newTheme);
}
async loadConversation() {
this.state.loading = true;
try {
const conversationId = this.conversationId;
if (!conversationId) {
this.state.loading = false;
return;
}
// Load conversation data
const [conversation] = await this.orm.read(
"whatsapp.conversation",
[conversationId],
["display_name", "phone_number", "account_id", "status", "partner_id"]
);
this.state.conversation = conversation;
// Load messages
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", conversationId]],
["content", "direction", "create_date", "message_type", "media_url", "status", "sent_by_id"],
{ order: "create_date asc" }
);
this.state.messages = messages;
// Mark messages as read
const unreadIds = messages
.filter(m => m.direction === "inbound" && !m.is_read)
.map(m => m.id);
if (unreadIds.length > 0) {
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
}
} catch (error) {
console.error("Error loading conversation:", error);
this.notification.add(_t("Error loading conversation"), { type: "danger" });
}
this.state.loading = false;
setTimeout(() => this.scrollToBottom(), 100);
}
scrollToBottom() {
if (this.messagesEndRef.el) {
this.messagesEndRef.el.scrollIntoView({ behavior: "smooth" });
}
}
onInputChange(ev) {
this.state.newMessage = ev.target.value;
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
async sendMessage() {
const message = this.state.newMessage.trim();
if (!message || this.state.sending) return;
this.state.sending = true;
try {
// Call the send method on the conversation
await this.orm.call(
"whatsapp.conversation",
"send_message_from_chat",
[[this.conversationId], message]
);
this.state.newMessage = "";
await this.loadConversation();
this.notification.add(_t("Message sent"), { type: "success" });
} catch (error) {
console.error("Error sending message:", error);
this.notification.add(_t("Error sending message: ") + error.message, { type: "danger" });
}
this.state.sending = false;
if (this.inputRef.el) {
this.inputRef.el.focus();
}
}
async refreshMessages() {
await this.loadConversation();
}
formatTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
formatDate(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return _t("Today");
} else if (date.toDateString() === yesterday.toDateString()) {
return _t("Yesterday");
}
return date.toLocaleDateString();
}
getMessageClass(message) {
return message.direction === "outbound" ? "outbound" : "inbound";
}
getSenderName(message) {
if (message.direction === "outbound") {
// For DRRR theme, show "Tú" or agent name
if (message.sent_by_id && message.sent_by_id[1]) {
return message.sent_by_id[1];
}
return "Tú";
} else {
// For inbound, show contact name
return this.state.conversation?.display_name || "Cliente";
}
}
getStatusIcon(status) {
switch (status) {
case "sent": return "✓";
case "delivered": return "✓✓";
case "read": return "✓✓";
default: return "⏳";
}
}
goBack() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "whatsapp.conversation",
res_id: this.conversationId,
views: [[false, "form"]],
target: "current",
});
}
}
registry.category("actions").add("whatsapp_chat", WhatsAppChat);

View File

@@ -0,0 +1,120 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
export class WhatsAppChatWidget extends Component {
static template = "odoo_whatsapp_hub.ChatWidget";
static props = {
conversationId: { type: Number, optional: true },
partnerId: { type: Number, optional: true },
};
setup() {
this.orm = useService("orm");
this.state = useState({
conversation: null,
messages: [],
newMessage: "",
loading: true,
});
onWillStart(async () => {
await this.loadConversation();
});
onMounted(() => {
this.scrollToBottom();
});
}
async loadConversation() {
this.state.loading = true;
try {
if (this.props.conversationId) {
const conversations = await this.orm.searchRead(
"whatsapp.conversation",
[["id", "=", this.props.conversationId]],
["id", "display_name", "phone_number", "status"]
);
if (conversations.length) {
this.state.conversation = conversations[0];
await this.loadMessages();
}
}
} finally {
this.state.loading = false;
}
}
async loadMessages() {
if (!this.state.conversation) return;
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", this.state.conversation.id]],
["id", "direction", "content", "message_type", "media_url", "status", "create_date"],
{ order: "create_date asc" }
);
this.state.messages = messages;
const unreadIds = messages
.filter(m => m.direction === "inbound" && !m.is_read)
.map(m => m.id);
if (unreadIds.length) {
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
}
this.scrollToBottom();
}
async sendMessage() {
if (!this.state.newMessage.trim() || !this.state.conversation) return;
const content = this.state.newMessage;
this.state.newMessage = "";
await this.orm.call(
"whatsapp.message",
"send_message",
[this.state.conversation.id, content]
);
await this.loadMessages();
}
scrollToBottom() {
const container = document.querySelector(".o_whatsapp_messages");
if (container) {
container.scrollTop = container.scrollHeight;
}
}
formatTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
getStatusIcon(status) {
const icons = {
pending: "fa-clock-o",
sent: "fa-check",
delivered: "fa-check-double text-muted",
read: "fa-check-double text-primary",
failed: "fa-exclamation-circle text-danger",
};
return icons[status] || "";
}
onKeyPress(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
}
registry.category("public_components").add("WhatsAppChatWidget", WhatsAppChatWidget);

View File

@@ -0,0 +1,254 @@
/** @odoo-module **/
import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
export class DollarsChat extends Component {
static template = "odoo_whatsapp_hub.DollarsChat";
static props = {
action: { type: Object, optional: true },
actionId: { type: [Number, Boolean], optional: true },
};
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
conversations: [],
messages: [],
selectedConversation: null,
newMessage: "",
loading: true,
sending: false,
searchQuery: "",
});
this.messagesEndRef = useRef("messagesEnd");
this.inputRef = useRef("messageInput");
onWillStart(async () => {
await this.loadConversations();
});
onMounted(() => {
// Auto-refresh every 10 seconds
this.refreshInterval = setInterval(() => {
if (this.state.selectedConversation) {
this.loadMessages(this.state.selectedConversation.id, true);
}
this.loadConversations(true);
}, 10000);
});
}
willUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
get filteredConversations() {
if (!this.state.searchQuery) {
return this.state.conversations;
}
const query = this.state.searchQuery.toLowerCase();
return this.state.conversations.filter(conv =>
conv.display_name?.toLowerCase().includes(query) ||
conv.phone_number?.toLowerCase().includes(query)
);
}
async loadConversations(silent = false) {
if (!silent) {
this.state.loading = true;
}
try {
const conversations = await this.orm.searchRead(
"whatsapp.conversation",
[],
["display_name", "phone_number", "status", "last_message_at", "last_message_preview", "unread_count", "partner_id"],
{ order: "last_message_at desc", limit: 50 }
);
this.state.conversations = conversations;
} catch (error) {
console.error("Error loading conversations:", error);
}
if (!silent) {
this.state.loading = false;
}
}
async selectConversation(conv) {
this.state.selectedConversation = conv;
await this.loadMessages(conv.id);
}
async loadMessages(conversationId, silent = false) {
try {
const messages = await this.orm.searchRead(
"whatsapp.message",
[["conversation_id", "=", conversationId]],
["content", "direction", "create_date", "message_type", "media_url", "status", "sent_by_id", "is_read"],
{ order: "create_date asc" }
);
this.state.messages = messages;
// Mark as read
const unreadIds = messages
.filter(m => m.direction === "inbound" && !m.is_read)
.map(m => m.id);
if (unreadIds.length > 0) {
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
// Refresh conversation to update unread count
await this.loadConversations(true);
}
setTimeout(() => this.scrollToBottom(), 100);
} catch (error) {
console.error("Error loading messages:", error);
}
}
scrollToBottom() {
if (this.messagesEndRef.el) {
this.messagesEndRef.el.scrollIntoView({ behavior: "smooth" });
}
}
onSearchInput(ev) {
this.state.searchQuery = ev.target.value;
}
onMessageInput(ev) {
this.state.newMessage = ev.target.value;
// Auto-resize textarea
ev.target.style.height = 'auto';
ev.target.style.height = Math.min(ev.target.scrollHeight, 120) + 'px';
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
async sendMessage() {
const message = this.state.newMessage.trim();
if (!message || this.state.sending || !this.state.selectedConversation) return;
this.state.sending = true;
try {
await this.orm.call(
"whatsapp.conversation",
"send_message_from_chat",
[[this.state.selectedConversation.id], message]
);
this.state.newMessage = "";
if (this.inputRef.el) {
this.inputRef.el.style.height = 'auto';
}
await this.loadMessages(this.state.selectedConversation.id);
await this.loadConversations(true);
} catch (error) {
console.error("Error sending message:", error);
this.notification.add(_t("Error al enviar: ") + (error.message || error), { type: "danger" });
}
this.state.sending = false;
if (this.inputRef.el) {
this.inputRef.el.focus();
}
}
async refreshMessages() {
if (this.state.selectedConversation) {
await this.loadMessages(this.state.selectedConversation.id);
}
}
formatTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return "Ayer";
}
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
}
formatMessageTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
getInitial(name) {
if (!name) return "?";
return name.charAt(0).toUpperCase();
}
getStatusIcon(status) {
switch (status) {
case "sent": return "✓";
case "delivered": return "✓✓";
case "read": return "✓✓";
default: return "⏳";
}
}
getStatusClass(convStatus) {
const statusMap = {
'bot': 'Bot',
'waiting': 'En espera',
'active': 'Activa',
'resolved': 'Resuelta'
};
return statusMap[convStatus] || convStatus;
}
async markAsResolved() {
if (!this.state.selectedConversation) return;
try {
await this.orm.call(
"whatsapp.conversation",
"action_mark_resolved",
[[this.state.selectedConversation.id]]
);
await this.loadConversations(true);
this.state.selectedConversation.status = 'resolved';
this.notification.add(_t("Conversación marcada como resuelta"), { type: "success" });
} catch (error) {
console.error("Error:", error);
}
}
openInOdoo() {
if (!this.state.selectedConversation) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "whatsapp.conversation",
res_id: this.state.selectedConversation.id,
views: [[false, "form"]],
target: "current",
});
}
}
// Register as the main WhatsApp action
registry.category("actions").add("dollars_whatsapp_chat", DollarsChat);

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odoo_whatsapp_hub.WhatsAppChat">
<div t-attf-class="o_whatsapp_chat_fullscreen {{ themeClass }}">
<!-- Header -->
<div class="o_whatsapp_chat_header">
<button class="btn btn-link" style="color: inherit;" t-on-click="goBack">
<i class="fa fa-arrow-left fa-lg"/>
</button>
<div class="avatar">
<t t-if="state.conversation">
<t t-esc="(state.conversation.display_name || 'W')[0].toUpperCase()"/>
</t>
</div>
<div class="contact-info">
<div class="contact-name">
<t t-esc="state.conversation?.display_name || 'Cargando...'"/>
</div>
<div class="contact-status">
<t t-esc="state.conversation?.phone_number || ''"/>
</div>
</div>
<button class="theme-toggle" t-on-click="toggleTheme">
<i t-attf-class="fa {{ state.currentTheme === 'whatsapp' ? 'fa-terminal' : 'fa-whatsapp' }} me-1"/>
<t t-esc="nextThemeName"/>
</button>
<button class="btn btn-link" style="color: inherit;" t-on-click="refreshMessages">
<i class="fa fa-refresh fa-lg"/>
</button>
</div>
<!-- Messages Container -->
<div class="o_whatsapp_messages_container">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">
<t t-if="state.currentTheme === 'drrr'">
[SYSTEM] Conectando al servidor...
</t>
<t t-else="">
Cargando mensajes...
</t>
</p>
</div>
</t>
<t t-else="">
<t t-if="state.messages.length === 0">
<div class="text-center py-5" style="opacity: 0.6;">
<t t-if="state.currentTheme === 'drrr'">
<p>[SYSTEM] No hay mensajes en este chat</p>
<p>[SYSTEM] Escribe algo para comenzar...</p>
</t>
<t t-else="">
<i class="fa fa-comments fa-3x mb-3"/>
<p>No hay mensajes aún</p>
</t>
</div>
</t>
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_whatsapp_message {{ getMessageClass(message) }}">
<!-- Sender name (visible in DRRR theme) -->
<span class="message-sender">
<t t-esc="getSenderName(message)"/>
</span>
<!-- Media content -->
<t t-if="message.media_url">
<t t-if="message.message_type === 'image'">
<img t-att-src="message.media_url" class="o_whatsapp_media_image"
t-on-click="() => window.open(message.media_url, '_blank')"/>
</t>
<t t-elif="message.message_type === 'audio'">
<audio controls="" class="o_whatsapp_media_audio">
<source t-att-src="message.media_url"/>
</audio>
</t>
<t t-elif="message.message_type === 'video'">
<video controls="" class="o_whatsapp_media_video">
<source t-att-src="message.media_url"/>
</video>
</t>
<t t-else="">
<a t-att-href="message.media_url" target="_blank" class="o_whatsapp_media_doc">
<i class="fa fa-file me-2"/>
Documento
</a>
</t>
</t>
<!-- Text content -->
<t t-if="message.content">
<span class="message-content">
<t t-esc="message.content"/>
</span>
</t>
<!-- Meta info -->
<span class="message-meta">
<span class="message-time">
<t t-esc="formatTime(message.create_date)"/>
</span>
<t t-if="message.direction === 'outbound'">
<span t-attf-class="message-status {{ message.status === 'read' ? 'text-info' : '' }}">
<t t-esc="getStatusIcon(message.status)"/>
</span>
</t>
</span>
</div>
</t>
<div t-ref="messagesEnd"/>
</t>
</div>
<!-- Input Area -->
<div class="o_whatsapp_input_area">
<input type="text"
t-ref="messageInput"
class="form-control"
t-att-placeholder="state.currentTheme === 'drrr' ? '> Escribe tu mensaje...' : 'Escribe un mensaje...'"
t-att-value="state.newMessage"
t-on-input="onInputChange"
t-on-keydown="onKeyDown"
t-att-disabled="state.sending"/>
<button class="btn btn-success rounded-circle"
t-on-click="sendMessage"
t-att-disabled="state.sending || !state.newMessage.trim()">
<t t-if="state.sending">
<i class="fa fa-spinner fa-spin"/>
</t>
<t t-else="">
<i class="fa fa-paper-plane"/>
</t>
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odoo_whatsapp_hub.ChatWidget">
<div class="o_whatsapp_chat_container">
<t t-if="state.loading">
<div class="d-flex justify-content-center align-items-center h-100">
<i class="fa fa-spinner fa-spin fa-2x"/>
</div>
</t>
<t t-elif="state.conversation">
<!-- Header -->
<div class="o_whatsapp_chat_header">
<div class="avatar">
<t t-esc="state.conversation.display_name[0]"/>
</div>
<div class="contact-info">
<div class="contact-name" t-esc="state.conversation.display_name"/>
<div class="contact-status" t-esc="state.conversation.phone_number"/>
</div>
</div>
<!-- Messages -->
<div class="o_whatsapp_messages">
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_whatsapp_message #{message.direction}">
<div class="message-content">
<!-- Image message -->
<t t-if="message.message_type === 'image' and message.media_url">
<img t-att-src="message.media_url" class="o_whatsapp_media_image" t-att-alt="message.content"/>
<t t-if="message.content and message.content !== '[Image]'">
<div class="mt-1" t-esc="message.content"/>
</t>
</t>
<!-- Audio message -->
<t t-elif="message.message_type === 'audio' and message.media_url">
<audio controls="controls" class="o_whatsapp_media_audio">
<source t-att-src="message.media_url" type="audio/ogg"/>
</audio>
</t>
<!-- Video message -->
<t t-elif="message.message_type === 'video' and message.media_url">
<video controls="controls" class="o_whatsapp_media_video">
<source t-att-src="message.media_url" type="video/mp4"/>
</video>
</t>
<!-- Document message -->
<t t-elif="message.message_type === 'document' and message.media_url">
<a t-att-href="message.media_url" target="_blank" class="o_whatsapp_media_doc">
<i class="fa fa-file-o me-1"/> <t t-esc="message.content or 'Documento'"/>
</a>
</t>
<!-- Text message -->
<t t-else="">
<t t-esc="message.content"/>
</t>
</div>
<div class="message-meta">
<span t-esc="formatTime(message.create_date)"/>
<t t-if="message.direction === 'outbound'">
<i t-attf-class="fa #{getStatusIcon(message.status)} message-status"/>
</t>
</div>
</div>
</t>
</div>
<!-- Input -->
<div class="o_whatsapp_input_area">
<input
type="text"
placeholder="Escribe un mensaje..."
t-model="state.newMessage"
t-on-keypress="onKeyPress"
/>
<button t-on-click="sendMessage">
<i class="fa fa-paper-plane"/>
</button>
</div>
</t>
<t t-else="">
<div class="d-flex justify-content-center align-items-center h-100 text-muted">
<span>Selecciona una conversación</span>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,282 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="odoo_whatsapp_hub.DollarsChat">
<div class="o_dollars_chat">
<!-- Header -->
<div class="o_dollars_header">
<div class="o_dollars_logo">
<div class="o_dollars_logo_icon">
<i class="fa fa-whatsapp"/>
</div>
<span class="o_dollars_logo_text">WHATSAPP HUB</span>
</div>
<div class="o_dollars_status">
<div class="o_dollars_status_item">
<span class="o_dollars_status_dot"/>
<span>Conectado</span>
</div>
<div class="o_dollars_status_item">
<i class="fa fa-lock"/>
<span>Cifrado E2E</span>
</div>
<div class="o_dollars_status_item">
<i class="fa fa-comments"/>
<span><t t-esc="state.conversations.length"/> chats</span>
</div>
</div>
<div class="o_dollars_header_actions">
<button class="o_dollars_header_btn" t-on-click="() => this.loadConversations()">
<i class="fa fa-refresh"/>
<span>Actualizar</span>
</button>
</div>
</div>
<!-- Main 3-column layout -->
<div class="o_dollars_main">
<!-- Left Sidebar - Conversations -->
<div class="o_dollars_sidebar">
<div class="o_dollars_sidebar_header">
<div class="o_dollars_search">
<i class="fa fa-search o_dollars_search_icon"/>
<input type="text"
placeholder="Buscar conversación..."
t-att-value="state.searchQuery"
t-on-input="onSearchInput"/>
</div>
</div>
<div class="o_dollars_conversations">
<t t-if="state.loading">
<div class="o_dollars_loading">
<div class="o_dollars_spinner"/>
</div>
</t>
<t t-else="">
<t t-foreach="filteredConversations" t-as="conv" t-key="conv.id">
<div t-attf-class="o_dollars_conv_item {{ state.selectedConversation?.id === conv.id ? 'active' : '' }}"
t-on-click="() => this.selectConversation(conv)">
<div t-attf-class="o_dollars_conv_avatar {{ conv.status === 'active' ? 'online' : '' }}">
<t t-esc="getInitial(conv.display_name)"/>
</div>
<div class="o_dollars_conv_info">
<div class="o_dollars_conv_name">
<t t-esc="conv.display_name || conv.phone_number"/>
</div>
<div class="o_dollars_conv_preview">
<t t-esc="conv.last_message_preview || 'Sin mensajes'"/>
</div>
</div>
<div class="o_dollars_conv_meta">
<span class="o_dollars_conv_time">
<t t-esc="formatTime(conv.last_message_at)"/>
</span>
<t t-if="conv.unread_count > 0">
<span class="o_dollars_conv_badge">
<t t-esc="conv.unread_count"/>
</span>
</t>
</div>
</div>
</t>
<t t-if="filteredConversations.length === 0">
<div class="o_dollars_empty" style="padding: 40px 20px;">
<div class="o_dollars_empty_icon">
<i class="fa fa-inbox"/>
</div>
<div class="o_dollars_empty_text">No hay conversaciones</div>
</div>
</t>
</t>
</div>
</div>
<!-- Center - Chat Area -->
<div class="o_dollars_chat_area">
<t t-if="state.selectedConversation">
<!-- Chat Header -->
<div class="o_dollars_chat_header">
<div class="o_dollars_chat_avatar">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</div>
<div class="o_dollars_chat_info">
<div class="o_dollars_chat_name">
<t t-esc="state.selectedConversation.display_name || state.selectedConversation.phone_number"/>
</div>
<div class="o_dollars_chat_status">
<span class="o_dollars_status_dot" style="width: 6px; height: 6px;"/>
<span><t t-esc="getStatusClass(state.selectedConversation.status)"/></span>
<span style="margin-left: 8px; opacity: 0.7;">
<t t-esc="state.selectedConversation.phone_number"/>
</span>
</div>
</div>
<div class="o_dollars_chat_actions">
<button class="o_dollars_chat_btn" t-on-click="refreshMessages" title="Actualizar">
<i class="fa fa-refresh"/>
</button>
<button class="o_dollars_chat_btn" t-on-click="markAsResolved" title="Marcar como resuelta">
<i class="fa fa-check"/>
</button>
<button class="o_dollars_chat_btn" t-on-click="openInOdoo" title="Abrir en Odoo">
<i class="fa fa-external-link"/>
</button>
</div>
</div>
<!-- Messages -->
<div class="o_dollars_messages">
<t t-foreach="state.messages" t-as="message" t-key="message.id">
<div t-attf-class="o_dollars_message {{ message.direction }}">
<div class="o_dollars_msg_avatar">
<t t-if="message.direction === 'outbound'">
<i class="fa fa-user"/>
</t>
<t t-else="">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</t>
</div>
<div class="o_dollars_msg_content">
<div class="o_dollars_msg_header">
<span class="o_dollars_msg_sender">
<t t-if="message.direction === 'outbound'">
<t t-esc="message.sent_by_id?.[1] || 'Tú'"/>
</t>
<t t-else="">
<t t-esc="state.selectedConversation.display_name || 'Cliente'"/>
</t>
</span>
<span class="o_dollars_msg_time">
<t t-esc="formatMessageTime(message.create_date)"/>
</span>
</div>
<div class="o_dollars_msg_bubble">
<!-- Media -->
<t t-if="message.media_url">
<t t-if="message.message_type === 'image'">
<img t-att-src="message.media_url"
class="o_dollars_msg_image"
t-on-click="() => window.open(message.media_url, '_blank')"/>
</t>
<t t-elif="message.message_type === 'audio'">
<audio controls="" class="o_dollars_msg_audio">
<source t-att-src="message.media_url"/>
</audio>
</t>
<t t-elif="message.message_type === 'video'">
<video controls="" class="o_dollars_msg_video">
<source t-att-src="message.media_url"/>
</video>
</t>
<t t-else="">
<a t-att-href="message.media_url" target="_blank" class="o_dollars_msg_doc">
<i class="fa fa-file"/>
<span>Documento</span>
</a>
</t>
</t>
<!-- Text -->
<t t-if="message.content">
<t t-esc="message.content"/>
</t>
</div>
<t t-if="message.direction === 'outbound'">
<div t-attf-class="o_dollars_msg_status {{ message.status === 'read' ? 'read' : '' }}">
<t t-esc="getStatusIcon(message.status)"/>
</div>
</t>
</div>
</div>
</t>
<div t-ref="messagesEnd"/>
</div>
<!-- Input Area -->
<div class="o_dollars_input_area">
<div class="o_dollars_input_wrapper">
<textarea t-ref="messageInput"
rows="1"
placeholder="Escribe un mensaje..."
t-att-value="state.newMessage"
t-on-input="onMessageInput"
t-on-keydown="onKeyDown"
t-att-disabled="state.sending"/>
</div>
<button class="o_dollars_send_btn"
t-on-click="sendMessage"
t-att-disabled="state.sending || !state.newMessage.trim()">
<t t-if="state.sending">
<i class="fa fa-spinner fa-spin"/>
</t>
<t t-else="">
<i class="fa fa-paper-plane"/>
</t>
</button>
</div>
</t>
<t t-else="">
<!-- Empty state -->
<div class="o_dollars_empty">
<div class="o_dollars_empty_icon">
<i class="fa fa-comments"/>
</div>
<div class="o_dollars_empty_title">WhatsApp Hub</div>
<div class="o_dollars_empty_text">
Selecciona una conversación para comenzar
</div>
</div>
</t>
</div>
<!-- Right Panel - Contact Details -->
<t t-if="state.selectedConversation">
<div class="o_dollars_details">
<div class="o_dollars_details_header">
<div class="o_dollars_details_avatar">
<t t-esc="getInitial(state.selectedConversation.display_name)"/>
</div>
<div class="o_dollars_details_name">
<t t-esc="state.selectedConversation.display_name || 'Sin nombre'"/>
</div>
<div class="o_dollars_details_phone">
<t t-esc="state.selectedConversation.phone_number"/>
</div>
<div class="o_dollars_details_status">
<span class="o_dollars_status_dot"/>
<span><t t-esc="getStatusClass(state.selectedConversation.status)"/></span>
</div>
</div>
<div class="o_dollars_details_content">
<div class="o_dollars_details_section">
<div class="o_dollars_details_section_title">Acciones</div>
<div class="o_dollars_details_item" t-on-click="markAsResolved">
<i class="fa fa-check-circle"/>
<span>Marcar como resuelta</span>
</div>
<div class="o_dollars_details_item" t-on-click="openInOdoo">
<i class="fa fa-external-link"/>
<span>Ver en Odoo</span>
</div>
</div>
<div class="o_dollars_details_section">
<div class="o_dollars_details_section_title">Información</div>
<div class="o_dollars_details_item">
<i class="fa fa-phone"/>
<span><t t-esc="state.selectedConversation.phone_number"/></span>
</div>
<div class="o_dollars_details_item">
<i class="fa fa-comment"/>
<span><t t-esc="state.messages.length"/> mensajes</span>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Client Action for Dollars Chat Interface -->
<record id="action_dollars_whatsapp_chat" model="ir.actions.client">
<field name="name">WhatsApp Chat</field>
<field name="tag">dollars_whatsapp_chat</field>
<field name="target">fullscreen</field>
</record>
</odoo>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Inherit Partner Form -->
<record id="view_partner_form_whatsapp" model="ir.ui.view">
<field name="name">res.partner.form.whatsapp</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<!-- Add WhatsApp button box -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_whatsapp_conversations" type="object" class="oe_stat_button" icon="fa-whatsapp">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="whatsapp_conversation_count"/>
</span>
<span class="o_stat_text">WhatsApp</span>
</div>
</button>
</xpath>
<!-- Add WhatsApp notebook page -->
<xpath expr="//notebook" position="inside">
<page string="WhatsApp" name="whatsapp">
<field name="whatsapp_conversation_ids" mode="list" readonly="1">
<list>
<field name="account_id"/>
<field name="status" widget="badge"/>
<field name="last_message_at"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
<!-- Server Action: Send WhatsApp -->
<record id="action_partner_send_whatsapp" model="ir.actions.server">
<field name="name">Enviar WhatsApp</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">
if records:
if len(records) == 1:
action = records.action_send_whatsapp()
else:
action = {
'type': 'ir.actions.act_window',
'name': 'Envío Masivo WhatsApp',
'res_model': 'whatsapp.mass.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_ids': records.ids},
}
</field>
</record>
</odoo>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Tree View -->
<record id="view_whatsapp_account_tree" model="ir.ui.view">
<field name="name">whatsapp.account.tree</field>
<field name="model">whatsapp.account</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="phone_number"/>
<field name="status" widget="badge" decoration-success="status == 'connected'" decoration-warning="status == 'connecting'" decoration-danger="status == 'disconnected'"/>
<field name="is_default"/>
<field name="conversation_count"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_whatsapp_account_form" model="ir.ui.view">
<field name="name">whatsapp.account.form</field>
<field name="model">whatsapp.account</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_sync_status" string="Actualizar Estado" type="object" class="btn-primary"/>
<field name="status" widget="statusbar" statusbar_visible="disconnected,connecting,connected"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_conversations" type="object" class="oe_stat_button" icon="fa-comments">
<field name="conversation_count" widget="statinfo" string="Conversaciones"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="phone_number"/>
<field name="external_id"/>
<field name="is_default"/>
</group>
<group>
<field name="api_url"/>
<field name="api_key" password="True"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Código QR" name="qr_code" invisible="status != 'connecting'">
<field name="qr_code" widget="image" class="oe_avatar"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_whatsapp_account" model="ir.actions.act_window">
<field name="name">Cuentas WhatsApp</field>
<field name="res_model">whatsapp.account</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Tree View -->
<record id="view_whatsapp_conversation_tree" model="ir.ui.view">
<field name="name">whatsapp.conversation.tree</field>
<field name="model">whatsapp.conversation</field>
<field name="arch" type="xml">
<list>
<field name="display_name"/>
<field name="phone_number"/>
<field name="status"/>
<field name="last_message_at"/>
</list>
</field>
</record>
<!-- Form View - Simplified -->
<record id="view_whatsapp_conversation_form" model="ir.ui.view">
<field name="name">whatsapp.conversation.form</field>
<field name="model">whatsapp.conversation</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_open_chat" string="ABRIR CHAT" type="object" class="btn-primary"/>
<button name="action_mark_resolved" string="Resolver" type="object"/>
<field name="status" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="display_name"/>
<field name="phone_number"/>
</group>
<group>
<field name="account_id"/>
<field name="last_message_at"/>
</group>
</group>
<field name="message_ids">
<list>
<field name="create_date"/>
<field name="direction"/>
<field name="content"/>
<field name="media_url" widget="url"/>
<field name="status"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_whatsapp_conversation_search" model="ir.ui.view">
<field name="name">whatsapp.conversation.search</field>
<field name="model">whatsapp.conversation</field>
<field name="arch" type="xml">
<search>
<field name="phone_number"/>
</search>
</field>
</record>
<!-- Action -->
<record id="action_whatsapp_conversation" model="ir.actions.act_window">
<field name="name">Conversaciones</field>
<field name="res_model">whatsapp.conversation</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Main Menu -->
<menuitem
id="menu_whatsapp_root"
name="WhatsApp"
web_icon="odoo_whatsapp_hub,static/description/icon.png"
sequence="50"
/>
<!-- Chat Hub (Dollars Interface) -->
<menuitem
id="menu_whatsapp_chat_hub"
name="Chat Hub"
parent="menu_whatsapp_root"
action="action_dollars_whatsapp_chat"
sequence="5"
/>
<!-- Conversations Menu (classic view) -->
<menuitem
id="menu_whatsapp_conversations"
name="Conversaciones (Lista)"
parent="menu_whatsapp_root"
action="action_whatsapp_conversation"
sequence="10"
/>
<!-- Accounts Menu -->
<menuitem
id="menu_whatsapp_accounts"
name="Cuentas"
parent="menu_whatsapp_root"
action="action_whatsapp_account"
sequence="20"
/>
<!-- Configuration Menu -->
<menuitem
id="menu_whatsapp_config"
name="Configuración"
parent="menu_whatsapp_root"
sequence="100"
/>
<menuitem
id="menu_whatsapp_accounts_config"
name="Cuentas WhatsApp"
parent="menu_whatsapp_config"
action="action_whatsapp_account"
sequence="10"
/>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import send_whatsapp
from . import mass_whatsapp

View File

@@ -0,0 +1,95 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class WhatsAppMassWizard(models.TransientModel):
_name = 'whatsapp.mass.wizard'
_description = 'Send Mass WhatsApp Message'
account_id = fields.Many2one(
'whatsapp.account',
string='Cuenta WhatsApp',
required=True,
default=lambda self: self.env['whatsapp.account'].get_default_account(),
)
partner_ids = fields.Many2many(
'res.partner',
string='Contactos',
required=True,
)
content = fields.Text(string='Mensaje', required=True)
use_template = fields.Boolean(string='Usar Variables')
total_count = fields.Integer(
string='Total Contactos',
compute='_compute_stats',
)
valid_count = fields.Integer(
string='Con Teléfono',
compute='_compute_stats',
)
@api.depends('partner_ids')
def _compute_stats(self):
for wizard in self:
wizard.total_count = len(wizard.partner_ids)
wizard.valid_count = len(wizard.partner_ids.filtered(
lambda p: p.mobile or p.phone
))
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_ids = self.env.context.get('active_ids', [])
active_model = self.env.context.get('active_model')
if active_model == 'res.partner' and active_ids:
res['partner_ids'] = [(6, 0, active_ids)]
return res
def action_send(self):
"""Send WhatsApp to all selected partners"""
self.ensure_one()
if not self.account_id:
raise UserError('Seleccione una cuenta de WhatsApp')
sent_count = 0
failed_count = 0
for partner in self.partner_ids:
phone = partner.mobile or partner.phone
if not phone:
failed_count += 1
continue
try:
content = self.content
if self.use_template:
content = content.replace('{{name}}', partner.name or '')
content = content.replace('{{email}}', partner.email or '')
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
phone=phone,
account_id=self.account_id.id,
contact_name=partner.name,
)
conversation.partner_id = partner
self.env['whatsapp.message'].send_message(
conversation_id=conversation.id,
content=content,
)
sent_count += 1
except Exception:
failed_count += 1
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': f'Enviados: {sent_count}, Fallidos: {failed_count}',
'type': 'success' if failed_count == 0 else 'warning',
}
}

View File

@@ -0,0 +1,78 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class WhatsAppSendWizard(models.TransientModel):
_name = 'whatsapp.send.wizard'
_description = 'Send WhatsApp Message'
partner_id = fields.Many2one('res.partner', string='Contacto')
phone = fields.Char(string='Teléfono', required=True)
account_id = fields.Many2one(
'whatsapp.account',
string='Cuenta WhatsApp',
required=True,
default=lambda self: self.env['whatsapp.account'].get_default_account(),
)
message_type = fields.Selection([
('text', 'Texto'),
('image', 'Imagen'),
('document', 'Documento'),
], string='Tipo', default='text', required=True)
content = fields.Text(string='Mensaje', required=True)
media_url = fields.Char(string='URL del Archivo')
attachment_id = fields.Many2one('ir.attachment', string='Adjunto')
res_model = fields.Char(string='Modelo Origen')
res_id = fields.Integer(string='ID Origen')
@api.onchange('attachment_id')
def _onchange_attachment_id(self):
if self.attachment_id:
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
self.media_url = f"{base_url}/web/content/{self.attachment_id.id}"
def action_send(self):
"""Send the WhatsApp message"""
self.ensure_one()
if not self.account_id:
raise UserError('Seleccione una cuenta de WhatsApp')
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
phone=self.phone,
account_id=self.account_id.id,
contact_name=self.partner_id.name if self.partner_id else None,
)
if self.partner_id:
conversation.partner_id = self.partner_id
self.env['whatsapp.message'].send_message(
conversation_id=conversation.id,
content=self.content,
message_type=self.message_type,
media_url=self.media_url,
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': 'Mensaje enviado correctamente',
'type': 'success',
}
}
def action_send_and_open(self):
"""Send message and open conversation"""
self.action_send()
conversation = self.env['whatsapp.conversation'].search([
('phone_number', '=', self.phone),
('account_id', '=', self.account_id.id),
], limit=1, order='id desc')
if conversation:
return conversation.action_open_chat()
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Send WhatsApp Wizard Form -->
<record id="view_whatsapp_send_wizard_form" model="ir.ui.view">
<field name="name">whatsapp.send.wizard.form</field>
<field name="model">whatsapp.send.wizard</field>
<field name="arch" type="xml">
<form string="Enviar WhatsApp">
<group>
<group>
<field name="partner_id" readonly="partner_id"/>
<field name="phone"/>
<field name="account_id"/>
</group>
<group>
<field name="message_type"/>
<field name="attachment_id" invisible="message_type == 'text'"/>
<field name="media_url" invisible="message_type == 'text'"/>
</group>
</group>
<group>
<field name="content" placeholder="Escribe tu mensaje aquí..."/>
</group>
<footer>
<button name="action_send" string="Enviar" type="object" class="btn-primary"/>
<button name="action_send_and_open" string="Enviar y Abrir Chat" type="object" class="btn-secondary"/>
<button string="Cancelar" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Mass WhatsApp Wizard Form -->
<record id="view_whatsapp_mass_wizard_form" model="ir.ui.view">
<field name="name">whatsapp.mass.wizard.form</field>
<field name="model">whatsapp.mass.wizard</field>
<field name="arch" type="xml">
<form string="Envío Masivo WhatsApp">
<group>
<group>
<field name="account_id"/>
<field name="use_template"/>
</group>
<group>
<field name="total_count" readonly="1"/>
<field name="valid_count" readonly="1"/>
</group>
</group>
<group string="Contactos">
<field name="partner_ids" widget="many2many_tags"/>
</group>
<group string="Mensaje">
<field name="content" placeholder="Escribe tu mensaje aquí...&#10;&#10;Variables disponibles: {{name}}, {{email}}"/>
</group>
<div class="alert alert-info" role="alert" invisible="not use_template">
<strong>Variables disponibles:</strong>
<ul>
<li><code>{{name}}</code> - Nombre del contacto</li>
<li><code>{{email}}</code> - Email del contacto</li>
</ul>
</div>
<footer>
<button name="action_send" string="Enviar a Todos" type="object" class="btn-primary" confirm="¿Está seguro de enviar el mensaje a todos los contactos seleccionados?"/>
<button string="Cancelar" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for Mass Wizard -->
<record id="action_whatsapp_mass_wizard" model="ir.actions.act_window">
<field name="name">Envío Masivo WhatsApp</field>
<field name="res_model">whatsapp.mass.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

BIN
odoo_whatsapp_hub_v2.zip Normal file

Binary file not shown.

220
qr-realtime.html Normal file
View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html>
<head>
<title>WhatsApp QR - Tiempo Real</title>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<style>
body {
font-family: Arial;
text-align: center;
padding: 20px;
background: linear-gradient(135deg, #075e54 0%, #128c7e 100%);
color: white;
min-height: 100vh;
margin: 0;
}
.container { max-width: 500px; margin: 0 auto; }
h1 { margin-bottom: 10px; }
#qr-container {
background: white;
padding: 20px;
border-radius: 15px;
margin: 20px 0;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
#qr-container img { max-width: 100%; border-radius: 10px; }
#status {
padding: 15px;
border-radius: 10px;
margin: 10px 0;
font-size: 18px;
font-weight: bold;
}
.connecting { background: #f39c12; }
.connected { background: #27ae60; }
.disconnected { background: #e74c3c; }
.waiting { background: #3498db; }
button {
padding: 15px 40px;
font-size: 18px;
cursor: pointer;
border: none;
border-radius: 10px;
background: #25D366;
color: white;
margin: 10px;
}
button:hover { background: #128C7E; }
.instructions {
background: rgba(255,255,255,0.1);
padding: 15px;
border-radius: 10px;
margin-top: 20px;
text-align: left;
}
.instructions ol { margin: 10px 0; padding-left: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>📱 Vincular WhatsApp</h1>
<p>Servidor: 192.168.10.221</p>
<div id="status" class="waiting">Esperando conexión...</div>
<div id="qr-container">
<p style="color: #666;">Click "Iniciar" para generar el código QR</p>
</div>
<button onclick="startSession()">🚀 Iniciar Sesión</button>
<button onclick="checkStatus()" style="background: #3498db;">🔄 Verificar Estado</button>
<div class="instructions">
<strong>Instrucciones:</strong>
<ol>
<li>Click en "Iniciar Sesión"</li>
<li>Espera a que aparezca el código QR</li>
<li>Abre WhatsApp en tu teléfono</li>
<li>Ve a Configuración → Dispositivos vinculados</li>
<li>Escanea el código QR</li>
</ol>
</div>
<div id="phone-info" style="margin-top: 20px;"></div>
</div>
<script>
const API = 'http://192.168.10.221:3001';
const SESSION_ID = 'whatsapp_' + Date.now();
let socket = null;
function updateStatus(status, className) {
const el = document.getElementById('status');
el.textContent = status;
el.className = className;
}
function showQR(qrDataUrl) {
document.getElementById('qr-container').innerHTML =
'<img src="' + qrDataUrl + '" alt="QR Code">';
updateStatus('📸 Escanea el código QR con WhatsApp', 'connecting');
}
function connectSocket() {
socket = io(API, { path: '/ws' });
socket.on('connect', () => {
console.log('Socket conectado');
socket.emit('subscribe', SESSION_ID);
});
socket.on('qr', (event) => {
console.log('QR recibido:', event);
if (event.accountId === SESSION_ID && event.data.qrCode) {
showQR(event.data.qrCode);
}
});
socket.on('connected', (event) => {
console.log('WhatsApp conectado:', event);
if (event.accountId === SESSION_ID) {
document.getElementById('qr-container').innerHTML =
'<div style="color: #27ae60; font-size: 48px;">✅</div>' +
'<h2 style="color: #333;">¡Conectado!</h2>';
updateStatus('✅ WhatsApp conectado exitosamente', 'connected');
document.getElementById('phone-info').innerHTML =
'<p>Teléfono: <strong>' + (event.data.phoneNumber || 'N/A') + '</strong></p>';
}
});
socket.on('disconnected', (event) => {
console.log('Desconectado:', event);
if (event.accountId === SESSION_ID) {
updateStatus('❌ Desconectado: ' + (event.data.reason || ''), 'disconnected');
}
});
}
async function startSession() {
updateStatus('🔄 Creando sesión...', 'connecting');
document.getElementById('qr-container').innerHTML =
'<p style="color: #666;">Generando código QR...</p>';
// Conectar socket primero
if (!socket || !socket.connected) {
connectSocket();
}
try {
const res = await fetch(API + '/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: SESSION_ID,
name: 'WhatsApp ' + new Date().toLocaleTimeString()
})
});
const data = await res.json();
console.log('Sesión creada:', data);
updateStatus('⏳ Esperando código QR...', 'waiting');
// Verificar estado cada 2 segundos
let attempts = 0;
const checkInterval = setInterval(async () => {
attempts++;
try {
const statusRes = await fetch(API + '/api/sessions/' + SESSION_ID);
const statusData = await statusRes.json();
console.log('Estado:', statusData);
if (statusData.qrCode) {
showQR(statusData.qrCode);
} else if (statusData.status === 'connected') {
clearInterval(checkInterval);
document.getElementById('qr-container').innerHTML =
'<div style="color: #27ae60; font-size: 48px;">✅</div>' +
'<h2 style="color: #333;">¡Conectado!</h2>';
updateStatus('✅ WhatsApp conectado', 'connected');
}
if (attempts > 30) { // 60 segundos
clearInterval(checkInterval);
updateStatus('⏱️ Tiempo agotado. Intenta de nuevo.', 'disconnected');
}
} catch (e) {
console.error('Error verificando:', e);
}
}, 2000);
} catch (e) {
console.error('Error:', e);
updateStatus('❌ Error: ' + e.message, 'disconnected');
}
}
async function checkStatus() {
try {
const res = await fetch(API + '/api/sessions');
const sessions = await res.json();
console.log('Sesiones:', sessions);
let info = 'Sesiones activas: ' + sessions.length;
sessions.forEach(s => {
info += '\\n- ' + s.name + ': ' + s.status;
if (s.phoneNumber) info += ' (' + s.phoneNumber + ')';
});
alert(info);
} catch (e) {
alert('Error: ' + e.message);
}
}
// Auto-conectar socket al cargar
connectSocket();
</script>
</body>
</html>

83
qr-viewer.html Normal file
View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<title>WhatsApp QR Scanner</title>
<style>
body { font-family: Arial; text-align: center; padding: 20px; background: #1a1a2e; color: white; }
#qr { margin: 20px auto; max-width: 300px; }
#qr img { width: 100%; border-radius: 10px; }
#status { padding: 10px; margin: 10px; border-radius: 5px; }
.connecting { background: #f39c12; }
.connected { background: #27ae60; }
.disconnected { background: #e74c3c; }
button { padding: 15px 30px; font-size: 18px; cursor: pointer; margin: 10px; border: none; border-radius: 5px; }
#createBtn { background: #27ae60; color: white; }
#refreshBtn { background: #3498db; color: white; }
</style>
</head>
<body>
<h1>WhatsApp QR Scanner</h1>
<p>Servidor: <strong>192.168.10.221:3001</strong></p>
<button id="createBtn" onclick="createSession()">Crear Sesión</button>
<button id="refreshBtn" onclick="checkStatus()">Actualizar Estado</button>
<div id="status">Esperando...</div>
<div id="qr"></div>
<div id="phone"></div>
<script>
const API = 'http://192.168.10.221:3001/api';
const SESSION_ID = 'odoo_whatsapp';
async function createSession() {
document.getElementById('status').className = 'connecting';
document.getElementById('status').textContent = 'Creando sesión...';
try {
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountId: SESSION_ID, name: 'Odoo WhatsApp' })
});
const data = await res.json();
console.log('Sesión creada:', data);
// Esperar y verificar QR
setTimeout(checkStatus, 2000);
setTimeout(checkStatus, 5000);
setTimeout(checkStatus, 10000);
setTimeout(checkStatus, 15000);
} catch (e) {
document.getElementById('status').textContent = 'Error: ' + e.message;
}
}
async function checkStatus() {
try {
const res = await fetch(`${API}/sessions/${SESSION_ID}`);
const data = await res.json();
document.getElementById('status').textContent = `Estado: ${data.status}`;
document.getElementById('status').className = data.status;
if (data.qrCode) {
document.getElementById('qr').innerHTML = `<img src="${data.qrCode}" alt="QR Code">`;
document.getElementById('qr').innerHTML += '<p>Escanea este código con WhatsApp</p>';
} else if (data.status === 'connected') {
document.getElementById('qr').innerHTML = '<h2>✅ Conectado!</h2>';
document.getElementById('phone').textContent = 'Teléfono: ' + (data.phoneNumber || 'N/A');
} else {
document.getElementById('qr').innerHTML = '<p>Esperando QR...</p>';
}
} catch (e) {
document.getElementById('status').textContent = 'Error al verificar: ' + e.message;
}
}
// Auto-refresh cada 3 segundos
setInterval(checkStatus, 3000);
checkStatus();
</script>
</body>
</html>

View File

@@ -17,6 +17,7 @@ class Settings(BaseSettings):
# WhatsApp Core
WHATSAPP_CORE_URL: str = "http://localhost:3001"
WHATSAPP_CORE_PUBLIC_URL: str = "http://localhost:3001" # URL accessible from browser
# Flow Engine
FLOW_ENGINE_URL: str = "http://localhost:8001"
@@ -24,6 +25,9 @@ class Settings(BaseSettings):
# Integrations
INTEGRATIONS_URL: str = "http://localhost:8002"
# Odoo Webhook
ODOO_WEBHOOK_URL: str = "" # e.g., "http://192.168.10.188:8069/whatsapp/webhook"
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"

View File

@@ -68,7 +68,7 @@ class Contact(Base):
name = Column(String(100), nullable=True)
email = Column(String(255), nullable=True)
company = Column(String(100), nullable=True)
metadata = Column(JSONB, default=dict)
extra_data = Column(JSONB, default=dict)
tags = Column(ARRAY(String), default=list)
odoo_partner_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
@@ -110,7 +110,7 @@ class Message(Base):
type = Column(SQLEnum(MessageType), default=MessageType.TEXT, nullable=False)
content = Column(Text, nullable=True)
media_url = Column(String(500), nullable=True)
metadata = Column(JSONB, default=dict)
extra_data = Column(JSONB, default=dict)
sent_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
is_internal_note = Column(Boolean, default=False, nullable=False)
status = Column(SQLEnum(MessageStatus), default=MessageStatus.PENDING, nullable=False)

View File

@@ -62,11 +62,29 @@ async def create_account(
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
def list_accounts(
async def list_accounts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
accounts = db.query(WhatsAppAccount).all()
# Sync status with WhatsApp Core for each account
async with httpx.AsyncClient() as client:
for account in accounts:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
timeout=5,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
except Exception:
pass
db.commit()
return accounts
@@ -122,6 +140,63 @@ async def delete_account(
return {"success": True}
@router.post("/accounts/{account_id}/pause")
async def pause_account(
account_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Pause WhatsApp connection without logging out (preserves session)"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/pause",
timeout=10,
)
if response.status_code == 200:
account.status = AccountStatus.DISCONNECTED
db.commit()
return {"success": True, "status": "paused"}
else:
raise HTTPException(status_code=500, detail="Failed to pause session")
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
@router.post("/accounts/{account_id}/resume")
async def resume_account(
account_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Resume paused WhatsApp connection"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/resume",
timeout=30,
)
if response.status_code == 200:
data = response.json()
session = data.get("session", {})
account.status = AccountStatus(session.get("status", "connecting"))
account.qr_code = session.get("qrCode")
db.commit()
return {"success": True, "status": account.status.value}
else:
raise HTTPException(status_code=500, detail="Failed to resume session")
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
@router.get("/conversations", response_model=List[ConversationResponse])
def list_conversations(
status: ConversationStatus = None,
@@ -228,6 +303,7 @@ async def handle_whatsapp_event(
elif event.type == "message":
msg_data = event.data
phone = msg_data.get("from", "").split("@")[0]
is_from_me = msg_data.get("fromMe", False)
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
if not contact:
@@ -256,19 +332,47 @@ async def handle_whatsapp_event(
db.refresh(conversation)
wa_message = msg_data.get("message", {})
media_url = msg_data.get("mediaUrl")
media_type = msg_data.get("mediaType", "text")
# Extract text content
content = (
wa_message.get("conversation") or
wa_message.get("extendedTextMessage", {}).get("text") or
"[Media]"
wa_message.get("imageMessage", {}).get("caption") or
wa_message.get("videoMessage", {}).get("caption") or
wa_message.get("documentMessage", {}).get("fileName") or
""
)
# Map media type to MessageType
type_mapping = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"audio": MessageType.AUDIO,
"video": MessageType.VIDEO,
"document": MessageType.DOCUMENT,
"sticker": MessageType.IMAGE,
}
msg_type = type_mapping.get(media_type, MessageType.TEXT)
# Build full media URL if present (use relative URL for browser access via nginx proxy)
full_media_url = None
if media_url:
# Use relative URL that nginx will proxy to whatsapp-core
full_media_url = media_url # e.g., "/media/uuid.jpg"
# Set direction based on fromMe flag
direction = MessageDirection.OUTBOUND if is_from_me else MessageDirection.INBOUND
message = Message(
conversation_id=conversation.id,
whatsapp_message_id=msg_data.get("id"),
direction=MessageDirection.INBOUND,
type=MessageType.TEXT,
content=content,
status=MessageStatus.DELIVERED,
direction=direction,
type=msg_type,
content=content if content else f"[{media_type.capitalize()}]",
media_url=full_media_url,
status=MessageStatus.DELIVERED if not is_from_me else MessageStatus.SENT,
)
db.add(message)
@@ -277,8 +381,8 @@ async def handle_whatsapp_event(
db.commit()
db.refresh(message)
# Process message through Flow Engine (if in BOT status)
if conversation.status == ConversationStatus.BOT:
# Process message through Flow Engine (only for inbound messages in BOT status)
if not is_from_me and conversation.status == ConversationStatus.BOT:
async with httpx.AsyncClient() as client:
try:
await client.post(
@@ -305,8 +409,53 @@ async def handle_whatsapp_event(
except Exception as e:
print(f"Flow engine error: {e}")
# Send webhook to Odoo if configured
if settings.ODOO_WEBHOOK_URL:
async with httpx.AsyncClient() as client:
try:
await client.post(
settings.ODOO_WEBHOOK_URL,
json={
"type": "message",
"account_id": str(account.id),
"data": {
"id": str(message.id),
"conversation_id": str(conversation.id),
"from": phone,
"contact_name": contact.name,
"content": content,
"type": media_type,
"direction": "outbound" if is_from_me else "inbound",
"media_url": full_media_url,
},
},
timeout=10,
)
except Exception as e:
print(f"Odoo webhook error: {e}")
return {"status": "ok"}
# Send account status to Odoo webhook
if settings.ODOO_WEBHOOK_URL and event.type in ["connected", "disconnected", "qr"]:
async with httpx.AsyncClient() as client:
try:
await client.post(
settings.ODOO_WEBHOOK_URL,
json={
"type": "account_status",
"account_id": str(account.id),
"data": {
"status": account.status.value if account.status else "disconnected",
"phone_number": account.phone_number,
"qr_code": account.qr_code,
},
},
timeout=10,
)
except Exception as e:
print(f"Odoo webhook error: {e}")
db.commit()
return {"status": "ok"}
@@ -448,3 +597,183 @@ def add_internal_note(
db.commit()
db.refresh(message)
return {"success": True, "message_id": str(message.id)}
# ============================================
# Odoo Internal Endpoints (no authentication)
# ============================================
class OdooSendMessageRequest(BaseModel):
phone_number: str
message: str
account_id: str
@router.get("/internal/odoo/accounts/{account_id}")
async def odoo_get_account(
account_id: UUID,
db: Session = Depends(get_db),
):
"""Get account status for Odoo (no auth required)"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Sync with WhatsApp Core
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}",
timeout=10,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
db.commit()
except Exception:
pass
return {
"id": str(account.id),
"phone_number": account.phone_number,
"name": account.name,
"status": account.status.value if account.status else "disconnected",
"qr_code": account.qr_code,
}
@router.get("/internal/odoo/accounts")
async def odoo_list_accounts(
db: Session = Depends(get_db),
):
"""List all accounts for Odoo (no auth required)"""
accounts = db.query(WhatsAppAccount).all()
async with httpx.AsyncClient() as client:
for account in accounts:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
timeout=5,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
except Exception:
pass
db.commit()
return [
{
"id": str(a.id),
"phone_number": a.phone_number,
"name": a.name,
"status": a.status.value if a.status else "disconnected",
}
for a in accounts
]
@router.post("/internal/odoo/send")
async def odoo_send_message(
request: OdooSendMessageRequest,
db: Session = Depends(get_db),
):
"""Send WhatsApp message from Odoo (no auth required)"""
account = db.query(WhatsAppAccount).filter(
WhatsAppAccount.id == request.account_id
).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Find or create contact
phone = request.phone_number.replace("+", "").replace(" ", "").replace("-", "")
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
if not contact:
contact = Contact(phone_number=phone)
db.add(contact)
db.commit()
db.refresh(contact)
# Find or create conversation
conversation = db.query(Conversation).filter(
Conversation.whatsapp_account_id == account.id,
Conversation.contact_id == contact.id,
Conversation.status != ConversationStatus.RESOLVED,
).first()
if not conversation:
conversation = Conversation(
whatsapp_account_id=account.id,
contact_id=contact.id,
status=ConversationStatus.BOT,
)
db.add(conversation)
db.commit()
db.refresh(conversation)
# Create message
message = Message(
conversation_id=conversation.id,
direction=MessageDirection.OUTBOUND,
type=MessageType.TEXT,
content=request.message,
status=MessageStatus.PENDING,
)
db.add(message)
db.commit()
db.refresh(message)
# Send via WhatsApp Core
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}/messages",
json={
"to": phone,
"type": "text",
"content": {"text": request.message},
},
timeout=30,
)
if response.status_code == 200:
data = response.json()
message.whatsapp_message_id = data.get("messageId")
message.status = MessageStatus.SENT
else:
message.status = MessageStatus.FAILED
except Exception as e:
message.status = MessageStatus.FAILED
raise HTTPException(status_code=500, detail=f"Failed to send: {e}")
db.commit()
return {"success": True, "message_id": str(message.id)}
@router.get("/internal/odoo/conversations")
def odoo_list_conversations(
account_id: str = None,
db: Session = Depends(get_db),
):
"""List conversations for Odoo (no auth required)"""
query = db.query(Conversation)
if account_id:
query = query.filter(Conversation.whatsapp_account_id == account_id)
conversations = query.order_by(Conversation.last_message_at.desc()).limit(100).all()
return [
{
"id": str(c.id),
"contact_phone": c.contact.phone_number if c.contact else None,
"contact_name": c.contact.name if c.contact else None,
"status": c.status.value if c.status else None,
"last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
}
for c in conversations
]

View File

@@ -4,9 +4,10 @@ sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic[email]==2.10.4
pydantic-settings==2.7.1
redis==5.2.1
httpx==0.28.1

View File

@@ -2,10 +2,10 @@ FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++
RUN apk add --no-cache python3 make g++ git
COPY package*.json ./
RUN npm ci
RUN npm install
COPY tsconfig.json ./
COPY src ./src

View File

@@ -10,7 +10,8 @@
"lint": "eslint src/**/*.ts"
},
"dependencies": {
"@whiskeysockets/baileys": "^6.7.16",
"@whiskeysockets/baileys": "^6.7.17",
"cors": "^2.8.5",
"express": "^4.21.2",
"socket.io": "^4.8.1",
"ioredis": "^5.4.1",
@@ -19,6 +20,7 @@
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",

View File

@@ -25,7 +25,8 @@ export function createRouter(sessionManager: SessionManager): Router {
// Get session info
router.get('/sessions/:accountId', (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.accountId);
const accountId = req.params.accountId as string;
const session = sessionManager.getSession(accountId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
@@ -41,17 +42,41 @@ export function createRouter(sessionManager: SessionManager): Router {
// Disconnect session
router.post('/sessions/:accountId/disconnect', async (req: Request, res: Response) => {
try {
await sessionManager.disconnectSession(req.params.accountId);
const accountId = req.params.accountId as string;
await sessionManager.disconnectSession(accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Pause session (disconnect without logout)
router.post('/sessions/:accountId/pause', async (req: Request, res: Response) => {
try {
const accountId = req.params.accountId as string;
await sessionManager.pauseSession(accountId);
res.json({ success: true, status: 'paused' });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Resume session
router.post('/sessions/:accountId/resume', async (req: Request, res: Response) => {
try {
const accountId = req.params.accountId as string;
const session = await sessionManager.resumeSession(accountId);
res.json({ success: true, session });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Delete session
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
try {
await sessionManager.deleteSession(req.params.accountId);
const accountId = req.params.accountId as string;
await sessionManager.deleteSession(accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -84,8 +109,9 @@ export function createRouter(sessionManager: SessionManager): Router {
messageContent = { text: content.text || content };
}
const accountId = req.params.accountId as string;
const result = await sessionManager.sendMessage(
req.params.accountId,
accountId,
to,
messageContent
);

View File

@@ -1,4 +1,6 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { SessionManager } from './sessions/SessionManager';
@@ -21,8 +23,12 @@ async function main() {
path: '/ws',
});
app.use(cors());
app.use(express.json());
// Serve media files statically
app.use('/media', express.static(path.join(process.cwd(), 'media')));
const sessionManager = new SessionManager('./sessions');
const router = createRouter(sessionManager);
app.use('/api', router);
@@ -36,11 +42,14 @@ async function main() {
// Forward to API Gateway
try {
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
const response = await fetch(`${API_GATEWAY_URL}/api/whatsapp/internal/whatsapp/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
if (!response.ok) {
logger.error({ status: response.status }, 'API Gateway rejected event');
}
} catch (error) {
logger.error({ error }, 'Failed to forward event to API Gateway');
}

View File

@@ -0,0 +1,319 @@
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WASocket,
proto,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
downloadMediaMessage,
getContentType,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as QRCode from 'qrcode';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
import pino from 'pino';
import { randomUUID } from 'crypto';
import { SessionStore, SessionInfo, SessionEvent } from './types';
const logger = pino({ level: 'info' });
export class SessionManager extends EventEmitter {
private sessions: Map<string, SessionStore> = new Map();
private sessionsPath: string;
private mediaPath: string;
constructor(sessionsPath: string = './sessions') {
super();
this.sessionsPath = sessionsPath;
this.mediaPath = './media';
if (!fs.existsSync(sessionsPath)) {
fs.mkdirSync(sessionsPath, { recursive: true });
}
if (!fs.existsSync(this.mediaPath)) {
fs.mkdirSync(this.mediaPath, { recursive: true });
}
}
async createSession(accountId: string, name: string, phoneNumber?: string): Promise<SessionInfo> {
if (this.sessions.has(accountId)) {
const existing = this.sessions.get(accountId)!;
return existing.info;
}
const sessionPath = path.join(this.sessionsPath, accountId);
const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
const info: SessionInfo = {
accountId,
phoneNumber: null,
name,
status: 'connecting',
qrCode: null,
pairingCode: null,
createdAt: new Date(),
};
const store: SessionStore = { socket: null, info };
this.sessions.set(accountId, store);
// Get latest version
const { version } = await fetchLatestBaileysVersion();
logger.info({ version }, 'Using WA version');
const socket = makeWASocket({
version,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
logger: pino({ level: 'warn' }),
syncFullHistory: false,
markOnlineOnConnect: false,
browser: ['WhatsApp Central', 'Desktop', '1.0.0'],
connectTimeoutMs: 60000,
retryRequestDelayMs: 2000,
});
store.socket = socket;
socket.ev.on('creds.update', saveCreds);
socket.ev.on('connection.update', async (update) => {
const { connection, lastDisconnect, qr } = update;
logger.info({ connection, hasQR: !!qr }, 'Connection update');
if (qr) {
try {
const qrDataUrl = await QRCode.toDataURL(qr);
info.qrCode = qrDataUrl;
info.status = 'connecting';
logger.info({ accountId }, 'QR code generated');
this.emitEvent({ type: 'qr', accountId, data: { qrCode: qrDataUrl } });
} catch (err) {
logger.error({ err }, 'Failed to generate QR');
}
}
if (connection === 'close') {
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = reason !== DisconnectReason.loggedOut;
logger.info({ reason, shouldReconnect }, `Connection closed for ${accountId}`);
info.status = 'disconnected';
info.qrCode = null;
if (shouldReconnect) {
// Delete session data and try fresh
this.sessions.delete(accountId);
setTimeout(() => {
logger.info(`Retrying session ${accountId}...`);
this.createSession(accountId, name, phoneNumber);
}, 5000);
} else {
logger.info(`Session ${accountId} logged out`);
this.emitEvent({ type: 'disconnected', accountId, data: { reason: 'logged_out' } });
}
}
if (connection === 'open') {
info.status = 'connected';
info.qrCode = null;
info.phoneNumber = socket.user?.id?.split(':')[0] || null;
logger.info(`Session ${accountId} connected: ${info.phoneNumber}`);
this.emitEvent({ type: 'connected', accountId, data: { phoneNumber: info.phoneNumber } });
}
});
// Request pairing code if phone number provided
if (phoneNumber && !state.creds.registered) {
try {
const code = await socket.requestPairingCode(phoneNumber);
info.pairingCode = code;
logger.info({ accountId, code }, 'Pairing code generated');
this.emitEvent({ type: 'pairing_code', accountId, data: { code } });
} catch (err) {
logger.error({ err }, 'Failed to request pairing code');
}
}
socket.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
const remoteJid = msg.key.remoteJid || '';
// Skip group messages (groups end with @g.us)
if (remoteJid.endsWith('@g.us')) continue;
// Skip broadcast lists
if (remoteJid === 'status@broadcast') continue;
// Skip non-user messages (like system messages)
if (!remoteJid.endsWith('@s.whatsapp.net')) continue;
// Detect message type and handle media
let mediaUrl: string | null = null;
let mediaType: string = 'text';
const msgContent = msg.message;
if (msgContent) {
const contentType = getContentType(msgContent);
if (contentType && ['imageMessage', 'audioMessage', 'videoMessage', 'documentMessage', 'stickerMessage'].includes(contentType)) {
try {
// Download media
const buffer = await downloadMediaMessage(
msg,
'buffer',
{},
{
logger,
reuploadRequest: socket.updateMediaMessage,
}
);
if (buffer) {
// Determine file extension
const extensions: Record<string, string> = {
imageMessage: 'jpg',
audioMessage: 'ogg',
videoMessage: 'mp4',
documentMessage: 'pdf',
stickerMessage: 'webp',
};
const ext = extensions[contentType] || 'bin';
const filename = `${randomUUID()}.${ext}`;
const filepath = path.join(this.mediaPath, filename);
// Save file
fs.writeFileSync(filepath, buffer as Buffer);
mediaUrl = `/media/${filename}`;
mediaType = contentType.replace('Message', '');
logger.info({ accountId, filename, mediaType }, 'Media downloaded');
}
} catch (err) {
logger.error({ err }, 'Failed to download media');
}
}
}
const messageData = {
id: msg.key.id,
from: remoteJid,
fromMe: msg.key.fromMe || false,
pushName: msg.pushName,
message: msg.message,
timestamp: msg.messageTimestamp,
mediaUrl,
mediaType,
};
this.emitEvent({ type: 'message', accountId, data: messageData });
}
});
socket.ev.on('messages.update', (updates) => {
for (const update of updates) {
if (update.update.status) {
this.emitEvent({
type: 'message_status',
accountId,
data: {
id: update.key.id,
remoteJid: update.key.remoteJid,
status: update.update.status,
},
});
}
}
});
return info;
}
async disconnectSession(accountId: string): Promise<void> {
const store = this.sessions.get(accountId);
if (!store || !store.socket) return;
await store.socket.logout();
store.socket = null;
store.info.status = 'disconnected';
store.info.qrCode = null;
}
async pauseSession(accountId: string): Promise<void> {
const store = this.sessions.get(accountId);
if (!store || !store.socket) return;
// Close connection without logout (keeps credentials)
store.socket.end(undefined);
store.socket = null;
store.info.status = 'paused';
store.info.qrCode = null;
logger.info({ accountId }, 'Session paused');
this.emitEvent({ type: 'paused', accountId, data: {} });
}
async resumeSession(accountId: string): Promise<SessionInfo> {
const store = this.sessions.get(accountId);
if (!store) {
throw new Error(`Session ${accountId} not found`);
}
if (store.info.status === 'connected') {
return store.info;
}
logger.info({ accountId }, 'Resuming session');
// Recreate the session using existing credentials
const name = store.info.name;
this.sessions.delete(accountId);
return this.createSession(accountId, name);
}
async deleteSession(accountId: string): Promise<void> {
await this.disconnectSession(accountId);
this.sessions.delete(accountId);
const sessionPath = path.join(this.sessionsPath, accountId);
if (fs.existsSync(sessionPath)) {
fs.rmSync(sessionPath, { recursive: true });
}
}
getSession(accountId: string): SessionInfo | null {
const store = this.sessions.get(accountId);
return store?.info || null;
}
getAllSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.info);
}
async sendMessage(
accountId: string,
to: string,
content: proto.IMessage
): Promise<proto.WebMessageInfo | null> {
const store = this.sessions.get(accountId);
if (!store?.socket || store.info.status !== 'connected') {
throw new Error(`Session ${accountId} not connected`);
}
const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
const result = await store.socket.sendMessage(jid, content as any);
return result || null;
}
private emitEvent(event: SessionEvent): void {
this.emit('session_event', event);
}
}

View File

@@ -0,0 +1,31 @@
import { WASocket } from '@whiskeysockets/baileys';
export interface SessionInfo {
accountId: string;
phoneNumber: string | null;
name: string;
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
qrCode: string | null;
pairingCode: string | null;
createdAt: Date;
}
export interface SessionStore {
socket: WASocket | null;
info: SessionInfo;
}
export type SessionEventType =
| 'qr'
| 'pairing_code'
| 'connected'
| 'disconnected'
| 'paused'
| 'message'
| 'message_status';
export interface SessionEvent {
type: SessionEventType;
accountId: string;
data: unknown;
}