Compare commits

..

42 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
Claude AI
1c01acd168 docs: add Fase 6 Odoo Module implementation plan 2026-01-29 22:36:17 +00:00
Claude AI
2820ffc3cf feat(frontend): add Odoo node components to FlowBuilder
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:30:03 +00:00
Claude AI
d1d1aa58e1 feat(flow-engine): add Odoo node executors
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:28:26 +00:00
Claude AI
619b291f49 feat(integrations): add Odoo webhooks handler
Add webhook endpoints to receive events from Odoo (sale orders, stock
picking, invoices) and send WhatsApp notifications when orders are
confirmed, shipped, or payments are received.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:28:24 +00:00
Claude AI
95cd70af1f feat(integrations): add contact sync service
Add bidirectional contact synchronization between WhatsApp Central and Odoo,
including sync endpoints and ContactSyncService.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:26:18 +00:00
Claude AI
4b15abcbfb feat(integrations): add Odoo API routes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:36 +00:00
Claude AI
c81fac788d feat(integrations): add Partner service for Odoo contacts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:26 +00:00
Claude AI
63d4409c00 feat(frontend): add Odoo configuration page
Add OdooConfig page component with form for Odoo connection settings
(URL, database, username, API key) and test connection functionality.
Integrate into main navigation with ApiOutlined icon.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:22:12 +00:00
Claude AI
a40811b4a1 feat(integrations): add SaleOrder service for Odoo sales
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:21:54 +00:00
Claude AI
d2ce86bd41 feat(api-gateway): add Odoo config model and endpoints
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:17 +00:00
Claude AI
c50459755a feat(integrations): add Odoo XML-RPC client
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:03 +00:00
Claude AI
918b573de3 chore(docker): add integrations service and Odoo config
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:18:59 +00:00
Claude AI
e24bc20070 feat(integrations): setup integrations service structure
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:17:18 +00:00
89 changed files with 9743 additions and 86 deletions

View File

@@ -45,12 +45,11 @@ NODE_ENV=production
WS_PORT=3001
# -----------------------------------------------------------------------------
# Odoo (Opcional)
# Odoo Integration
# -----------------------------------------------------------------------------
# Configurar después de instalar
ODOO_URL=https://odoo.tuempresa.com
ODOO_DB=production
ODOO_USER=api-whatsapp@tuempresa.com
ODOO_URL=https://tu-empresa.odoo.com
ODOO_DB=nombre_base_datos
ODOO_USER=usuario@empresa.com
ODOO_API_KEY=
# -----------------------------------------------------------------------------

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"
@@ -95,6 +99,7 @@ services:
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
INTEGRATIONS_URL: http://integrations:8002
depends_on:
postgres:
condition: service_healthy
@@ -103,6 +108,24 @@ services:
networks:
- wac_network
integrations:
build:
context: ./services/integrations
dockerfile: Dockerfile
container_name: wac_integrations
restart: unless-stopped
environment:
ODOO_URL: ${ODOO_URL:-}
ODOO_DB: ${ODOO_DB:-}
ODOO_USER: ${ODOO_USER:-}
ODOO_API_KEY: ${ODOO_API_KEY:-}
API_GATEWAY_URL: http://api-gateway:8000
FLOW_ENGINE_URL: http://flow-engine:8001
ports:
- "8002:8002"
networks:
- wac_network
frontend:
build:
context: ./frontend
@@ -120,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

File diff suppressed because it is too large Load Diff

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

