Compare commits
42 Commits
24850e23f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd3499097 | ||
|
|
1040debe2e | ||
|
|
28592254b2 | ||
|
|
0c5fe0e3bb | ||
|
|
f305f6495a | ||
|
|
ece4398807 | ||
|
|
a5914a164b | ||
|
|
3c36701abc | ||
|
|
4877dc23d7 | ||
|
|
626236c6dd | ||
|
|
357987844e | ||
|
|
de48f0177a | ||
|
|
9b27eddb5f | ||
|
|
b747b6c49a | ||
|
|
991b8ddfe8 | ||
|
|
e10d67b19d | ||
|
|
0c2e2f1b7a | ||
|
|
48db1a94f7 | ||
|
|
f1933cf0d0 | ||
|
|
ad218ecccf | ||
|
|
5f61a815e5 | ||
|
|
13dedaf48d | ||
|
|
cf424b1f37 | ||
|
|
c8c6deb4de | ||
|
|
e85c9c10b5 | ||
|
|
218c137564 | ||
|
|
1074bf6739 | ||
|
|
7551a3d8b7 | ||
|
|
87d59ca433 | ||
|
|
1c01acd168 | ||
|
|
2820ffc3cf | ||
|
|
d1d1aa58e1 | ||
|
|
619b291f49 | ||
|
|
95cd70af1f | ||
|
|
4b15abcbfb | ||
|
|
c81fac788d | ||
|
|
63d4409c00 | ||
|
|
a40811b4a1 | ||
|
|
d2ce86bd41 | ||
|
|
c50459755a | ||
|
|
918b573de3 | ||
|
|
e24bc20070 |
@@ -45,12 +45,11 @@ NODE_ENV=production
|
|||||||
WS_PORT=3001
|
WS_PORT=3001
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Odoo (Opcional)
|
# Odoo Integration
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Configurar después de instalar
|
ODOO_URL=https://tu-empresa.odoo.com
|
||||||
ODOO_URL=https://odoo.tuempresa.com
|
ODOO_DB=nombre_base_datos
|
||||||
ODOO_DB=production
|
ODOO_USER=usuario@empresa.com
|
||||||
ODOO_USER=api-whatsapp@tuempresa.com
|
|
||||||
ODOO_API_KEY=
|
ODOO_API_KEY=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -105,12 +105,13 @@ docker-compose.override.yml
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Sessions / Data
|
# Sessions / Data
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
sessions/
|
/sessions/
|
||||||
*.session
|
*.session
|
||||||
*.session.json
|
*.session.json
|
||||||
|
|
||||||
# WhatsApp Baileys sessions
|
# WhatsApp Baileys sessions (data, not source code)
|
||||||
services/whatsapp-core/sessions/
|
services/whatsapp-core/sessions/
|
||||||
|
!services/whatsapp-core/src/sessions/
|
||||||
auth_info*/
|
auth_info*/
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
233
README.md
233
README.md
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
Plataforma de mensajería centralizada con automatización de chatbots, gestión multi-agente e integración profunda con Odoo.
|
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
|
## Descripción
|
||||||
|
|
||||||
WhatsApp Centralizado es una solución empresarial similar a Kommo, Wasapi, ManyChat y Brevo, diseñada para:
|
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.)
|
- **Integrar con Odoo** de forma bidireccional (CRM, Ventas, Inventario, Helpdesk, etc.)
|
||||||
- **Conectar múltiples números** de WhatsApp desde una sola plataforma
|
- **Conectar múltiples números** de WhatsApp desde una sola plataforma
|
||||||
|
|
||||||
## Características Principales
|
## Características Implementadas
|
||||||
|
|
||||||
### Flow Builder Visual
|
### Flow Builder Visual
|
||||||
- 30+ tipos de nodos (mensajes, lógica, validación, acciones)
|
- 30+ tipos de nodos (mensajes, lógica, validación, acciones)
|
||||||
- Editor drag & drop con React Flow
|
- Editor drag & drop con React Flow
|
||||||
- Variables y contexto de conversación
|
- Variables globales y contexto de conversación
|
||||||
- A/B Testing integrado
|
- A/B Testing integrado
|
||||||
- Integración con IA (GPT, Claude, Ollama)
|
- Integración con IA (DeepSeek)
|
||||||
- Sub-flujos reutilizables
|
- Sub-flujos reutilizables
|
||||||
|
- Plantillas predefinidas
|
||||||
|
|
||||||
### Gestión Multi-Agente
|
### Gestión Multi-Agente
|
||||||
- Sistema de colas (Ventas, Soporte, etc.)
|
- 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
|
- Transferencia bot → humano → bot
|
||||||
- Panel de supervisor en tiempo real
|
- Panel de supervisor en tiempo real
|
||||||
- SLA tracking con alertas
|
- SLA tracking con alertas
|
||||||
- Encuestas CSAT integradas
|
- Notas internas y respuestas rápidas
|
||||||
|
|
||||||
### Integración Odoo
|
### Integración Odoo (Fase 5)
|
||||||
- Conexión bidireccional via XML-RPC
|
- Conexión via XML-RPC con autenticación
|
||||||
- 8 módulos soportados (Contactos, CRM, Ventas, Inventario, Helpdesk, Facturación, Calendario, Productos)
|
- Sincronización bidireccional de contactos
|
||||||
- 20+ acciones disponibles en flujos
|
- 8 nodos de flujo para Odoo:
|
||||||
- Automatizaciones Odoo → WhatsApp
|
- Buscar/Crear Partner
|
||||||
- Módulo Odoo con widget de chat
|
- 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
|
## Stack Tecnológico
|
||||||
|
|
||||||
| Componente | Tecnología |
|
| Componente | Tecnología | Puerto |
|
||||||
|------------|------------|
|
|------------|------------|--------|
|
||||||
| WhatsApp Core | Node.js + TypeScript + Baileys |
|
| WhatsApp Core | Node.js + TypeScript + Baileys | 3001 |
|
||||||
| API Gateway | Python + FastAPI |
|
| API Gateway | Python + FastAPI | 8000 |
|
||||||
| Flow Engine | Python |
|
| Flow Engine | Python + FastAPI | 8001 |
|
||||||
| Frontend | React + TypeScript |
|
| Integrations | Python + FastAPI | 8002 |
|
||||||
| Base de Datos | PostgreSQL + Redis |
|
| Frontend | React + TypeScript + Vite | 3000 |
|
||||||
| Despliegue | Docker + Docker Compose |
|
| Base de Datos | PostgreSQL 16 | 5432 |
|
||||||
|
| Cache/PubSub | Redis 7 | 6379 |
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ FRONTEND (React) │
|
│ FRONTEND (React) │
|
||||||
│ Dashboard │ Inbox Chat │ Flow Builder (React Flow) │
|
│ Dashboard │ Inbox Chat │ Flow Builder │ Odoo Config │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ API GATEWAY (FastAPI) │
|
│ 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 │
|
│ WHATSAPP CORE │ │ FLOW ENGINE │ │ INTEGRATIONS │
|
||||||
│ (Node.js) │ │ (Python) │ │ (Python) │
|
│ (Node.js) │ │ (Python) │ │ (Python) │
|
||||||
│ Baileys │ │ Motor bot │ │ Odoo, Webhooks │
|
│ Baileys │ │ 30+ Nodos │ │ Odoo XML-RPC │
|
||||||
|
│ Multi-número │ │ AI Response │ │ Webhooks │
|
||||||
└──────────────────┘ └──────────────┘ └──────────────────────────┘
|
└──────────────────┘ └──────────────┘ └──────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────┼───────────────┘
|
||||||
│
|
│
|
||||||
┌─────────┴─────────┐
|
┌─────────┴─────────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌──────────┐ ┌──────────┐
|
┌──────────┐ ┌──────────┐
|
||||||
│PostgreSQL│ │ Redis │
|
│PostgreSQL│ │ Redis │
|
||||||
|
│ :5432 │ │ :6379 │
|
||||||
└──────────┘ └──────────┘
|
└──────────┘ └──────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ ODOO 19 │
|
||||||
|
│ odoo_whatsapp_hub│
|
||||||
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inicio Rápido
|
## Instalación
|
||||||
|
|
||||||
### Requisitos
|
### Requisitos
|
||||||
- Docker 24.0+
|
- Docker 24.0+
|
||||||
- Docker Compose 2.20+
|
- Docker Compose 2.20+
|
||||||
- 4GB RAM mínimo (8GB recomendado)
|
- 4GB RAM mínimo (8GB recomendado)
|
||||||
|
- Odoo 19 (para el módulo)
|
||||||
|
|
||||||
### Instalación
|
### Instalación Rápida
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clonar repositorio
|
# 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
|
cd WhatsAppCentralizado
|
||||||
|
|
||||||
# Copiar configuración
|
# Copiar configuración
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Editar variables de entorno
|
# Editar variables de entorno (ver sección Configuración)
|
||||||
nano .env
|
nano .env
|
||||||
|
|
||||||
# Iniciar servicios
|
# Iniciar servicios
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Aplicar migraciones
|
# Ver logs
|
||||||
docker-compose exec api-gateway alembic upgrade head
|
docker-compose logs -f
|
||||||
|
|
||||||
# Crear usuario admin
|
# Aplicar migraciones de base de datos
|
||||||
docker-compose exec api-gateway python scripts/create_admin.py
|
docker-compose exec api-gateway alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### Acceso
|
## Configuración
|
||||||
- Frontend: http://localhost:3000
|
|
||||||
- API: http://localhost:8000
|
### Variables de Entorno Principales
|
||||||
- Docs API: http://localhost:8000/docs
|
|
||||||
|
```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
|
## Documentación
|
||||||
|
|
||||||
- [Diseño del Sistema](docs/plans/2026-01-29-whatsapp-centralizado-design.md)
|
- [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)
|
- [API Reference](docs/api/README.md)
|
||||||
- [Flow Builder](docs/flow-builder/README.md)
|
- [Flow Builder](docs/flow-builder/README.md)
|
||||||
- [Integración Odoo](docs/odoo-integration/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)
|
- [Guía de Despliegue](docs/deployment/README.md)
|
||||||
|
|
||||||
## Roadmap
|
## Servicios Docker
|
||||||
|
|
||||||
- [x] Diseño y arquitectura
|
```bash
|
||||||
- [ ] Fase 1: Fundación (WhatsApp Core + API + Frontend básico)
|
# Ver estado de servicios
|
||||||
- [ ] Fase 2: Flow Engine Básico
|
docker-compose ps
|
||||||
- [ ] Fase 3: Inbox Avanzado + Multi-agente
|
|
||||||
- [ ] Fase 4: Flow Engine Avanzado
|
# Reiniciar un servicio
|
||||||
- [ ] Fase 5: Integración Odoo Completa
|
docker-compose restart api-gateway
|
||||||
- [ ] Fase 6: Módulo Odoo
|
|
||||||
- [ ] Fase 7: Reportes y Analytics
|
# Ver logs de un servicio
|
||||||
- [ ] Fase 8: Multi-canal (Email, SMS)
|
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
|
## Licencia
|
||||||
|
|
||||||
Propietario - Todos los derechos reservados.
|
Propietario - Todos los derechos reservados.
|
||||||
|
Desarrollado por Consultoria AS.
|
||||||
|
|
||||||
## Contacto
|
## Contacto
|
||||||
|
|
||||||
Desarrollado para uso interno empresarial.
|
- Repositorio: https://git.consultoria-as.com/consultoria-as/WhatsAppCentralizado
|
||||||
|
- Desarrollado para uso interno empresarial.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ services:
|
|||||||
WS_PORT: 3001
|
WS_PORT: 3001
|
||||||
volumes:
|
volumes:
|
||||||
- whatsapp_sessions:/app/sessions
|
- whatsapp_sessions:/app/sessions
|
||||||
|
- whatsapp_media:/app/media
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -70,6 +71,9 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||||
WHATSAPP_CORE_URL: http://whatsapp-core:3001
|
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}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -95,6 +99,7 @@ services:
|
|||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
||||||
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
||||||
|
INTEGRATIONS_URL: http://integrations:8002
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -103,6 +108,24 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- wac_network
|
- 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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -120,6 +143,7 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
whatsapp_sessions:
|
whatsapp_sessions:
|
||||||
|
whatsapp_media:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
wac_network:
|
wac_network:
|
||||||
|
|||||||
344
docs/CONTEXTO_DESARROLLO.md
Normal file
344
docs/CONTEXTO_DESARROLLO.md
Normal 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
245
docs/odoo-module-install.md
Normal 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
|
||||||
2052
docs/plans/2026-01-29-fase-6-modulo-odoo.md
Normal file
2052
docs/plans/2026-01-29-fase-6-modulo-odoo.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ FROM node:20-alpine as builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -8,21 +8,23 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api {
|
location /api/ {
|
||||||
|
rewrite ^/api/(.*) /$1 break;
|
||||||
proxy_pass http://api-gateway:8000;
|
proxy_pass http://api-gateway:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /auth {
|
|
||||||
proxy_pass http://api-gateway:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://whatsapp-core:3001;
|
proxy_pass http://whatsapp-core:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
|
ApiOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import Dashboard from '../pages/Dashboard';
|
import Dashboard from '../pages/Dashboard';
|
||||||
@@ -26,6 +27,7 @@ import FlowTemplates from '../pages/FlowTemplates';
|
|||||||
import GlobalVariables from '../pages/GlobalVariables';
|
import GlobalVariables from '../pages/GlobalVariables';
|
||||||
import Queues from '../pages/Queues';
|
import Queues from '../pages/Queues';
|
||||||
import SupervisorDashboard from '../pages/SupervisorDashboard';
|
import SupervisorDashboard from '../pages/SupervisorDashboard';
|
||||||
|
import OdooConfig from '../pages/OdooConfig';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -82,6 +84,11 @@ export default function MainLayout() {
|
|||||||
icon: <BarChartOutlined />,
|
icon: <BarChartOutlined />,
|
||||||
label: 'Supervisor',
|
label: 'Supervisor',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/odoo',
|
||||||
|
icon: <ApiOutlined />,
|
||||||
|
label: 'Odoo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/settings',
|
key: '/settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
@@ -194,6 +201,7 @@ export default function MainLayout() {
|
|||||||
<Route path="/variables" element={<GlobalVariables />} />
|
<Route path="/variables" element={<GlobalVariables />} />
|
||||||
<Route path="/queues" element={<Queues />} />
|
<Route path="/queues" element={<Queues />} />
|
||||||
<Route path="/supervisor" element={<SupervisorDashboard />} />
|
<Route path="/supervisor" element={<SupervisorDashboard />} />
|
||||||
|
<Route path="/odoo" element={<OdooConfig />} />
|
||||||
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
|
<Route path="/settings" element={<div>Configuración (próximamente)</div>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -154,6 +154,54 @@ const AISentimentNode = () => (
|
|||||||
</div>
|
</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 = {
|
const nodeTypes: NodeTypes = {
|
||||||
trigger: TriggerNode,
|
trigger: TriggerNode,
|
||||||
message: MessageNode,
|
message: MessageNode,
|
||||||
@@ -176,6 +224,14 @@ const nodeTypes: NodeTypes = {
|
|||||||
http_request: HttpRequestNode,
|
http_request: HttpRequestNode,
|
||||||
ai_response: AIResponseNode,
|
ai_response: AIResponseNode,
|
||||||
ai_sentiment: AISentimentNode,
|
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 {
|
interface Flow {
|
||||||
@@ -306,6 +362,22 @@ export default function FlowBuilder() {
|
|||||||
>
|
>
|
||||||
<Button>+ Avanzados</Button>
|
<Button>+ Avanzados</Button>
|
||||||
</Dropdown>
|
</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
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined />}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface Message {
|
|||||||
direction: 'inbound' | 'outbound';
|
direction: 'inbound' | 'outbound';
|
||||||
type: string;
|
type: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
|
media_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_internal_note: boolean;
|
is_internal_note: boolean;
|
||||||
sent_by: string | null;
|
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 {
|
function renderMessage(msg: Message): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -391,7 +449,7 @@ export default function Inbox(): JSX.Element {
|
|||||||
<FileTextOutlined /> Nota interna
|
<FileTextOutlined /> Nota interna
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
|
{renderMessageContent(msg)}
|
||||||
</div>
|
</div>
|
||||||
<Text
|
<Text
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|||||||
182
frontend/src/pages/OdooConfig.tsx
Normal file
182
frontend/src/pages/OdooConfig.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
@@ -28,7 +30,7 @@ interface WhatsAppAccount {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone_number: string | null;
|
phone_number: string | null;
|
||||||
status: 'connecting' | 'connected' | 'disconnected';
|
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
|
||||||
qr_code: string | null;
|
qr_code: string | null;
|
||||||
created_at: string;
|
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 handleShowQR = async (account: WhatsAppAccount) => {
|
||||||
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
|
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
|
||||||
setQrModal(data);
|
setQrModal(data);
|
||||||
@@ -102,13 +130,15 @@ export default function WhatsAppAccounts() {
|
|||||||
connected: 'green',
|
connected: 'green',
|
||||||
connecting: 'orange',
|
connecting: 'orange',
|
||||||
disconnected: 'red',
|
disconnected: 'red',
|
||||||
|
paused: 'gold',
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
connected: 'Conectado',
|
connected: 'Conectado',
|
||||||
connecting: 'Conectando',
|
connecting: 'Conectando',
|
||||||
disconnected: 'Desconectado',
|
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',
|
key: 'actions',
|
||||||
render: (_: any, record: WhatsAppAccount) => (
|
render: (_: any, record: WhatsAppAccount) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.status !== 'connected' && (
|
{record.status !== 'connected' && record.status !== 'paused' && (
|
||||||
<Button
|
<Button
|
||||||
icon={<QrcodeOutlined />}
|
icon={<QrcodeOutlined />}
|
||||||
onClick={() => handleShowQR(record)}
|
onClick={() => handleShowQR(record)}
|
||||||
@@ -124,6 +154,33 @@ export default function WhatsAppAccounts() {
|
|||||||
Ver QR
|
Ver QR
|
||||||
</Button>
|
</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
|
<Button
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
4
odoo_whatsapp_hub/__init__.py
Normal file
4
odoo_whatsapp_hub/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import wizards
|
||||||
|
from .hooks import post_init_hook
|
||||||
42
odoo_whatsapp_hub/__manifest__.py
Normal file
42
odoo_whatsapp_hub/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
1
odoo_whatsapp_hub/controllers/__init__.py
Normal file
1
odoo_whatsapp_hub/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import webhook
|
||||||
144
odoo_whatsapp_hub/controllers/webhook.py
Normal file
144
odoo_whatsapp_hub/controllers/webhook.py
Normal 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'})
|
||||||
11
odoo_whatsapp_hub/data/whatsapp_data.xml
Normal file
11
odoo_whatsapp_hub/data/whatsapp_data.xml
Normal 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>
|
||||||
59
odoo_whatsapp_hub/hooks.py
Normal file
59
odoo_whatsapp_hub/hooks.py
Normal 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,
|
||||||
|
})
|
||||||
4
odoo_whatsapp_hub/models/__init__.py
Normal file
4
odoo_whatsapp_hub/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from . import res_partner
|
||||||
|
from . import whatsapp_account
|
||||||
|
from . import whatsapp_conversation
|
||||||
|
from . import whatsapp_message
|
||||||
116
odoo_whatsapp_hub/models/res_partner.py
Normal file
116
odoo_whatsapp_hub/models/res_partner.py
Normal 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()
|
||||||
110
odoo_whatsapp_hub/models/whatsapp_account.py
Normal file
110
odoo_whatsapp_hub/models/whatsapp_account.py
Normal 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)
|
||||||
214
odoo_whatsapp_hub/models/whatsapp_conversation.py
Normal file
214
odoo_whatsapp_hub/models/whatsapp_conversation.py
Normal 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
|
||||||
124
odoo_whatsapp_hub/models/whatsapp_message.py
Normal file
124
odoo_whatsapp_hub/models/whatsapp_message.py
Normal 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
|
||||||
8
odoo_whatsapp_hub/security/ir.model.access.csv
Normal file
8
odoo_whatsapp_hub/security/ir.model.access.csv
Normal 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
|
||||||
|
0
odoo_whatsapp_hub/static/description/.gitkeep
Normal file
0
odoo_whatsapp_hub/static/description/.gitkeep
Normal file
774
odoo_whatsapp_hub/static/src/css/dollars_theme.css
Normal file
774
odoo_whatsapp_hub/static/src/css/dollars_theme.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
377
odoo_whatsapp_hub/static/src/css/whatsapp.css
Normal file
377
odoo_whatsapp_hub/static/src/css/whatsapp.css
Normal 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;
|
||||||
|
}
|
||||||
232
odoo_whatsapp_hub/static/src/js/chat_action.js
Normal file
232
odoo_whatsapp_hub/static/src/js/chat_action.js
Normal 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);
|
||||||
120
odoo_whatsapp_hub/static/src/js/chat_widget.js
Normal file
120
odoo_whatsapp_hub/static/src/js/chat_widget.js
Normal 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);
|
||||||
254
odoo_whatsapp_hub/static/src/js/dollars_chat.js
Normal file
254
odoo_whatsapp_hub/static/src/js/dollars_chat.js
Normal 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);
|
||||||
138
odoo_whatsapp_hub/static/src/xml/chat_template.xml
Normal file
138
odoo_whatsapp_hub/static/src/xml/chat_template.xml
Normal 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>
|
||||||
87
odoo_whatsapp_hub/static/src/xml/chat_widget.xml
Normal file
87
odoo_whatsapp_hub/static/src/xml/chat_widget.xml
Normal 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>
|
||||||
282
odoo_whatsapp_hub/static/src/xml/dollars_template.xml
Normal file
282
odoo_whatsapp_hub/static/src/xml/dollars_template.xml
Normal 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>
|
||||||
9
odoo_whatsapp_hub/views/dollars_action.xml
Normal file
9
odoo_whatsapp_hub/views/dollars_action.xml
Normal 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>
|
||||||
58
odoo_whatsapp_hub/views/res_partner_views.xml
Normal file
58
odoo_whatsapp_hub/views/res_partner_views.xml
Normal 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>
|
||||||
63
odoo_whatsapp_hub/views/whatsapp_account_views.xml
Normal file
63
odoo_whatsapp_hub/views/whatsapp_account_views.xml
Normal 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>
|
||||||
70
odoo_whatsapp_hub/views/whatsapp_conversation_views.xml
Normal file
70
odoo_whatsapp_hub/views/whatsapp_conversation_views.xml
Normal 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>
|
||||||
53
odoo_whatsapp_hub/views/whatsapp_menu.xml
Normal file
53
odoo_whatsapp_hub/views/whatsapp_menu.xml
Normal 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>
|
||||||
2
odoo_whatsapp_hub/wizards/__init__.py
Normal file
2
odoo_whatsapp_hub/wizards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import send_whatsapp
|
||||||
|
from . import mass_whatsapp
|
||||||
95
odoo_whatsapp_hub/wizards/mass_whatsapp.py
Normal file
95
odoo_whatsapp_hub/wizards/mass_whatsapp.py
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
78
odoo_whatsapp_hub/wizards/send_whatsapp.py
Normal file
78
odoo_whatsapp_hub/wizards/send_whatsapp.py
Normal 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'}
|
||||||
77
odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml
Normal file
77
odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml
Normal 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í... 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
BIN
odoo_whatsapp_hub_v2.zip
Normal file
Binary file not shown.
220
qr-realtime.html
Normal file
220
qr-realtime.html
Normal 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
83
qr-viewer.html
Normal 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>
|
||||||
@@ -17,10 +17,17 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# WhatsApp Core
|
# WhatsApp Core
|
||||||
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
||||||
|
WHATSAPP_CORE_PUBLIC_URL: str = "http://localhost:3001" # URL accessible from browser
|
||||||
|
|
||||||
# Flow Engine
|
# Flow Engine
|
||||||
FLOW_ENGINE_URL: str = "http://localhost:8001"
|
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
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import engine, Base
|
from app.core.database import engine, Base
|
||||||
from app.routers import auth, whatsapp, flows, queues, supervisor, flow_templates, global_variables
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ app.include_router(queues.router)
|
|||||||
app.include_router(supervisor.router)
|
app.include_router(supervisor.router)
|
||||||
app.include_router(flow_templates.router)
|
app.include_router(flow_templates.router)
|
||||||
app.include_router(global_variables.router)
|
app.include_router(global_variables.router)
|
||||||
|
app.include_router(integrations_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.queue import Queue, QueueAgent, AssignmentMethod
|
|||||||
from app.models.quick_reply import QuickReply
|
from app.models.quick_reply import QuickReply
|
||||||
from app.models.global_variable import GlobalVariable
|
from app.models.global_variable import GlobalVariable
|
||||||
from app.models.flow_template import FlowTemplate
|
from app.models.flow_template import FlowTemplate
|
||||||
|
from app.models.odoo_config import OdooConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"QuickReply",
|
"QuickReply",
|
||||||
"GlobalVariable",
|
"GlobalVariable",
|
||||||
"FlowTemplate",
|
"FlowTemplate",
|
||||||
|
"OdooConfig",
|
||||||
]
|
]
|
||||||
|
|||||||
19
services/api-gateway/app/models/odoo_config.py
Normal file
19
services/api-gateway/app/models/odoo_config.py
Normal 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)
|
||||||
@@ -68,7 +68,7 @@ class Contact(Base):
|
|||||||
name = Column(String(100), nullable=True)
|
name = Column(String(100), nullable=True)
|
||||||
email = Column(String(255), nullable=True)
|
email = Column(String(255), nullable=True)
|
||||||
company = Column(String(100), 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)
|
tags = Column(ARRAY(String), default=list)
|
||||||
odoo_partner_id = Column(Integer, nullable=True)
|
odoo_partner_id = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
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)
|
type = Column(SQLEnum(MessageType), default=MessageType.TEXT, nullable=False)
|
||||||
content = Column(Text, nullable=True)
|
content = Column(Text, nullable=True)
|
||||||
media_url = Column(String(500), 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)
|
sent_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
is_internal_note = Column(Boolean, default=False, nullable=False)
|
is_internal_note = Column(Boolean, default=False, nullable=False)
|
||||||
status = Column(SQLEnum(MessageStatus), default=MessageStatus.PENDING, nullable=False)
|
status = Column(SQLEnum(MessageStatus), default=MessageStatus.PENDING, nullable=False)
|
||||||
|
|||||||
92
services/api-gateway/app/routers/integrations.py
Normal file
92
services/api-gateway/app/routers/integrations.py
Normal 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"}
|
||||||
@@ -62,11 +62,29 @@ async def create_account(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
|
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
|
||||||
def list_accounts(
|
async def list_accounts(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
accounts = db.query(WhatsAppAccount).all()
|
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
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
@@ -122,6 +140,63 @@ async def delete_account(
|
|||||||
return {"success": True}
|
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])
|
@router.get("/conversations", response_model=List[ConversationResponse])
|
||||||
def list_conversations(
|
def list_conversations(
|
||||||
status: ConversationStatus = None,
|
status: ConversationStatus = None,
|
||||||
@@ -228,6 +303,7 @@ async def handle_whatsapp_event(
|
|||||||
elif event.type == "message":
|
elif event.type == "message":
|
||||||
msg_data = event.data
|
msg_data = event.data
|
||||||
phone = msg_data.get("from", "").split("@")[0]
|
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()
|
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
|
||||||
if not contact:
|
if not contact:
|
||||||
@@ -256,19 +332,47 @@ async def handle_whatsapp_event(
|
|||||||
db.refresh(conversation)
|
db.refresh(conversation)
|
||||||
|
|
||||||
wa_message = msg_data.get("message", {})
|
wa_message = msg_data.get("message", {})
|
||||||
|
media_url = msg_data.get("mediaUrl")
|
||||||
|
media_type = msg_data.get("mediaType", "text")
|
||||||
|
|
||||||
|
# Extract text content
|
||||||
content = (
|
content = (
|
||||||
wa_message.get("conversation") or
|
wa_message.get("conversation") or
|
||||||
wa_message.get("extendedTextMessage", {}).get("text") 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(
|
message = Message(
|
||||||
conversation_id=conversation.id,
|
conversation_id=conversation.id,
|
||||||
whatsapp_message_id=msg_data.get("id"),
|
whatsapp_message_id=msg_data.get("id"),
|
||||||
direction=MessageDirection.INBOUND,
|
direction=direction,
|
||||||
type=MessageType.TEXT,
|
type=msg_type,
|
||||||
content=content,
|
content=content if content else f"[{media_type.capitalize()}]",
|
||||||
status=MessageStatus.DELIVERED,
|
media_url=full_media_url,
|
||||||
|
status=MessageStatus.DELIVERED if not is_from_me else MessageStatus.SENT,
|
||||||
)
|
)
|
||||||
db.add(message)
|
db.add(message)
|
||||||
|
|
||||||
@@ -277,8 +381,8 @@ async def handle_whatsapp_event(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
|
|
||||||
# Process message through Flow Engine (if in BOT status)
|
# Process message through Flow Engine (only for inbound messages in BOT status)
|
||||||
if conversation.status == ConversationStatus.BOT:
|
if not is_from_me and conversation.status == ConversationStatus.BOT:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -305,8 +409,53 @@ async def handle_whatsapp_event(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Flow engine error: {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"}
|
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()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@@ -448,3 +597,183 @@ def add_internal_note(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
return {"success": True, "message_id": str(message.id)}
|
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
|
||||||
|
]
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ sqlalchemy==2.0.36
|
|||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pydantic==2.10.4
|
pydantic[email]==2.10.4
|
||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.7.1
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
API_GATEWAY_URL: str = "http://localhost:8000"
|
API_GATEWAY_URL: str = "http://localhost:8000"
|
||||||
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
||||||
|
INTEGRATIONS_URL: str = "http://localhost:8002"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ from app.nodes.validation import (
|
|||||||
)
|
)
|
||||||
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
|
from app.nodes.script import JavaScriptExecutor, HttpRequestExecutor
|
||||||
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
|
from app.nodes.ai import AIResponseExecutor, AISentimentExecutor
|
||||||
|
from app.nodes.odoo import (
|
||||||
|
OdooSearchPartnerExecutor,
|
||||||
|
OdooCreatePartnerExecutor,
|
||||||
|
OdooGetBalanceExecutor,
|
||||||
|
OdooSearchOrdersExecutor,
|
||||||
|
OdooGetOrderExecutor,
|
||||||
|
OdooSearchProductsExecutor,
|
||||||
|
OdooCheckStockExecutor,
|
||||||
|
OdooCreateLeadExecutor,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -60,6 +70,14 @@ def _register_executors():
|
|||||||
NodeRegistry.register("http_request", HttpRequestExecutor())
|
NodeRegistry.register("http_request", HttpRequestExecutor())
|
||||||
NodeRegistry.register("ai_response", AIResponseExecutor())
|
NodeRegistry.register("ai_response", AIResponseExecutor())
|
||||||
NodeRegistry.register("ai_sentiment", AISentimentExecutor())
|
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()
|
_register_executors()
|
||||||
|
|||||||
@@ -24,3 +24,13 @@ from app.nodes.validation import (
|
|||||||
ValidatePhoneExecutor,
|
ValidatePhoneExecutor,
|
||||||
ValidateRegexExecutor,
|
ValidateRegexExecutor,
|
||||||
)
|
)
|
||||||
|
from app.nodes.odoo import (
|
||||||
|
OdooSearchPartnerExecutor,
|
||||||
|
OdooCreatePartnerExecutor,
|
||||||
|
OdooGetBalanceExecutor,
|
||||||
|
OdooSearchOrdersExecutor,
|
||||||
|
OdooGetOrderExecutor,
|
||||||
|
OdooSearchProductsExecutor,
|
||||||
|
OdooCheckStockExecutor,
|
||||||
|
OdooCreateLeadExecutor,
|
||||||
|
)
|
||||||
|
|||||||
272
services/flow-engine/app/nodes/odoo.py
Normal file
272
services/flow-engine/app/nodes/odoo.py
Normal 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"
|
||||||
10
services/integrations/Dockerfile
Normal file
10
services/integrations/Dockerfile
Normal 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"]
|
||||||
1
services/integrations/app/__init__.py
Normal file
1
services/integrations/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Integrations Service
|
||||||
22
services/integrations/app/config.py
Normal file
22
services/integrations/app/config.py
Normal 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()
|
||||||
22
services/integrations/app/main.py
Normal file
22
services/integrations/app/main.py
Normal 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"}
|
||||||
18
services/integrations/app/odoo/__init__.py
Normal file
18
services/integrations/app/odoo/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
167
services/integrations/app/odoo/client.py
Normal file
167
services/integrations/app/odoo/client.py
Normal 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()
|
||||||
23
services/integrations/app/odoo/exceptions.py
Normal file
23
services/integrations/app/odoo/exceptions.py
Normal 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
|
||||||
5
services/integrations/app/routers/__init__.py
Normal file
5
services/integrations/app/routers/__init__.py
Normal 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"]
|
||||||
233
services/integrations/app/routers/odoo.py
Normal file
233
services/integrations/app/routers/odoo.py
Normal 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()
|
||||||
48
services/integrations/app/routers/sync.py
Normal file
48
services/integrations/app/routers/sync.py
Normal 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))
|
||||||
150
services/integrations/app/routers/webhooks.py
Normal file
150
services/integrations/app/routers/webhooks.py
Normal 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"}
|
||||||
23
services/integrations/app/schemas/__init__.py
Normal file
23
services/integrations/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
38
services/integrations/app/schemas/crm.py
Normal file
38
services/integrations/app/schemas/crm.py
Normal 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
|
||||||
43
services/integrations/app/schemas/partner.py
Normal file
43
services/integrations/app/schemas/partner.py
Normal 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
|
||||||
31
services/integrations/app/schemas/product.py
Normal file
31
services/integrations/app/schemas/product.py
Normal 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
|
||||||
40
services/integrations/app/schemas/sale.py
Normal file
40
services/integrations/app/schemas/sale.py
Normal 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
|
||||||
6
services/integrations/app/services/__init__.py
Normal file
6
services/integrations/app/services/__init__.py
Normal 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"]
|
||||||
108
services/integrations/app/services/crm.py
Normal file
108
services/integrations/app/services/crm.py
Normal 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",
|
||||||
|
)
|
||||||
97
services/integrations/app/services/partner.py
Normal file
97
services/integrations/app/services/partner.py
Normal 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),
|
||||||
|
}
|
||||||
117
services/integrations/app/services/product.py
Normal file
117
services/integrations/app/services/product.py
Normal 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),
|
||||||
|
}
|
||||||
128
services/integrations/app/services/sale.py
Normal file
128
services/integrations/app/services/sale.py
Normal 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}"
|
||||||
87
services/integrations/app/services/sync.py
Normal file
87
services/integrations/app/services/sync.py
Normal 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
|
||||||
6
services/integrations/requirements.txt
Normal file
6
services/integrations/requirements.txt
Normal 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
|
||||||
@@ -2,10 +2,10 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++ git
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"lint": "eslint src/**/*.ts"
|
"lint": "eslint src/**/*.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@whiskeysockets/baileys": "^6.7.16",
|
"@whiskeysockets/baileys": "^6.7.17",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"uuid": "^11.0.5"
|
"uuid": "^11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export function createRouter(sessionManager: SessionManager): Router {
|
|||||||
|
|
||||||
// Get session info
|
// Get session info
|
||||||
router.get('/sessions/:accountId', (req: Request, res: Response) => {
|
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) {
|
if (!session) {
|
||||||
return res.status(404).json({ error: 'Session not found' });
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
}
|
}
|
||||||
@@ -41,17 +42,41 @@ export function createRouter(sessionManager: SessionManager): Router {
|
|||||||
// Disconnect session
|
// Disconnect session
|
||||||
router.post('/sessions/:accountId/disconnect', async (req: Request, res: Response) => {
|
router.post('/sessions/:accountId/disconnect', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
await sessionManager.disconnectSession(req.params.accountId);
|
const accountId = req.params.accountId as string;
|
||||||
|
await sessionManager.disconnectSession(accountId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
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
|
// Delete session
|
||||||
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
|
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
await sessionManager.deleteSession(req.params.accountId);
|
const accountId = req.params.accountId as string;
|
||||||
|
await sessionManager.deleteSession(accountId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
@@ -84,8 +109,9 @@ export function createRouter(sessionManager: SessionManager): Router {
|
|||||||
messageContent = { text: content.text || content };
|
messageContent = { text: content.text || content };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountId = req.params.accountId as string;
|
||||||
const result = await sessionManager.sendMessage(
|
const result = await sessionManager.sendMessage(
|
||||||
req.params.accountId,
|
accountId,
|
||||||
to,
|
to,
|
||||||
messageContent
|
messageContent
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { Server as SocketIOServer } from 'socket.io';
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
import { SessionManager } from './sessions/SessionManager';
|
import { SessionManager } from './sessions/SessionManager';
|
||||||
@@ -21,8 +23,12 @@ async function main() {
|
|||||||
path: '/ws',
|
path: '/ws',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Serve media files statically
|
||||||
|
app.use('/media', express.static(path.join(process.cwd(), 'media')));
|
||||||
|
|
||||||
const sessionManager = new SessionManager('./sessions');
|
const sessionManager = new SessionManager('./sessions');
|
||||||
const router = createRouter(sessionManager);
|
const router = createRouter(sessionManager);
|
||||||
app.use('/api', router);
|
app.use('/api', router);
|
||||||
@@ -36,11 +42,14 @@ async function main() {
|
|||||||
|
|
||||||
// Forward to API Gateway
|
// Forward to API Gateway
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
|
const response = await fetch(`${API_GATEWAY_URL}/api/whatsapp/internal/whatsapp/event`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(event),
|
body: JSON.stringify(event),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error({ status: response.status }, 'API Gateway rejected event');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to forward event to API Gateway');
|
logger.error({ error }, 'Failed to forward event to API Gateway');
|
||||||
}
|
}
|
||||||
|
|||||||
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal file
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
services/whatsapp-core/src/sessions/types.ts
Normal file
31
services/whatsapp-core/src/sessions/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user