feat: Major WhatsApp integration update with Odoo and pause/resume
## Frontend - Add media display (images, audio, video, docs) in Inbox - Add pause/resume functionality for WhatsApp accounts - Fix media URLs to use nginx proxy (relative URLs) ## API Gateway - Add /accounts/:id/pause and /accounts/:id/resume endpoints - Fix media URL handling for browser access ## WhatsApp Core - Add pauseSession() - disconnect without logout - Add resumeSession() - reconnect using saved credentials - Add media download and storage for incoming messages - Serve media files via /media/ static route ## Odoo Module (odoo_whatsapp_hub) - Add Chat Hub interface with DOLLARS theme (dark, 3-column layout) - Add WhatsApp/DRRR theme switcher for chat view - Add "ABRIR CHAT" button in conversation form - Add send_message_from_chat() method - Add security/ir.model.access.csv - Fix CSS scoping to avoid breaking Odoo UI - Update webhook to handle message events properly ## Documentation - Add docs/CONTEXTO_DESARROLLO.md with complete project context ## Infrastructure - Add whatsapp_media Docker volume - Configure nginx proxy for /media/ route - Update .gitignore to track src/sessions/ source files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -105,12 +105,13 @@ docker-compose.override.yml
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sessions / Data
|
||||
# -----------------------------------------------------------------------------
|
||||
sessions/
|
||||
/sessions/
|
||||
*.session
|
||||
*.session.json
|
||||
|
||||
# WhatsApp Baileys sessions
|
||||
# WhatsApp Baileys sessions (data, not source code)
|
||||
services/whatsapp-core/sessions/
|
||||
!services/whatsapp-core/src/sessions/
|
||||
auth_info*/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
WS_PORT: 3001
|
||||
volumes:
|
||||
- whatsapp_sessions:/app/sessions
|
||||
- whatsapp_media:/app/media
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
@@ -70,6 +71,9 @@ services:
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
WHATSAPP_CORE_URL: http://whatsapp-core:3001
|
||||
WHATSAPP_CORE_PUBLIC_URL: ${WHATSAPP_CORE_PUBLIC_URL:-http://localhost:3001}
|
||||
FLOW_ENGINE_URL: http://flow-engine:8001
|
||||
ODOO_WEBHOOK_URL: ${ODOO_WEBHOOK_URL:-http://192.168.10.188:8069/whatsapp/webhook}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -139,6 +143,7 @@ volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
whatsapp_sessions:
|
||||
whatsapp_media:
|
||||
|
||||
networks:
|
||||
wac_network:
|
||||
|
||||
344
docs/CONTEXTO_DESARROLLO.md
Normal file
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*
|
||||
@@ -8,21 +8,23 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://api-gateway:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /auth {
|
||||
proxy_pass http://api-gateway:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://whatsapp-core:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
proxy_pass http://whatsapp-core:3001/media/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ interface Message {
|
||||
direction: 'inbound' | 'outbound';
|
||||
type: string;
|
||||
content: string | null;
|
||||
media_url: string | null;
|
||||
created_at: string;
|
||||
is_internal_note: boolean;
|
||||
sent_by: string | null;
|
||||
@@ -368,6 +369,63 @@ export default function Inbox(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessageContent(msg: Message): JSX.Element {
|
||||
// Render media based on type
|
||||
if (msg.media_url) {
|
||||
const mediaType = msg.type?.toUpperCase();
|
||||
|
||||
if (mediaType === 'IMAGE') {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={msg.media_url}
|
||||
alt="Imagen"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 300,
|
||||
borderRadius: 4,
|
||||
marginBottom: msg.content ? 8 : 0,
|
||||
}}
|
||||
onClick={() => window.open(msg.media_url!, '_blank')}
|
||||
/>
|
||||
{msg.content && msg.content !== '[Image]' && (
|
||||
<Text style={{ color: 'inherit', display: 'block' }}>{msg.content}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaType === 'AUDIO') {
|
||||
return (
|
||||
<audio controls style={{ maxWidth: '100%' }}>
|
||||
<source src={msg.media_url} type="audio/ogg" />
|
||||
Tu navegador no soporta audio.
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaType === 'VIDEO') {
|
||||
return (
|
||||
<video controls style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }}>
|
||||
<source src={msg.media_url} type="video/mp4" />
|
||||
Tu navegador no soporta video.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaType === 'DOCUMENT') {
|
||||
return (
|
||||
<a href={msg.media_url} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>
|
||||
📄 {msg.content || 'Documento'}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default text content
|
||||
return <Text style={{ color: 'inherit' }}>{msg.content}</Text>;
|
||||
}
|
||||
|
||||
function renderMessage(msg: Message): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
@@ -391,7 +449,7 @@ export default function Inbox(): JSX.Element {
|
||||
<FileTextOutlined /> Nota interna
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
|
||||
{renderMessageContent(msg)}
|
||||
</div>
|
||||
<Text
|
||||
type="secondary"
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
QrcodeOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
@@ -28,7 +30,7 @@ interface WhatsAppAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
phone_number: string | null;
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
|
||||
qr_code: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -76,6 +78,32 @@ export default function WhatsAppAccounts() {
|
||||
},
|
||||
});
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.post(`/api/whatsapp/accounts/${id}/pause`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('Conexión pausada');
|
||||
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Error al pausar');
|
||||
},
|
||||
});
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.post(`/api/whatsapp/accounts/${id}/resume`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('Reconectando...');
|
||||
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Error al reanudar');
|
||||
},
|
||||
});
|
||||
|
||||
const handleShowQR = async (account: WhatsAppAccount) => {
|
||||
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
|
||||
setQrModal(data);
|
||||
@@ -102,13 +130,15 @@ export default function WhatsAppAccounts() {
|
||||
connected: 'green',
|
||||
connecting: 'orange',
|
||||
disconnected: 'red',
|
||||
paused: 'gold',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
connected: 'Conectado',
|
||||
connecting: 'Conectando',
|
||||
disconnected: 'Desconectado',
|
||||
paused: 'Pausado',
|
||||
};
|
||||
return <Tag color={colors[status]}>{labels[status]}</Tag>;
|
||||
return <Tag color={colors[status]}>{labels[status] || status}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -116,7 +146,7 @@ export default function WhatsAppAccounts() {
|
||||
key: 'actions',
|
||||
render: (_: any, record: WhatsAppAccount) => (
|
||||
<Space>
|
||||
{record.status !== 'connected' && (
|
||||
{record.status !== 'connected' && record.status !== 'paused' && (
|
||||
<Button
|
||||
icon={<QrcodeOutlined />}
|
||||
onClick={() => handleShowQR(record)}
|
||||
@@ -124,6 +154,33 @@ export default function WhatsAppAccounts() {
|
||||
Ver QR
|
||||
</Button>
|
||||
)}
|
||||
{record.status === 'connected' && (
|
||||
<Button
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '¿Pausar conexión?',
|
||||
content: 'La conexión se pausará pero podrás reanudarla después sin escanear QR.',
|
||||
okText: 'Pausar',
|
||||
onOk: () => pauseMutation.mutate(record.id),
|
||||
});
|
||||
}}
|
||||
style={{ color: '#faad14', borderColor: '#faad14' }}
|
||||
>
|
||||
Pausar
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'paused' || record.status === 'disconnected') && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => resumeMutation.mutate(record.id)}
|
||||
loading={resumeMutation.isPending}
|
||||
style={{ background: '#25D366', borderColor: '#25D366' }}
|
||||
>
|
||||
Reanudar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
'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',
|
||||
@@ -27,8 +29,11 @@
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odoo_whatsapp_hub/static/src/css/whatsapp.css',
|
||||
'odoo_whatsapp_hub/static/src/js/chat_widget.js',
|
||||
'odoo_whatsapp_hub/static/src/xml/chat_widget.xml',
|
||||
'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,
|
||||
|
||||
@@ -8,14 +8,15 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
class WhatsAppWebhookController(http.Controller):
|
||||
|
||||
@http.route('/whatsapp/webhook', type='json', auth='public', methods=['POST'], csrf=False)
|
||||
@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:
|
||||
data = request.jsonrequest
|
||||
# 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}')
|
||||
@@ -29,13 +30,14 @@ class WhatsAppWebhookController(http.Controller):
|
||||
|
||||
handler = handlers.get(event_type)
|
||||
if handler:
|
||||
return handler(data)
|
||||
result = handler(data)
|
||||
return request.make_json_response(result)
|
||||
|
||||
return {'status': 'ignored', 'reason': f'Unknown event type: {event_type}'}
|
||||
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 {'status': 'error', 'message': str(e)}
|
||||
return request.make_json_response({'status': 'error', 'message': str(e)})
|
||||
|
||||
def _handle_message(self, data):
|
||||
"""Handle incoming message"""
|
||||
@@ -64,23 +66,25 @@ class WhatsAppWebhookController(http.Controller):
|
||||
'status': 'bot',
|
||||
})
|
||||
|
||||
# Try to find partner by phone
|
||||
partner = request.env['res.partner'].sudo().search([
|
||||
'|',
|
||||
('phone', 'ilike', phone[-10:]),
|
||||
('mobile', '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': 'inbound',
|
||||
'direction': direction,
|
||||
'message_type': msg_data.get('type', 'text'),
|
||||
'content': msg_data.get('content'),
|
||||
'media_url': msg_data.get('media_url'),
|
||||
'status': 'delivered',
|
||||
'status': 'delivered' if direction == 'inbound' else 'sent',
|
||||
})
|
||||
|
||||
return {'status': 'ok'}
|
||||
@@ -137,4 +141,4 @@ class WhatsAppWebhookController(http.Controller):
|
||||
@http.route('/whatsapp/webhook/test', type='http', auth='public', methods=['GET'])
|
||||
def webhook_test(self):
|
||||
"""Test endpoint to verify webhook connectivity"""
|
||||
return json.dumps({'status': 'ok', 'message': 'WhatsApp webhook is active'})
|
||||
return request.make_json_response({'status': 'ok', 'message': 'WhatsApp webhook is active'})
|
||||
|
||||
@@ -59,9 +59,9 @@ class WhatsAppAccount(models.Model):
|
||||
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/accounts/{self.external_id}',
|
||||
headers=self._get_headers(),
|
||||
f'{self.api_url}/api/whatsapp/internal/odoo/accounts/{self.external_id}',
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
@@ -71,7 +71,9 @@ class WhatsAppAccount(models.Model):
|
||||
'phone_number': data.get('phone_number'),
|
||||
'qr_code': data.get('qr_code'),
|
||||
})
|
||||
except Exception as e:
|
||||
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}')
|
||||
|
||||
|
||||
@@ -115,6 +115,79 @@ class WhatsAppConversation(models.Model):
|
||||
'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"""
|
||||
|
||||
@@ -68,15 +68,23 @@ class WhatsAppMessage(models.Model):
|
||||
"""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/conversations/{self.conversation_id.external_id}/messages',
|
||||
headers=account._get_headers(),
|
||||
f'{account.api_url}/api/whatsapp/internal/odoo/send',
|
||||
json={
|
||||
'type': self.message_type,
|
||||
'content': self.content,
|
||||
'media_url': self.media_url,
|
||||
'phone_number': phone_number,
|
||||
'message': self.content,
|
||||
'account_id': account.external_id,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
@@ -84,7 +92,7 @@ class WhatsAppMessage(models.Model):
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.write({
|
||||
'external_id': data.get('id'),
|
||||
'external_id': data.get('message_id'),
|
||||
'status': 'sent',
|
||||
'error_message': False,
|
||||
})
|
||||
|
||||
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
|
||||
|
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;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,175 @@
|
||||
/* WhatsApp Hub Styles */
|
||||
/* WhatsApp Hub - Scoped Styles */
|
||||
/* All styles use specific class prefixes to avoid affecting other Odoo views */
|
||||
|
||||
/* WhatsApp Green */
|
||||
:root {
|
||||
/* ============================================
|
||||
WHATSAPP CHAT FULLSCREEN (Theme selector)
|
||||
============================================ */
|
||||
|
||||
.o_whatsapp_chat_fullscreen {
|
||||
--whatsapp-green: #25D366;
|
||||
--whatsapp-dark-green: #128C7E;
|
||||
--whatsapp-light-green: #DCF8C6;
|
||||
--whatsapp-bg: #E5DDD5;
|
||||
}
|
||||
|
||||
/* Chat container */
|
||||
.o_whatsapp_chat_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--whatsapp-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Chat header */
|
||||
.o_whatsapp_chat_header {
|
||||
background: var(--whatsapp-dark-green);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
/* 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_header .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.o_whatsapp_chat_fullscreen .avatar {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
background: var(--avatar-bg);
|
||||
color: var(--avatar-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: var(--whatsapp-dark-green);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.o_whatsapp_chat_header .contact-info {
|
||||
.o_whatsapp_chat_fullscreen .contact-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_whatsapp_chat_header .contact-name {
|
||||
.o_whatsapp_chat_fullscreen .contact-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.o_whatsapp_chat_header .contact-status {
|
||||
.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;
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Messages container */
|
||||
.o_whatsapp_messages {
|
||||
.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: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Message bubble */
|
||||
.o_whatsapp_message {
|
||||
/* 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_message.inbound {
|
||||
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.inbound {
|
||||
align-self: flex-start;
|
||||
background: white;
|
||||
background: var(--msg-inbound-bg);
|
||||
color: var(--msg-inbound-text);
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.o_whatsapp_message.outbound {
|
||||
.o_whatsapp_chat_fullscreen.theme-whatsapp .o_whatsapp_message.outbound {
|
||||
align-self: flex-end;
|
||||
background: var(--whatsapp-light-green);
|
||||
background: var(--msg-outbound-bg);
|
||||
color: var(--msg-outbound-text);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.o_whatsapp_message .message-content {
|
||||
margin-bottom: 4px;
|
||||
.o_whatsapp_chat_fullscreen.theme-whatsapp .message-sender {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_whatsapp_message .message-meta {
|
||||
.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: #667781;
|
||||
color: var(--msg-meta-text);
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -99,63 +177,178 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.o_whatsapp_message .message-status {
|
||||
color: #53bdeb;
|
||||
/* DRRR Theme Messages */
|
||||
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_messages_container {
|
||||
padding: 20px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
.o_whatsapp_input_area {
|
||||
background: #F0F2F5;
|
||||
.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_input_area input {
|
||||
.o_whatsapp_chat_fullscreen .o_whatsapp_input_area input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 10px 16px;
|
||||
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_input_area button {
|
||||
background: var(--whatsapp-green);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
.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_input_area button:hover {
|
||||
background: var(--whatsapp-dark-green);
|
||||
.o_whatsapp_chat_fullscreen.theme-drrr .o_whatsapp_input_area .btn {
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.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: var(--whatsapp-green);
|
||||
color: #25D366;
|
||||
}
|
||||
|
||||
.o_whatsapp_status_disconnected {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Partner WhatsApp button */
|
||||
/* WhatsApp button style */
|
||||
.btn-whatsapp {
|
||||
background-color: var(--whatsapp-green);
|
||||
border-color: var(--whatsapp-green);
|
||||
background-color: #25D366;
|
||||
border-color: #25D366;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-whatsapp:hover {
|
||||
background-color: var(--whatsapp-dark-green);
|
||||
border-color: var(--whatsapp-dark-green);
|
||||
background-color: #128C7E;
|
||||
border-color: #128C7E;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -55,7 +55,7 @@ export class WhatsAppChatWidget extends Component {
|
||||
const messages = await this.orm.searchRead(
|
||||
"whatsapp.message",
|
||||
[["conversation_id", "=", this.state.conversation.id]],
|
||||
["id", "direction", "content", "message_type", "status", "create_date"],
|
||||
["id", "direction", "content", "message_type", "media_url", "status", "create_date"],
|
||||
{ order: "create_date asc" }
|
||||
);
|
||||
this.state.messages = messages;
|
||||
|
||||
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>
|
||||
@@ -23,7 +23,37 @@
|
||||
<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" t-esc="message.content"/>
|
||||
<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'">
|
||||
|
||||
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>
|
||||
@@ -5,55 +5,46 @@
|
||||
<field name="name">whatsapp.conversation.tree</field>
|
||||
<field name="model">whatsapp.conversation</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-bf="unread_count > 0">
|
||||
<list>
|
||||
<field name="display_name"/>
|
||||
<field name="phone_number"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'bot'" decoration-warning="status == 'waiting'" decoration-success="status == 'active'" decoration-muted="status == 'resolved'"/>
|
||||
<field name="assigned_user_id" widget="many2one_avatar_user"/>
|
||||
<field name="status"/>
|
||||
<field name="last_message_at"/>
|
||||
<field name="last_message_preview"/>
|
||||
<field name="unread_count" widget="badge" decoration-danger="unread_count > 0"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<!-- 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_assign_to_me" string="Asignarme" type="object" class="btn-primary" invisible="assigned_user_id"/>
|
||||
<button name="action_mark_resolved" string="Resolver" type="object" class="btn-secondary" invisible="status == 'resolved'"/>
|
||||
<field name="status" widget="statusbar" statusbar_visible="bot,waiting,active,resolved"/>
|
||||
<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" readonly="1"/>
|
||||
<field name="display_name"/>
|
||||
<field name="phone_number"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="account_id"/>
|
||||
<field name="assigned_user_id"/>
|
||||
<field name="last_message_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Mensajes" name="messages">
|
||||
<field name="message_ids" mode="list" readonly="1">
|
||||
<field name="message_ids">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="direction" widget="badge"/>
|
||||
<field name="direction"/>
|
||||
<field name="content"/>
|
||||
<field name="status" widget="badge"/>
|
||||
<field name="sent_by_id"/>
|
||||
<field name="media_url" widget="url"/>
|
||||
<field name="status"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
@@ -66,7 +57,6 @@
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="phone_number"/>
|
||||
<field name="partner_id"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -8,10 +8,19 @@
|
||||
sequence="50"
|
||||
/>
|
||||
|
||||
<!-- Conversations Menu -->
|
||||
<!-- 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"
|
||||
name="Conversaciones (Lista)"
|
||||
parent="menu_whatsapp_root"
|
||||
action="action_whatsapp_conversation"
|
||||
sequence="10"
|
||||
|
||||
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,6 +17,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# WhatsApp Core
|
||||
WHATSAPP_CORE_URL: str = "http://localhost:3001"
|
||||
WHATSAPP_CORE_PUBLIC_URL: str = "http://localhost:3001" # URL accessible from browser
|
||||
|
||||
# Flow Engine
|
||||
FLOW_ENGINE_URL: str = "http://localhost:8001"
|
||||
@@ -24,6 +25,9 @@ class Settings(BaseSettings):
|
||||
# Integrations
|
||||
INTEGRATIONS_URL: str = "http://localhost:8002"
|
||||
|
||||
# Odoo Webhook
|
||||
ODOO_WEBHOOK_URL: str = "" # e.g., "http://192.168.10.188:8069/whatsapp/webhook"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
|
||||
@@ -62,11 +62,29 @@ async def create_account(
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
|
||||
def list_accounts(
|
||||
async def list_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
accounts = db.query(WhatsAppAccount).all()
|
||||
|
||||
# Sync status with WhatsApp Core for each account
|
||||
async with httpx.AsyncClient() as client:
|
||||
for account in accounts:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
account.qr_code = data.get("qrCode")
|
||||
account.status = AccountStatus(data.get("status", "disconnected"))
|
||||
account.phone_number = data.get("phoneNumber")
|
||||
except Exception:
|
||||
pass
|
||||
db.commit()
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
@@ -122,6 +140,63 @@ async def delete_account(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/pause")
|
||||
async def pause_account(
|
||||
account_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Pause WhatsApp connection without logging out (preserves session)"""
|
||||
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/pause",
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
account.status = AccountStatus.DISCONNECTED
|
||||
db.commit()
|
||||
return {"success": True, "status": "paused"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to pause session")
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/resume")
|
||||
async def resume_account(
|
||||
account_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Resume paused WhatsApp connection"""
|
||||
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/resume",
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
session = data.get("session", {})
|
||||
account.status = AccountStatus(session.get("status", "connecting"))
|
||||
account.qr_code = session.get("qrCode")
|
||||
db.commit()
|
||||
return {"success": True, "status": account.status.value}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to resume session")
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=List[ConversationResponse])
|
||||
def list_conversations(
|
||||
status: ConversationStatus = None,
|
||||
@@ -228,6 +303,7 @@ async def handle_whatsapp_event(
|
||||
elif event.type == "message":
|
||||
msg_data = event.data
|
||||
phone = msg_data.get("from", "").split("@")[0]
|
||||
is_from_me = msg_data.get("fromMe", False)
|
||||
|
||||
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
|
||||
if not contact:
|
||||
@@ -256,19 +332,47 @@ async def handle_whatsapp_event(
|
||||
db.refresh(conversation)
|
||||
|
||||
wa_message = msg_data.get("message", {})
|
||||
media_url = msg_data.get("mediaUrl")
|
||||
media_type = msg_data.get("mediaType", "text")
|
||||
|
||||
# Extract text content
|
||||
content = (
|
||||
wa_message.get("conversation") or
|
||||
wa_message.get("extendedTextMessage", {}).get("text") or
|
||||
"[Media]"
|
||||
wa_message.get("imageMessage", {}).get("caption") or
|
||||
wa_message.get("videoMessage", {}).get("caption") or
|
||||
wa_message.get("documentMessage", {}).get("fileName") or
|
||||
""
|
||||
)
|
||||
|
||||
# Map media type to MessageType
|
||||
type_mapping = {
|
||||
"text": MessageType.TEXT,
|
||||
"image": MessageType.IMAGE,
|
||||
"audio": MessageType.AUDIO,
|
||||
"video": MessageType.VIDEO,
|
||||
"document": MessageType.DOCUMENT,
|
||||
"sticker": MessageType.IMAGE,
|
||||
}
|
||||
msg_type = type_mapping.get(media_type, MessageType.TEXT)
|
||||
|
||||
# Build full media URL if present (use relative URL for browser access via nginx proxy)
|
||||
full_media_url = None
|
||||
if media_url:
|
||||
# Use relative URL that nginx will proxy to whatsapp-core
|
||||
full_media_url = media_url # e.g., "/media/uuid.jpg"
|
||||
|
||||
# Set direction based on fromMe flag
|
||||
direction = MessageDirection.OUTBOUND if is_from_me else MessageDirection.INBOUND
|
||||
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
whatsapp_message_id=msg_data.get("id"),
|
||||
direction=MessageDirection.INBOUND,
|
||||
type=MessageType.TEXT,
|
||||
content=content,
|
||||
status=MessageStatus.DELIVERED,
|
||||
direction=direction,
|
||||
type=msg_type,
|
||||
content=content if content else f"[{media_type.capitalize()}]",
|
||||
media_url=full_media_url,
|
||||
status=MessageStatus.DELIVERED if not is_from_me else MessageStatus.SENT,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -277,8 +381,8 @@ async def handle_whatsapp_event(
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
# Process message through Flow Engine (if in BOT status)
|
||||
if conversation.status == ConversationStatus.BOT:
|
||||
# Process message through Flow Engine (only for inbound messages in BOT status)
|
||||
if not is_from_me and conversation.status == ConversationStatus.BOT:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.post(
|
||||
@@ -305,8 +409,53 @@ async def handle_whatsapp_event(
|
||||
except Exception as e:
|
||||
print(f"Flow engine error: {e}")
|
||||
|
||||
# Send webhook to Odoo if configured
|
||||
if settings.ODOO_WEBHOOK_URL:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.post(
|
||||
settings.ODOO_WEBHOOK_URL,
|
||||
json={
|
||||
"type": "message",
|
||||
"account_id": str(account.id),
|
||||
"data": {
|
||||
"id": str(message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"from": phone,
|
||||
"contact_name": contact.name,
|
||||
"content": content,
|
||||
"type": media_type,
|
||||
"direction": "outbound" if is_from_me else "inbound",
|
||||
"media_url": full_media_url,
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Odoo webhook error: {e}")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
# Send account status to Odoo webhook
|
||||
if settings.ODOO_WEBHOOK_URL and event.type in ["connected", "disconnected", "qr"]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.post(
|
||||
settings.ODOO_WEBHOOK_URL,
|
||||
json={
|
||||
"type": "account_status",
|
||||
"account_id": str(account.id),
|
||||
"data": {
|
||||
"status": account.status.value if account.status else "disconnected",
|
||||
"phone_number": account.phone_number,
|
||||
"qr_code": account.qr_code,
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Odoo webhook error: {e}")
|
||||
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -448,3 +597,183 @@ def add_internal_note(
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
return {"success": True, "message_id": str(message.id)}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Odoo Internal Endpoints (no authentication)
|
||||
# ============================================
|
||||
|
||||
class OdooSendMessageRequest(BaseModel):
|
||||
phone_number: str
|
||||
message: str
|
||||
account_id: str
|
||||
|
||||
|
||||
@router.get("/internal/odoo/accounts/{account_id}")
|
||||
async def odoo_get_account(
|
||||
account_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get account status for Odoo (no auth required)"""
|
||||
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
# Sync with WhatsApp Core
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}",
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
account.qr_code = data.get("qrCode")
|
||||
account.status = AccountStatus(data.get("status", "disconnected"))
|
||||
account.phone_number = data.get("phoneNumber")
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": str(account.id),
|
||||
"phone_number": account.phone_number,
|
||||
"name": account.name,
|
||||
"status": account.status.value if account.status else "disconnected",
|
||||
"qr_code": account.qr_code,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/internal/odoo/accounts")
|
||||
async def odoo_list_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all accounts for Odoo (no auth required)"""
|
||||
accounts = db.query(WhatsAppAccount).all()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for account in accounts:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
account.qr_code = data.get("qrCode")
|
||||
account.status = AccountStatus(data.get("status", "disconnected"))
|
||||
account.phone_number = data.get("phoneNumber")
|
||||
except Exception:
|
||||
pass
|
||||
db.commit()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"phone_number": a.phone_number,
|
||||
"name": a.name,
|
||||
"status": a.status.value if a.status else "disconnected",
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
|
||||
|
||||
@router.post("/internal/odoo/send")
|
||||
async def odoo_send_message(
|
||||
request: OdooSendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Send WhatsApp message from Odoo (no auth required)"""
|
||||
account = db.query(WhatsAppAccount).filter(
|
||||
WhatsAppAccount.id == request.account_id
|
||||
).first()
|
||||
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
# Find or create contact
|
||||
phone = request.phone_number.replace("+", "").replace(" ", "").replace("-", "")
|
||||
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
|
||||
if not contact:
|
||||
contact = Contact(phone_number=phone)
|
||||
db.add(contact)
|
||||
db.commit()
|
||||
db.refresh(contact)
|
||||
|
||||
# Find or create conversation
|
||||
conversation = db.query(Conversation).filter(
|
||||
Conversation.whatsapp_account_id == account.id,
|
||||
Conversation.contact_id == contact.id,
|
||||
Conversation.status != ConversationStatus.RESOLVED,
|
||||
).first()
|
||||
|
||||
if not conversation:
|
||||
conversation = Conversation(
|
||||
whatsapp_account_id=account.id,
|
||||
contact_id=contact.id,
|
||||
status=ConversationStatus.BOT,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.commit()
|
||||
db.refresh(conversation)
|
||||
|
||||
# Create message
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
direction=MessageDirection.OUTBOUND,
|
||||
type=MessageType.TEXT,
|
||||
content=request.message,
|
||||
status=MessageStatus.PENDING,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
# Send via WhatsApp Core
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}/messages",
|
||||
json={
|
||||
"to": phone,
|
||||
"type": "text",
|
||||
"content": {"text": request.message},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
message.whatsapp_message_id = data.get("messageId")
|
||||
message.status = MessageStatus.SENT
|
||||
else:
|
||||
message.status = MessageStatus.FAILED
|
||||
except Exception as e:
|
||||
message.status = MessageStatus.FAILED
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send: {e}")
|
||||
|
||||
db.commit()
|
||||
return {"success": True, "message_id": str(message.id)}
|
||||
|
||||
|
||||
@router.get("/internal/odoo/conversations")
|
||||
def odoo_list_conversations(
|
||||
account_id: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List conversations for Odoo (no auth required)"""
|
||||
query = db.query(Conversation)
|
||||
if account_id:
|
||||
query = query.filter(Conversation.whatsapp_account_id == account_id)
|
||||
|
||||
conversations = query.order_by(Conversation.last_message_at.desc()).limit(100).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"contact_phone": c.contact.phone_number if c.contact else None,
|
||||
"contact_name": c.contact.name if c.contact else None,
|
||||
"status": c.status.value if c.status else None,
|
||||
"last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
|
||||
}
|
||||
for c in conversations
|
||||
]
|
||||
|
||||
@@ -4,7 +4,8 @@ sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.20
|
||||
pydantic[email]==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint": "eslint src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.16",
|
||||
"@whiskeysockets/baileys": "^6.7.17",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"ioredis": "^5.4.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -50,6 +50,28 @@ export function createRouter(sessionManager: SessionManager): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Pause session (disconnect without logout)
|
||||
router.post('/sessions/:accountId/pause', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
await sessionManager.pauseSession(accountId);
|
||||
res.json({ success: true, status: 'paused' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Resume session
|
||||
router.post('/sessions/:accountId/resume', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
const session = await sessionManager.resumeSession(accountId);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session
|
||||
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { createServer } from 'http';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { SessionManager } from './sessions/SessionManager';
|
||||
@@ -21,8 +23,12 @@ async function main() {
|
||||
path: '/ws',
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Serve media files statically
|
||||
app.use('/media', express.static(path.join(process.cwd(), 'media')));
|
||||
|
||||
const sessionManager = new SessionManager('./sessions');
|
||||
const router = createRouter(sessionManager);
|
||||
app.use('/api', router);
|
||||
@@ -36,11 +42,14 @@ async function main() {
|
||||
|
||||
// Forward to API Gateway
|
||||
try {
|
||||
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
|
||||
const response = await fetch(`${API_GATEWAY_URL}/api/whatsapp/internal/whatsapp/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.error({ status: response.status }, 'API Gateway rejected event');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to forward event to API Gateway');
|
||||
}
|
||||
|
||||
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