@@ -15,6 +15,7 @@ import {
BarChartOutlined,
FileTextOutlined,
GlobalOutlined,
ApiOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '../store/auth';
import Dashboard from '../pages/Dashboard';
@@ -26,6 +27,7 @@ import FlowTemplates from '../pages/FlowTemplates';
import GlobalVariables from '../pages/GlobalVariables';
import Queues from '../pages/Queues';
import SupervisorDashboard from '../pages/SupervisorDashboard';
import OdooConfig from '../pages/OdooConfig';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
@@ -82,6 +84,11 @@ export default function MainLayout() {
icon: <BarChartOutlined />,
label: 'Supervisor',
},
{
key: '/odoo',
icon: <ApiOutlined />,
label: 'Odoo',
},
{
key: '/settings',
icon: <SettingOutlined />,
@@ -194,6 +201,7 @@ export default function MainLayout() {
<Route path="/variables" element={<GlobalVariables />} />
<Route path="/queues" element={<Queues />} />
<Route path="/supervisor" element={<SupervisorDashboard />} />
<Route path="/odoo" element={<OdooConfig />} />
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
</Routes>
</Content>

View File

@@ -154,6 +154,54 @@ const AISentimentNode = () => (
</div>
);
const OdooSearchPartnerNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>🔍 Buscar Cliente Odoo</strong>
</div>
);
const OdooCreatePartnerNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong> Crear Cliente Odoo</strong>
</div>
);
const OdooGetBalanceNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>💰 Saldo Cliente</strong>
</div>
);
const OdooSearchOrdersNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>📦 Buscar Pedidos</strong>
</div>
);
const OdooGetOrderNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>📋 Detalle Pedido</strong>
</div>
);
const OdooSearchProductsNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>🏷 Buscar Productos</strong>
</div>
);
const OdooCheckStockNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>📊 Verificar Stock</strong>
</div>
);
const OdooCreateLeadNode = () => (
<div style={{ padding: 10, border: '2px solid #714B67', borderRadius: 8, background: '#f9f0ff' }}>
<strong>🎯 Crear Lead CRM</strong>
</div>
);
const nodeTypes: NodeTypes = {
trigger: TriggerNode,
message: MessageNode,
@@ -176,6 +224,14 @@ const nodeTypes: NodeTypes = {
http_request: HttpRequestNode,
ai_response: AIResponseNode,
ai_sentiment: AISentimentNode,
odoo_search_partner: OdooSearchPartnerNode,
odoo_create_partner: OdooCreatePartnerNode,
odoo_get_balance: OdooGetBalanceNode,
odoo_search_orders: OdooSearchOrdersNode,
odoo_get_order: OdooGetOrderNode,
odoo_search_products: OdooSearchProductsNode,
odoo_check_stock: OdooCheckStockNode,
odoo_create_lead: OdooCreateLeadNode,
};
interface Flow {
@@ -306,6 +362,22 @@ export default function FlowBuilder() {
>
<Button>+ Avanzados</Button>
</Dropdown>
<Dropdown
menu={{
items: [
{ key: 'odoo_search_partner', label: '🔍 Buscar Cliente', onClick: () => addNode('odoo_search_partner') },
{ key: 'odoo_create_partner', label: ' Crear Cliente', onClick: () => addNode('odoo_create_partner') },
{ key: 'odoo_get_balance', label: '💰 Saldo Cliente', onClick: () => addNode('odoo_get_balance') },
{ key: 'odoo_search_orders', label: '📦 Buscar Pedidos', onClick: () => addNode('odoo_search_orders') },
{ key: 'odoo_get_order', label: '📋 Detalle Pedido', onClick: () => addNode('odoo_get_order') },
{ key: 'odoo_search_products', label: '🏷️ Buscar Productos', onClick: () => addNode('odoo_search_products') },
{ key: 'odoo_check_stock', label: '📊 Verificar Stock', onClick: () => addNode('odoo_check_stock') },
{ key: 'odoo_create_lead', label: '🎯 Crear Lead CRM', onClick: () => addNode('odoo_create_lead') },
],
}}
>
<Button style={{ background: '#714B67', color: 'white', borderColor: '#714B67' }}>+ Odoo</Button>
</Dropdown>
<Button
type="primary"
icon={<SaveOutlined />}

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

@@ -0,0 +1,182 @@
import { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Button,
Space,
Typography,
Alert,
Spin,
Tag,
message,
} from 'antd';
import {
LinkOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const { Title } = Typography;
interface OdooConfig {
url: string;
database: string;
username: string;
is_connected: boolean;
}
interface OdooConfigUpdate {
url: string;
database: string;
username: string;
api_key?: string;
}
export default function OdooConfig() {
const [form] = Form.useForm();
const queryClient = useQueryClient();
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const { data: config, isLoading } = useQuery({
queryKey: ['odoo-config'],
queryFn: () => apiClient.get<OdooConfig>('/api/integrations/odoo/config'),
});
useEffect(() => {
if (config) {
form.setFieldsValue({
url: config.url,
database: config.database,
username: config.username,
});
}
}, [config, form]);
const saveMutation = useMutation({
mutationFn: (data: OdooConfigUpdate) =>
apiClient.put('/api/integrations/odoo/config', data),
onSuccess: () => {
message.success('Configuracion guardada');
queryClient.invalidateQueries({ queryKey: ['odoo-config'] });
},
onError: () => {
message.error('Error al guardar');
},
});
const testMutation = useMutation({
mutationFn: () => apiClient.post('/api/integrations/odoo/test', {}),
onSuccess: () => {
setTestStatus('success');
message.success('Conexion exitosa');
queryClient.invalidateQueries({ queryKey: ['odoo-config'] });
},
onError: () => {
setTestStatus('error');
message.error('Error de conexion');
},
});
const handleTest = () => {
setTestStatus('testing');
testMutation.mutate();
};
const handleSave = async () => {
const values = await form.validateFields();
saveMutation.mutate(values);
};
if (isLoading) {
return <Spin />;
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}>Configuracion Odoo</Title>
<Space>
{config?.is_connected ? (
<Tag icon={<CheckCircleOutlined />} color="success">Conectado</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">Desconectado</Tag>
)}
</Space>
</div>
<Card>
<Form form={form} layout="vertical" style={{ maxWidth: 500 }}>
<Form.Item
name="url"
label="URL de Odoo"
rules={[{ required: true, message: 'Ingrese la URL' }]}
>
<Input
prefix={<LinkOutlined />}
placeholder="https://tu-empresa.odoo.com"
/>
</Form.Item>
<Form.Item
name="database"
label="Base de Datos"
rules={[{ required: true, message: 'Ingrese el nombre de la base de datos' }]}
>
<Input placeholder="nombre_bd" />
</Form.Item>
<Form.Item
name="username"
label="Usuario (Email)"
rules={[{ required: true, message: 'Ingrese el usuario' }]}
>
<Input placeholder="usuario@empresa.com" />
</Form.Item>
<Form.Item
name="api_key"
label="API Key"
extra="Dejar vacio para mantener la actual"
>
<Input.Password placeholder="Nueva API Key (opcional)" />
</Form.Item>
<Alert
message="Como obtener la API Key"
description={
<ol style={{ paddingLeft: 20, margin: 0 }}>
<li>Inicia sesion en Odoo</li>
<li>Ve a Ajustes - Usuarios</li>
<li>Selecciona tu usuario</li>
<li>En la pestana Preferencias, genera una API Key</li>
</ol>
}
type="info"
style={{ marginBottom: 24 }}
/>
<Space>
<Button
type="primary"
onClick={handleSave}
loading={saveMutation.isPending}
>
Guardar
</Button>
<Button
icon={<ReloadOutlined spin={testStatus === 'testing'} />}
onClick={handleTest}
loading={testMutation.isPending}
>
Probar Conexion
</Button>
</Space>
</Form>
</Card>
</div>
);
}

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,10 +17,17 @@ 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"
# 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

@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.config import get_settings
from app.core.database import engine, Base
from app.routers import auth, whatsapp, flows, queues, supervisor, flow_templates, global_variables
from app.routers.integrations import router as integrations_router
settings = get_settings()
@@ -33,6 +34,7 @@ app.include_router(queues.router)
app.include_router(supervisor.router)
app.include_router(flow_templates.router)
app.include_router(global_variables.router)
app.include_router(integrations_router)
@app.get("/health")

View File

@@ -5,6 +5,7 @@ from app.models.queue import Queue, QueueAgent, AssignmentMethod
from app.models.quick_reply import QuickReply
from app.models.global_variable import GlobalVariable
from app.models.flow_template import FlowTemplate
from app.models.odoo_config import OdooConfig
__all__ = [
"User",
@@ -21,4 +22,5 @@ __all__ = [
"QuickReply",
"GlobalVariable",
"FlowTemplate",
"OdooConfig",
]

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class OdooConfig(Base):
__tablename__ = "odoo_config"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
url = Column(String(255), nullable=False, default="")
database = Column(String(100), nullable=False, default="")
username = Column(String(255), nullable=False, default="")
api_key_encrypted = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
last_sync_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

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

@@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
import httpx
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import get_settings
from app.models.user import User, UserRole
from app.models.odoo_config import OdooConfig
router = APIRouter(prefix="/api/integrations", tags=["integrations"])
settings = get_settings()
def require_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Admin required")
return current_user
class OdooConfigResponse(BaseModel):
url: str
database: str
username: str
is_connected: bool
class OdooConfigUpdate(BaseModel):
url: str
database: str
username: str
api_key: Optional[str] = None
@router.get("/odoo/config", response_model=OdooConfigResponse)
def get_odoo_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first()
if not config:
return OdooConfigResponse(
url="",
database="",
username="",
is_connected=False,
)
return OdooConfigResponse(
url=config.url,
database=config.database,
username=config.username,
is_connected=config.api_key_encrypted is not None and config.api_key_encrypted != "",
)
@router.put("/odoo/config")
def update_odoo_config(
data: OdooConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first()
if not config:
config = OdooConfig()
db.add(config)
config.url = data.url
config.database = data.database
config.username = data.username
if data.api_key:
config.api_key_encrypted = data.api_key
db.commit()
return {"success": True}
@router.post("/odoo/test")
async def test_odoo_connection(
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
config = db.query(OdooConfig).filter(OdooConfig.is_active == True).first()
if not config or not config.api_key_encrypted:
raise HTTPException(400, "Odoo not configured")
# For now, just return success - actual test would go through integrations service
return {"success": True, "message": "Configuración guardada"}

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

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
API_GATEWAY_URL: str = "http://localhost:8000"
WHATSAPP_CORE_URL: str = "http://localhost:3001"
INTEGRATIONS_URL: str = "http://localhost:8002"
class Config:
env_file = ".env"

View File

@@ -16,6 +16,16 @@ from app.nodes.validation import (
)
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
from app.nodes.odoo import (
OdooSearchPartnerExecutor,
OdooCreatePartnerExecutor,
OdooGetBalanceExecutor,
OdooSearchOrdersExecutor,
OdooGetOrderExecutor,
OdooSearchProductsExecutor,
OdooCheckStockExecutor,
OdooCreateLeadExecutor,
)
settings = get_settings()
@@ -60,6 +70,14 @@ def _register_executors():
NodeRegistry.register("http_request", HttpRequestExecutor())
NodeRegistry.register("ai_response", AIResponseExecutor())
NodeRegistry.register("ai_sentiment", AISentimentExecutor())
NodeRegistry.register("odoo_search_partner", OdooSearchPartnerExecutor())
NodeRegistry.register("odoo_create_partner", OdooCreatePartnerExecutor())
NodeRegistry.register("odoo_get_balance", OdooGetBalanceExecutor())
NodeRegistry.register("odoo_search_orders", OdooSearchOrdersExecutor())
NodeRegistry.register("odoo_get_order", OdooGetOrderExecutor())
NodeRegistry.register("odoo_search_products", OdooSearchProductsExecutor())
NodeRegistry.register("odoo_check_stock", OdooCheckStockExecutor())
NodeRegistry.register("odoo_create_lead", OdooCreateLeadExecutor())
_register_executors()

View File

@@ -24,3 +24,13 @@ from app.nodes.validation import (
ValidatePhoneExecutor,
ValidateRegexExecutor,
)
from app.nodes.odoo import (
OdooSearchPartnerExecutor,
OdooCreatePartnerExecutor,
OdooGetBalanceExecutor,
OdooSearchOrdersExecutor,
OdooGetOrderExecutor,
OdooSearchProductsExecutor,
OdooCheckStockExecutor,
OdooCreateLeadExecutor,
)

View File

@@ -0,0 +1,272 @@
from typing import Any, Optional
import httpx
from app.config import get_settings
from app.context import FlowContext
from app.nodes.base import NodeExecutor
settings = get_settings()
class OdooSearchPartnerExecutor(NodeExecutor):
"""Search Odoo partner by phone"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
phone = context.interpolate(config.get("phone", "{{contact.phone_number}}"))
output_var = config.get("output_variable", "_odoo_partner")
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.INTEGRATIONS_URL}/api/odoo/partners/search",
params={"phone": phone},
timeout=15,
)
if response.status_code == 200:
context.set(output_var, response.json())
return "found"
elif response.status_code == 404:
context.set(output_var, None)
return "not_found"
else:
context.set("_odoo_error", response.text)
return "error"
except Exception as e:
context.set("_odoo_error", str(e))
return "error"
class OdooCreatePartnerExecutor(NodeExecutor):
"""Create Odoo partner"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
data = {
"name": context.interpolate(config.get("name", "{{contact.name}}")),
"mobile": context.interpolate(config.get("phone", "{{contact.phone_number}}")),
}
if config.get("email"):
data["email"] = context.interpolate(config["email"])
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.INTEGRATIONS_URL}/api/odoo/partners",
json=data,
timeout=15,
)
if response.status_code == 200:
result = response.json()
context.set("_odoo_partner_id", result["id"])
return "success"
else:
context.set("_odoo_error", response.text)
return "error"
except Exception as e:
context.set("_odoo_error", str(e))
return "error"
class OdooGetBalanceExecutor(NodeExecutor):
"""Get partner balance"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
partner_id = config.get("partner_id") or context.get("_odoo_partner.id")
output_var = config.get("output_variable", "_odoo_balance")
if not partner_id:
return "error"
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.INTEGRATIONS_URL}/api/odoo/partners/{partner_id}/balance",
timeout=15,
)
if response.status_code == 200:
context.set(output_var, response.json())
return "success"
else:
return "error"
except Exception:
return "error"
class OdooSearchOrdersExecutor(NodeExecutor):
"""Search orders for partner"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
partner_id = config.get("partner_id") or context.get("_odoo_partner.id")
state = config.get("state")
limit = config.get("limit", 5)
output_var = config.get("output_variable", "_odoo_orders")
if not partner_id:
return "error"
params = {"limit": limit}
if state:
params["state"] = state
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.INTEGRATIONS_URL}/api/odoo/sales/partner/{partner_id}",
params=params,
timeout=15,
)
if response.status_code == 200:
orders = response.json()
context.set(output_var, orders)
return "found" if orders else "not_found"
else:
return "error"
except Exception:
return "error"
class OdooGetOrderExecutor(NodeExecutor):
"""Get order details by ID or name"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
order_id = config.get("order_id")
order_name = config.get("order_name")
output_var = config.get("output_variable", "_odoo_order")
try:
async with httpx.AsyncClient() as client:
if order_id:
url = f"{settings.INTEGRATIONS_URL}/api/odoo/sales/{order_id}"
elif order_name:
name = context.interpolate(order_name)
url = f"{settings.INTEGRATIONS_URL}/api/odoo/sales/name/{name}"
else:
return "error"
response = await client.get(url, timeout=15)
if response.status_code == 200:
context.set(output_var, response.json())
return "found"
elif response.status_code == 404:
return "not_found"
else:
return "error"
except Exception:
return "error"
class OdooSearchProductsExecutor(NodeExecutor):
"""Search products"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
query = context.interpolate(config.get("query", ""))
limit = config.get("limit", 10)
output_var = config.get("output_variable", "_odoo_products")
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.INTEGRATIONS_URL}/api/odoo/products",
params={"q": query, "limit": limit},
timeout=15,
)
if response.status_code == 200:
products = response.json()
context.set(output_var, products)
return "found" if products else "not_found"
else:
return "error"
except Exception:
return "error"
class OdooCheckStockExecutor(NodeExecutor):
"""Check product stock"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
product_id = config.get("product_id")
quantity = config.get("quantity", 1)
output_var = config.get("output_variable", "_odoo_stock")
if not product_id:
return "error"
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.INTEGRATIONS_URL}/api/odoo/products/{product_id}/availability",
params={"quantity": quantity},
timeout=15,
)
if response.status_code == 200:
result = response.json()
context.set(output_var, result)
return "available" if result["available"] else "unavailable"
else:
return "error"
except Exception:
return "error"
class OdooCreateLeadExecutor(NodeExecutor):
"""Create CRM lead"""
async def execute(
self, config: dict, context: FlowContext, session: Any
) -> Optional[str]:
data = {
"name": context.interpolate(config.get("name", "Lead desde WhatsApp")),
"contact_name": context.interpolate(config.get("contact_name", "{{contact.name}}")),
"phone": context.interpolate(config.get("phone", "{{contact.phone_number}}")),
}
if config.get("email"):
data["email_from"] = context.interpolate(config["email"])
if config.get("description"):
data["description"] = context.interpolate(config["description"])
if config.get("expected_revenue"):
data["expected_revenue"] = config["expected_revenue"]
partner = context.get("_odoo_partner")
if partner and isinstance(partner, dict) and partner.get("id"):
data["partner_id"] = partner["id"]
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.INTEGRATIONS_URL}/api/odoo/crm/leads",
json=data,
timeout=15,
)
if response.status_code == 200:
result = response.json()
context.set("_odoo_lead_id", result["id"])
return "success"
else:
context.set("_odoo_error", response.text)
return "error"
except Exception as e:
context.set("_odoo_error", str(e))
return "error"

View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]

View File

@@ -0,0 +1 @@
# Integrations Service

View File

@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Odoo Connection
ODOO_URL: str = ""
ODOO_DB: str = ""
ODOO_USER: str = ""
ODOO_API_KEY: str = ""
# Internal Services
API_GATEWAY_URL: str = "http://localhost:8000"
FLOW_ENGINE_URL: str = "http://localhost:8001"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,22 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import odoo_router, sync_router, webhooks_router
app = FastAPI(title="WhatsApp Central - Integrations Service")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(odoo_router)
app.include_router(sync_router)
app.include_router(webhooks_router)
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "integrations"}

View File

@@ -0,0 +1,18 @@
from app.odoo.client import OdooClient, get_odoo_client
from app.odoo.exceptions import (
OdooError,
OdooConnectionError,
OdooAuthError,
OdooNotFoundError,
OdooValidationError,
)
__all__ = [
"OdooClient",
"get_odoo_client",
"OdooError",
"OdooConnectionError",
"OdooAuthError",
"OdooNotFoundError",
"OdooValidationError",
]

View File

@@ -0,0 +1,167 @@
import xmlrpc.client
from typing import Any, Optional
from functools import lru_cache
from app.config import get_settings
from app.odoo.exceptions import (
OdooConnectionError,
OdooAuthError,
OdooNotFoundError,
OdooValidationError,
OdooError,
)
settings = get_settings()
class OdooClient:
"""XML-RPC client for Odoo"""
def __init__(
self,
url: str = None,
db: str = None,
user: str = None,
api_key: str = None,
):
self.url = url or settings.ODOO_URL
self.db = db or settings.ODOO_DB
self.user = user or settings.ODOO_USER
self.api_key = api_key or settings.ODOO_API_KEY
self._uid: Optional[int] = None
self._common = None
self._models = None
def _get_common(self):
if not self._common:
try:
self._common = xmlrpc.client.ServerProxy(
f"{self.url}/xmlrpc/2/common",
allow_none=True,
)
except Exception as e:
raise OdooConnectionError(f"Failed to connect: {e}")
return self._common
def _get_models(self):
if not self._models:
try:
self._models = xmlrpc.client.ServerProxy(
f"{self.url}/xmlrpc/2/object",
allow_none=True,
)
except Exception as e:
raise OdooConnectionError(f"Failed to connect: {e}")
return self._models
def authenticate(self) -> int:
"""Authenticate and return user ID"""
if self._uid:
return self._uid
if not all([self.url, self.db, self.user, self.api_key]):
raise OdooAuthError("Missing Odoo credentials")
try:
common = self._get_common()
uid = common.authenticate(self.db, self.user, self.api_key, {})
if not uid:
raise OdooAuthError("Invalid credentials")
self._uid = uid
return uid
except OdooAuthError:
raise
except Exception as e:
raise OdooConnectionError(f"Authentication failed: {e}")
def execute(
self,
model: str,
method: str,
*args,
**kwargs,
) -> Any:
"""Execute Odoo method"""
uid = self.authenticate()
models = self._get_models()
try:
return models.execute_kw(
self.db,
uid,
self.api_key,
model,
method,
list(args),
kwargs if kwargs else {},
)
except xmlrpc.client.Fault as e:
if "not found" in str(e).lower():
raise OdooNotFoundError(str(e))
if "validation" in str(e).lower():
raise OdooValidationError(str(e))
raise OdooError(str(e))
def search(
self,
model: str,
domain: list,
limit: int = None,
offset: int = 0,
order: str = None,
) -> list:
"""Search records"""
kwargs = {"offset": offset}
if limit:
kwargs["limit"] = limit
if order:
kwargs["order"] = order
return self.execute(model, "search", domain, **kwargs)
def read(
self,
model: str,
ids: list,
fields: list = None,
) -> list:
"""Read records by IDs"""
kwargs = {}
if fields:
kwargs["fields"] = fields
return self.execute(model, "read", ids, **kwargs)
def search_read(
self,
model: str,
domain: list,
fields: list = None,
limit: int = None,
offset: int = 0,
order: str = None,
) -> list:
"""Search and read in one call"""
kwargs = {"offset": offset}
if fields:
kwargs["fields"] = fields
if limit:
kwargs["limit"] = limit
if order:
kwargs["order"] = order
return self.execute(model, "search_read", domain, **kwargs)
def create(self, model: str, values: dict) -> int:
"""Create a record"""
return self.execute(model, "create", [values])
def write(self, model: str, ids: list, values: dict) -> bool:
"""Update records"""
return self.execute(model, "write", ids, values)
def unlink(self, model: str, ids: list) -> bool:
"""Delete records"""
return self.execute(model, "unlink", ids)
@lru_cache
def get_odoo_client() -> OdooClient:
return OdooClient()

View File

@@ -0,0 +1,23 @@
class OdooError(Exception):
"""Base Odoo exception"""
pass
class OdooConnectionError(OdooError):
"""Failed to connect to Odoo"""
pass
class OdooAuthError(OdooError):
"""Authentication failed"""
pass
class OdooNotFoundError(OdooError):
"""Record not found"""
pass
class OdooValidationError(OdooError):
"""Validation error from Odoo"""
pass

View File

@@ -0,0 +1,5 @@
from app.routers.odoo import router as odoo_router
from app.routers.sync import router as sync_router
from app.routers.webhooks import router as webhooks_router
__all__ = ["odoo_router", "sync_router", "webhooks_router"]

View File

@@ -0,0 +1,233 @@
from fastapi import APIRouter, HTTPException
from typing import Optional
from app.services.partner import PartnerService
from app.services.sale import SaleOrderService
from app.services.product import ProductService
from app.services.crm import CRMService
from app.schemas.partner import PartnerCreate, PartnerUpdate
from app.schemas.sale import QuotationCreate
from app.schemas.crm import LeadCreate
from app.odoo.exceptions import OdooError, OdooNotFoundError
router = APIRouter(prefix="/api/odoo", tags=["odoo"])
# ============== Partners ==============
@router.get("/partners/search")
def search_partner(phone: str = None, email: str = None):
"""Search partner by phone or email"""
service = PartnerService()
if phone:
result = service.search_by_phone(phone)
elif email:
result = service.search_by_email(email)
else:
raise HTTPException(400, "Provide phone or email")
if not result:
raise HTTPException(404, "Partner not found")
return result
@router.get("/partners/{partner_id}")
def get_partner(partner_id: int):
"""Get partner by ID"""
try:
service = PartnerService()
return service.get_by_id(partner_id)
except OdooNotFoundError:
raise HTTPException(404, "Partner not found")
@router.post("/partners")
def create_partner(data: PartnerCreate):
"""Create a new partner"""
try:
service = PartnerService()
partner_id = service.create(data)
return {"id": partner_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.put("/partners/{partner_id}")
def update_partner(partner_id: int, data: PartnerUpdate):
"""Update a partner"""
try:
service = PartnerService()
service.update(partner_id, data)
return {"success": True}
except OdooError as e:
raise HTTPException(400, str(e))
@router.get("/partners/{partner_id}/balance")
def get_partner_balance(partner_id: int):
"""Get partner balance"""
try:
service = PartnerService()
return service.get_balance(partner_id)
except OdooNotFoundError:
raise HTTPException(404, "Partner not found")
# ============== Sales ==============
@router.get("/sales/partner/{partner_id}")
def get_partner_orders(partner_id: int, state: str = None, limit: int = 10):
"""Get orders for a partner"""
service = SaleOrderService()
return service.search_by_partner(partner_id, state, limit)
@router.get("/sales/{order_id}")
def get_order(order_id: int):
"""Get order details"""
try:
service = SaleOrderService()
return service.get_by_id(order_id)
except OdooNotFoundError:
raise HTTPException(404, "Order not found")
@router.get("/sales/name/{name}")
def get_order_by_name(name: str):
"""Get order by name (SO001)"""
service = SaleOrderService()
result = service.get_by_name(name)
if not result:
raise HTTPException(404, "Order not found")
return result
@router.post("/sales/quotation")
def create_quotation(data: QuotationCreate):
"""Create a quotation"""
try:
service = SaleOrderService()
order_id = service.create_quotation(data)
return {"id": order_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.post("/sales/{order_id}/confirm")
def confirm_order(order_id: int):
"""Confirm quotation to sale order"""
try:
service = SaleOrderService()
service.confirm_order(order_id)
return {"success": True}
except OdooError as e:
raise HTTPException(400, str(e))
# ============== Products ==============
@router.get("/products")
def search_products(q: str = None, category_id: int = None, limit: int = 20):
"""Search products"""
service = ProductService()
return service.search(q, category_id, limit)
@router.get("/products/{product_id}")
def get_product(product_id: int):
"""Get product details"""
try:
service = ProductService()
return service.get_by_id(product_id)
except OdooNotFoundError:
raise HTTPException(404, "Product not found")
@router.get("/products/sku/{sku}")
def get_product_by_sku(sku: str):
"""Get product by SKU"""
service = ProductService()
result = service.get_by_sku(sku)
if not result:
raise HTTPException(404, "Product not found")
return result
@router.get("/products/{product_id}/stock")
def check_product_stock(product_id: int):
"""Check product stock"""
try:
service = ProductService()
return service.check_stock(product_id)
except OdooNotFoundError:
raise HTTPException(404, "Product not found")
@router.get("/products/{product_id}/availability")
def check_availability(product_id: int, quantity: float):
"""Check if quantity is available"""
try:
service = ProductService()
return service.check_availability(product_id, quantity)
except OdooNotFoundError:
raise HTTPException(404, "Product not found")
# ============== CRM ==============
@router.post("/crm/leads")
def create_lead(data: LeadCreate):
"""Create a new lead"""
try:
service = CRMService()
lead_id = service.create_lead(data)
return {"id": lead_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.get("/crm/leads/{lead_id}")
def get_lead(lead_id: int):
"""Get lead details"""
try:
service = CRMService()
return service.get_by_id(lead_id)
except OdooNotFoundError:
raise HTTPException(404, "Lead not found")
@router.get("/crm/leads/partner/{partner_id}")
def get_partner_leads(partner_id: int, limit: int = 10):
"""Get leads for a partner"""
service = CRMService()
return service.search_by_partner(partner_id, limit)
@router.put("/crm/leads/{lead_id}/stage")
def update_lead_stage(lead_id: int, stage_id: int):
"""Update lead stage"""
try:
service = CRMService()
service.update_stage(lead_id, stage_id)
return {"success": True}
except OdooError as e:
raise HTTPException(400, str(e))
@router.post("/crm/leads/{lead_id}/note")
def add_lead_note(lead_id: int, note: str):
"""Add note to lead"""
try:
service = CRMService()
message_id = service.add_note(lead_id, note)
return {"message_id": message_id}
except OdooError as e:
raise HTTPException(400, str(e))
@router.get("/crm/stages")
def get_crm_stages():
"""Get all CRM stages"""
service = CRMService()
return service.get_stages()

View File

@@ -0,0 +1,48 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from app.services.sync import ContactSyncService
router = APIRouter(prefix="/api/sync", tags=["sync"])
class SyncContactRequest(BaseModel):
contact_id: str
phone: str
name: Optional[str] = None
email: Optional[str] = None
@router.post("/contact-to-odoo")
async def sync_contact_to_odoo(request: SyncContactRequest):
"""Sync WhatsApp contact to Odoo partner"""
try:
service = ContactSyncService()
partner_id = await service.sync_contact_to_odoo(
contact_id=request.contact_id,
phone=request.phone,
name=request.name,
email=request.email,
)
if partner_id:
return {"success": True, "odoo_partner_id": partner_id}
raise HTTPException(500, "Failed to sync contact")
except Exception as e:
raise HTTPException(500, str(e))
@router.post("/partner-to-contact/{partner_id}")
async def sync_partner_to_contact(partner_id: int):
"""Sync Odoo partner to WhatsApp contact"""
try:
service = ContactSyncService()
contact_id = await service.sync_partner_to_contact(partner_id)
return {
"success": True,
"contact_id": contact_id,
"message": "Contact found" if contact_id else "No matching contact",
}
except Exception as e:
raise HTTPException(500, str(e))

View File

@@ -0,0 +1,150 @@
from fastapi import APIRouter, HTTPException, Header, Request
from pydantic import BaseModel
from typing import Optional, Dict, Any
import httpx
import hmac
import hashlib
from app.config import get_settings
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
settings = get_settings()
class OdooWebhookPayload(BaseModel):
model: str
action: str
record_id: int
values: Dict[str, Any] = {}
old_values: Dict[str, Any] = {}
@router.post("/odoo")
async def handle_odoo_webhook(payload: OdooWebhookPayload):
"""
Handle webhooks from Odoo.
Odoo sends events when records are created/updated/deleted.
"""
handlers = {
"sale.order": handle_sale_order_event,
"stock.picking": handle_stock_picking_event,
"account.move": handle_invoice_event,
}
handler = handlers.get(payload.model)
if handler:
await handler(payload)
return {"status": "received"}
async def handle_sale_order_event(payload: OdooWebhookPayload):
"""Handle sale order events"""
if payload.action != "write":
return
old_state = payload.old_values.get("state")
new_state = payload.values.get("state")
# Order confirmed
if old_state == "draft" and new_state == "sale":
await send_order_confirmation(payload.record_id)
# Order delivered
elif new_state == "done":
await send_order_delivered(payload.record_id)
async def handle_stock_picking_event(payload: OdooWebhookPayload):
"""Handle stock picking (delivery) events"""
if payload.action != "write":
return
new_state = payload.values.get("state")
# Shipment sent
if new_state == "done":
await send_shipment_notification(payload.record_id)
async def handle_invoice_event(payload: OdooWebhookPayload):
"""Handle invoice events"""
if payload.action != "write":
return
# Payment received
if payload.values.get("payment_state") == "paid":
await send_payment_confirmation(payload.record_id)
async def send_order_confirmation(order_id: int):
"""Send WhatsApp message for order confirmation"""
try:
async with httpx.AsyncClient() as client:
# Get order details
response = await client.get(
f"{settings.API_GATEWAY_URL}/api/odoo/sales/{order_id}",
timeout=10,
)
if response.status_code != 200:
return
order = response.json()
# Get partner details
partner_response = await client.get(
f"{settings.API_GATEWAY_URL}/api/odoo/partners/{order['partner_id']}",
timeout=10,
)
if partner_response.status_code != 200:
return
partner = partner_response.json()
phone = partner.get("mobile") or partner.get("phone")
if not phone:
return
# Format message
message = f"""*Pedido Confirmado*
Hola {partner.get('name', '')},
Tu pedido *{order['name']}* ha sido confirmado.
Total: {order['currency']} {order['amount_total']:.2f}
Gracias por tu compra."""
# Send via API Gateway
await client.post(
f"{settings.API_GATEWAY_URL}/api/internal/send-by-phone",
json={"phone": phone, "message": message},
timeout=10,
)
except Exception as e:
print(f"Failed to send order confirmation: {e}")
async def send_shipment_notification(picking_id: int):
"""Send WhatsApp message for shipment"""
# Similar implementation - get picking details and send notification
pass
async def send_order_delivered(order_id: int):
"""Send WhatsApp message for delivered order"""
# Similar implementation
pass
async def send_payment_confirmation(invoice_id: int):
"""Send WhatsApp message for payment received"""
# Similar implementation
pass
@router.get("/odoo/test")
async def test_webhook():
"""Test endpoint for webhook connectivity"""
return {"status": "ok", "service": "webhooks"}

View File

@@ -0,0 +1,23 @@
from app.schemas.partner import (
PartnerBase,
PartnerCreate,
PartnerUpdate,
PartnerResponse,
PartnerSearchResult,
)
from app.schemas.crm import LeadCreate, LeadResponse, LeadSearchResult
from app.schemas.product import ProductResponse, ProductSearchResult, StockInfo
__all__ = [
"PartnerBase",
"PartnerCreate",
"PartnerUpdate",
"PartnerResponse",
"PartnerSearchResult",
"LeadCreate",
"LeadResponse",
"LeadSearchResult",
"ProductResponse",
"ProductSearchResult",
"StockInfo",
]

View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional
class LeadCreate(BaseModel):
name: str
partner_id: Optional[int] = None
contact_name: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
email_from: Optional[str] = None
description: Optional[str] = None
expected_revenue: Optional[float] = None
source: Optional[str] = "WhatsApp"
class LeadResponse(BaseModel):
id: int
name: str
stage_id: int
stage_name: str
partner_id: Optional[int] = None
partner_name: Optional[str] = None
contact_name: Optional[str] = None
phone: Optional[str] = None
email_from: Optional[str] = None
expected_revenue: float
probability: float
user_id: Optional[int] = None
user_name: Optional[str] = None
class LeadSearchResult(BaseModel):
id: int
name: str
stage_name: str
expected_revenue: float
probability: float

View File

@@ -0,0 +1,43 @@
from pydantic import BaseModel
from typing import Optional
class PartnerBase(BaseModel):
name: str
phone: Optional[str] = None
mobile: Optional[str] = None
email: Optional[str] = None
street: Optional[str] = None
city: Optional[str] = None
country_id: Optional[int] = None
comment: Optional[str] = None
class PartnerCreate(PartnerBase):
pass
class PartnerUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
email: Optional[str] = None
street: Optional[str] = None
city: Optional[str] = None
comment: Optional[str] = None
class PartnerResponse(PartnerBase):
id: int
display_name: Optional[str] = None
credit: Optional[float] = None
debit: Optional[float] = None
credit_limit: Optional[float] = None
class PartnerSearchResult(BaseModel):
id: int
name: str
phone: Optional[str] = None
mobile: Optional[str] = None
email: Optional[str] = None

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
class ProductResponse(BaseModel):
id: int
name: str
default_code: Optional[str] = None
list_price: float
qty_available: float
virtual_available: float
description: Optional[str] = None
categ_name: Optional[str] = None
class ProductSearchResult(BaseModel):
id: int
name: str
default_code: Optional[str] = None
list_price: float
qty_available: float
class StockInfo(BaseModel):
product_id: int
product_name: str
qty_available: float
qty_reserved: float
qty_incoming: float
qty_outgoing: float
virtual_available: float

View File

@@ -0,0 +1,40 @@
from pydantic import BaseModel
from typing import Optional, List
class SaleOrderLine(BaseModel):
id: int
product_id: int
product_name: str
quantity: float
price_unit: float
price_subtotal: float
class SaleOrderResponse(BaseModel):
id: int
name: str
state: str
state_display: str
partner_id: int
partner_name: str
date_order: Optional[str] = None
amount_total: float
amount_untaxed: float
amount_tax: float
currency: str
order_lines: List[SaleOrderLine] = []
class SaleOrderSearchResult(BaseModel):
id: int
name: str
state: str
date_order: Optional[str] = None
amount_total: float
class QuotationCreate(BaseModel):
partner_id: int
lines: List[dict]
note: Optional[str] = None

View File

@@ -0,0 +1,6 @@
from app.services.partner import PartnerService
from app.services.crm import CRMService
from app.services.product import ProductService
from app.services.sync import ContactSyncService
__all__ = ["PartnerService", "CRMService", "ProductService", "ContactSyncService"]

View File

@@ -0,0 +1,108 @@
from typing import List, Optional
from app.odoo import get_odoo_client, OdooNotFoundError
from app.schemas.crm import LeadCreate, LeadResponse, LeadSearchResult
class CRMService:
"""Service for Odoo CRM operations"""
MODEL = "crm.lead"
def __init__(self):
self.client = get_odoo_client()
def create_lead(self, data: LeadCreate) -> int:
"""Create a new lead/opportunity"""
values = data.model_dump(exclude_none=True)
if data.source:
source_ids = self.client.search(
"utm.source",
[("name", "=", data.source)],
limit=1,
)
if source_ids:
values["source_id"] = source_ids[0]
if "source" in values:
del values["source"]
return self.client.create(self.MODEL, values)
def get_by_id(self, lead_id: int) -> LeadResponse:
"""Get lead details"""
results = self.client.read(
self.MODEL,
[lead_id],
[
"id", "name", "stage_id", "partner_id", "contact_name",
"phone", "email_from", "expected_revenue", "probability",
"user_id",
],
)
if not results:
raise OdooNotFoundError(f"Lead {lead_id} not found")
lead = results[0]
return LeadResponse(
id=lead["id"],
name=lead["name"],
stage_id=lead["stage_id"][0] if lead.get("stage_id") else 0,
stage_name=lead["stage_id"][1] if lead.get("stage_id") else "",
partner_id=lead["partner_id"][0] if lead.get("partner_id") else None,
partner_name=lead["partner_id"][1] if lead.get("partner_id") else None,
contact_name=lead.get("contact_name"),
phone=lead.get("phone"),
email_from=lead.get("email_from"),
expected_revenue=lead.get("expected_revenue", 0),
probability=lead.get("probability", 0),
user_id=lead["user_id"][0] if lead.get("user_id") else None,
user_name=lead["user_id"][1] if lead.get("user_id") else None,
)
def search_by_partner(
self,
partner_id: int,
limit: int = 10,
) -> List[LeadSearchResult]:
"""Search leads by partner"""
results = self.client.search_read(
self.MODEL,
[("partner_id", "=", partner_id)],
fields=["id", "name", "stage_id", "expected_revenue", "probability"],
limit=limit,
order="create_date desc",
)
return [
LeadSearchResult(
id=r["id"],
name=r["name"],
stage_name=r["stage_id"][1] if r.get("stage_id") else "",
expected_revenue=r.get("expected_revenue", 0),
probability=r.get("probability", 0),
)
for r in results
]
def update_stage(self, lead_id: int, stage_id: int) -> bool:
"""Move lead to different stage"""
return self.client.write(self.MODEL, [lead_id], {"stage_id": stage_id})
def add_note(self, lead_id: int, note: str) -> int:
"""Add internal note to lead"""
return self.client.create("mail.message", {
"model": self.MODEL,
"res_id": lead_id,
"body": note,
"message_type": "comment",
})
def get_stages(self) -> List[dict]:
"""Get all CRM stages"""
return self.client.search_read(
"crm.stage",
[],
fields=["id", "name", "sequence"],
order="sequence",
)

View File

@@ -0,0 +1,97 @@
from typing import Optional
from app.odoo import get_odoo_client, OdooNotFoundError
from app.schemas.partner import (
PartnerCreate,
PartnerUpdate,
PartnerResponse,
PartnerSearchResult,
)
class PartnerService:
"""Service for Odoo res.partner operations"""
MODEL = "res.partner"
FIELDS = [
"id", "name", "display_name", "phone", "mobile", "email",
"street", "city", "country_id", "comment",
"credit", "debit", "credit_limit",
]
def __init__(self):
self.client = get_odoo_client()
def search_by_phone(self, phone: str) -> Optional[PartnerSearchResult]:
"""Search partner by phone number"""
normalized = phone.replace(" ", "").replace("-", "").replace("+", "")
domain = [
"|",
("phone", "ilike", normalized[-10:]),
("mobile", "ilike", normalized[-10:]),
]
results = self.client.search_read(
self.MODEL,
domain,
fields=["id", "name", "phone", "mobile", "email"],
limit=1,
)
if results:
return PartnerSearchResult(**results[0])
return None
def search_by_email(self, email: str) -> Optional[PartnerSearchResult]:
"""Search partner by email"""
results = self.client.search_read(
self.MODEL,
[("email", "=ilike", email)],
fields=["id", "name", "phone", "mobile", "email"],
limit=1,
)
if results:
return PartnerSearchResult(**results[0])
return None
def get_by_id(self, partner_id: int) -> PartnerResponse:
"""Get partner by ID"""
results = self.client.read(self.MODEL, [partner_id], self.FIELDS)
if not results:
raise OdooNotFoundError(f"Partner {partner_id} not found")
data = results[0]
if data.get("country_id") and isinstance(data["country_id"], (list, tuple)):
data["country_id"] = data["country_id"][0]
return PartnerResponse(**data)
def create(self, data: PartnerCreate) -> int:
"""Create a new partner"""
values = data.model_dump(exclude_none=True)
return self.client.create(self.MODEL, values)
def update(self, partner_id: int, data: PartnerUpdate) -> bool:
"""Update a partner"""
values = data.model_dump(exclude_none=True)
if not values:
return True
return self.client.write(self.MODEL, [partner_id], values)
def get_balance(self, partner_id: int) -> dict:
"""Get partner balance (credit/debit)"""
results = self.client.read(
self.MODEL,
[partner_id],
["credit", "debit", "credit_limit"],
)
if not results:
raise OdooNotFoundError(f"Partner {partner_id} not found")
data = results[0]
return {
"credit": data.get("credit", 0),
"debit": data.get("debit", 0),
"balance": data.get("debit", 0) - data.get("credit", 0),
"credit_limit": data.get("credit_limit", 0),
}

View File

@@ -0,0 +1,117 @@
from typing import List, Optional
from app.odoo import get_odoo_client, OdooNotFoundError
from app.schemas.product import ProductResponse, ProductSearchResult, StockInfo
class ProductService:
"""Service for Odoo product operations"""
MODEL = "product.product"
def __init__(self):
self.client = get_odoo_client()
def search(
self,
query: str = None,
category_id: int = None,
limit: int = 20,
) -> List[ProductSearchResult]:
"""Search products"""
domain = [("sale_ok", "=", True)]
if query:
domain.append("|")
domain.append(("name", "ilike", query))
domain.append(("default_code", "ilike", query))
if category_id:
domain.append(("categ_id", "=", category_id))
results = self.client.search_read(
self.MODEL,
domain,
fields=["id", "name", "default_code", "list_price", "qty_available"],
limit=limit,
)
return [ProductSearchResult(**r) for r in results]
def get_by_id(self, product_id: int) -> ProductResponse:
"""Get product details"""
results = self.client.read(
self.MODEL,
[product_id],
[
"id", "name", "default_code", "list_price",
"qty_available", "virtual_available",
"description_sale", "categ_id",
],
)
if not results:
raise OdooNotFoundError(f"Product {product_id} not found")
p = results[0]
return ProductResponse(
id=p["id"],
name=p["name"],
default_code=p.get("default_code"),
list_price=p.get("list_price", 0),
qty_available=p.get("qty_available", 0),
virtual_available=p.get("virtual_available", 0),
description=p.get("description_sale"),
categ_name=p["categ_id"][1] if p.get("categ_id") else None,
)
def get_by_sku(self, sku: str) -> Optional[ProductResponse]:
"""Get product by SKU (default_code)"""
ids = self.client.search(
self.MODEL,
[("default_code", "=", sku)],
limit=1,
)
if not ids:
return None
return self.get_by_id(ids[0])
def check_stock(self, product_id: int) -> StockInfo:
"""Get stock info for a product"""
results = self.client.read(
self.MODEL,
[product_id],
[
"id", "name", "qty_available", "virtual_available",
"incoming_qty", "outgoing_qty",
],
)
if not results:
raise OdooNotFoundError(f"Product {product_id} not found")
p = results[0]
qty_available = p.get("qty_available", 0)
virtual = p.get("virtual_available", 0)
return StockInfo(
product_id=p["id"],
product_name=p["name"],
qty_available=qty_available,
qty_reserved=max(0, qty_available - virtual),
qty_incoming=p.get("incoming_qty", 0),
qty_outgoing=p.get("outgoing_qty", 0),
virtual_available=virtual,
)
def check_availability(self, product_id: int, quantity: float) -> dict:
"""Check if quantity is available"""
stock = self.check_stock(product_id)
available = stock.virtual_available >= quantity
return {
"available": available,
"requested": quantity,
"in_stock": stock.qty_available,
"virtual_available": stock.virtual_available,
"shortage": max(0, quantity - stock.virtual_available),
}

View File

@@ -0,0 +1,128 @@
from typing import List, Optional
from app.odoo import get_odoo_client, OdooNotFoundError
from app.schemas.sale import (
SaleOrderResponse,
SaleOrderSearchResult,
SaleOrderLine,
QuotationCreate,
)
STATE_DISPLAY = {
"draft": "Presupuesto",
"sent": "Presupuesto Enviado",
"sale": "Pedido de Venta",
"done": "Bloqueado",
"cancel": "Cancelado",
}
class SaleOrderService:
"""Service for Odoo sale.order operations"""
MODEL = "sale.order"
LINE_MODEL = "sale.order.line"
def __init__(self):
self.client = get_odoo_client()
def search_by_partner(
self,
partner_id: int,
state: str = None,
limit: int = 10,
) -> List[SaleOrderSearchResult]:
"""Search orders by partner"""
domain = [("partner_id", "=", partner_id)]
if state:
domain.append(("state", "=", state))
results = self.client.search_read(
self.MODEL,
domain,
fields=["id", "name", "state", "date_order", "amount_total"],
limit=limit,
order="date_order desc",
)
return [SaleOrderSearchResult(**r) for r in results]
def get_by_id(self, order_id: int) -> SaleOrderResponse:
"""Get order details"""
results = self.client.read(
self.MODEL,
[order_id],
[
"id", "name", "state", "partner_id", "date_order",
"amount_total", "amount_untaxed", "amount_tax",
"currency_id", "order_line",
],
)
if not results:
raise OdooNotFoundError(f"Sale order {order_id} not found")
order = results[0]
lines = []
if order.get("order_line"):
line_data = self.client.read(
self.LINE_MODEL,
order["order_line"],
["id", "product_id", "product_uom_qty", "price_unit", "price_subtotal"],
)
for line in line_data:
lines.append(SaleOrderLine(
id=line["id"],
product_id=line["product_id"][0] if line.get("product_id") else 0,
product_name=line["product_id"][1] if line.get("product_id") else "",
quantity=line.get("product_uom_qty", 0),
price_unit=line.get("price_unit", 0),
price_subtotal=line.get("price_subtotal", 0),
))
return SaleOrderResponse(
id=order["id"],
name=order["name"],
state=order["state"],
state_display=STATE_DISPLAY.get(order["state"], order["state"]),
partner_id=order["partner_id"][0] if order.get("partner_id") else 0,
partner_name=order["partner_id"][1] if order.get("partner_id") else "",
date_order=order.get("date_order"),
amount_total=order.get("amount_total", 0),
amount_untaxed=order.get("amount_untaxed", 0),
amount_tax=order.get("amount_tax", 0),
currency=order["currency_id"][1] if order.get("currency_id") else "USD",
order_lines=lines,
)
def get_by_name(self, name: str) -> Optional[SaleOrderResponse]:
"""Get order by name (SO001)"""
ids = self.client.search(self.MODEL, [("name", "=", name)], limit=1)
if not ids:
return None
return self.get_by_id(ids[0])
def create_quotation(self, data: QuotationCreate) -> int:
"""Create a quotation"""
order_id = self.client.create(self.MODEL, {
"partner_id": data.partner_id,
"note": data.note,
})
for line in data.lines:
self.client.create(self.LINE_MODEL, {
"order_id": order_id,
"product_id": line["product_id"],
"product_uom_qty": line.get("quantity", 1),
})
return order_id
def confirm_order(self, order_id: int) -> bool:
"""Confirm quotation to sale order"""
return self.client.execute(self.MODEL, "action_confirm", [order_id])
def get_pdf_url(self, order_id: int) -> str:
"""Get URL to download order PDF"""
return f"{self.client.url}/report/pdf/sale.report_saleorder/{order_id}"

View File

@@ -0,0 +1,87 @@
from typing import Optional
import httpx
from app.config import get_settings
from app.services.partner import PartnerService
from app.schemas.partner import PartnerCreate
settings = get_settings()
class ContactSyncService:
"""Sync contacts between WhatsApp Central and Odoo"""
def __init__(self):
self.partner_service = PartnerService()
async def sync_contact_to_odoo(
self,
contact_id: str,
phone: str,
name: str = None,
email: str = None,
) -> Optional[int]:
"""
Sync a WhatsApp contact to Odoo.
Returns Odoo partner_id.
"""
# Check if partner exists
partner = self.partner_service.search_by_phone(phone)
if partner:
return partner.id
# Create new partner
data = PartnerCreate(
name=name or phone,
mobile=phone,
email=email,
)
partner_id = self.partner_service.create(data)
# Update contact in API Gateway with odoo_partner_id
await self._update_contact_odoo_id(contact_id, partner_id)
return partner_id
async def _update_contact_odoo_id(self, contact_id: str, odoo_id: int):
"""Update contact's odoo_partner_id in API Gateway"""
try:
async with httpx.AsyncClient() as client:
await client.patch(
f"{settings.API_GATEWAY_URL}/api/internal/contacts/{contact_id}",
json={"odoo_partner_id": odoo_id},
timeout=10,
)
except Exception as e:
print(f"Failed to update contact odoo_id: {e}")
async def sync_partner_to_contact(self, partner_id: int) -> Optional[str]:
"""
Sync Odoo partner to WhatsApp contact.
Returns contact_id if found.
"""
partner = self.partner_service.get_by_id(partner_id)
if not partner.phone and not partner.mobile:
return None
phone = partner.mobile or partner.phone
# Search contact in API Gateway
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.API_GATEWAY_URL}/api/internal/contacts/search",
params={"phone": phone},
timeout=10,
)
if response.status_code == 200:
contact = response.json()
if not contact.get("odoo_partner_id"):
await self._update_contact_odoo_id(contact["id"], partner_id)
return contact["id"]
except Exception as e:
print(f"Failed to search contact: {e}")
return None

View File

@@ -0,0 +1,6 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
httpx==0.26.0
python-multipart==0.0.6

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;
}