4
0

21 Коммиты c5dd4a027c ... e53790c888

Автор SHA1 Сообщение Дата
  root e53790c888 Perf: Add debounce to discuss_channel write to prevent concurrent updates 1 месяц назад
  odoo 2100f4a10a chore: commit pending changes before subtree update 1 месяц назад
  odoo c4c2695610 fix(whatsapp): resolve serialization failure and owl error in discuss 1 месяц назад
  odoo 0537d89930 Update whatsapp_web and whatsapp_web_groups modules 1 месяц назад
  odoo 583a9e9e9e [FIX] whatsapp_web: fix phone validation and align payload with wppconnect 1 месяц назад
  odoo 18b7b66855 Update whatsapp_web subtree from develop 2 месяцев назад
  odoo 218c34eebc Merge commit 'd8b06773a605e46f9e257586a49b9cea64e4f638' into TC 2 месяцев назад
  root 0135327dc1 Merge remote-tracking branch 'origin/develop' into develop 2 месяцев назад
  root acbf0d5a8f update status mensaje 2 месяцев назад
  root 1720df3765 fix: corregir sintaxis de herencia y orden de carga de modelos 2 месяцев назад
  root b530bc88a2 fix: agregar archivo mail_message.py faltante al repositorio 2 месяцев назад
  root dc44d89d7f refactor: eliminar dependencia circular - remover campos Many2one a ww.group 2 месяцев назад
  MC Team deb2f0344b Migrar a nueva API Gateway: usar session_name y api_key, endpoint groups desde BD 2 месяцев назад
  root 06d6e7a05c Add API key and login fields, add .gitignore 2 месяцев назад
  root b94917aba8 Add comprehensive WhatsApp Web integration features 4 месяцев назад
  root cf6a1a232d fix mobile send whatsapp web 6 месяцев назад
  root a4e54cf1c1 fix mobile 6 месяцев назад
  root af86bc3015 log whatsapp web json 6 месяцев назад
  root ae8070919a Merge branch 'develop' of ssh://g1t.m22.mx:12765/M22/whatsapp_web into develop 8 месяцев назад
  root 9fea7d2355 nueva funcion para integracion con whatsapp web groups 8 месяцев назад
  root 334bbe3965 Merge tag 'v0.1' into develop 9 месяцев назад

+ 53 - 0
.gitignore

@@ -0,0 +1,53 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Odoo
+*.pot
+*.mo
+*.log
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Environment
+.env
+.venv
+env/
+venv/
+ENV/
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Temporary files
+*.tmp
+*.bak
+*.orig
+
+

+ 466 - 0
API_REFERENCE.md

@@ -0,0 +1,466 @@
+# API Reference - WhatsApp Web Integration
+
+## Índice
+- [Modelos](#modelos)
+- [Métodos](#métodos)
+- [Campos](#campos)
+- [Ejemplos de Uso](#ejemplos-de-uso)
+- [Respuestas de API](#respuestas-de-api)
+
+## Modelos
+
+### whatsapp.account (Extendido)
+
+Extensión del modelo base de cuentas WhatsApp para soportar WhatsApp Web.
+
+#### Campos Adicionales
+
+| Campo | Tipo | Descripción |
+|-------|------|-------------|
+| `whatsapp_web_url` | Char | URL del servidor whatsapp-web.js |
+
+#### Métodos
+
+##### `get_groups()`
+Obtiene la lista de grupos disponibles en WhatsApp Web.
+
+**Parámetros:** Ninguno
+
+**Retorna:** `list` - Lista de diccionarios con información de grupos
+
+**Ejemplo:**
+```python
+account = self.env['whatsapp.account'].browse(1)
+groups = account.get_groups()
+```
+
+**Respuesta esperada:**
+```python
+[
+    {
+        'id': {'_serialized': '120363158956331133@g.us'},
+        'name': 'Mi Grupo',
+        'members': [
+            {
+                'id': {'_serialized': '5215551234567@c.us'},
+                'number': '5551234567',
+                'name': 'Juan Pérez',
+                'isAdmin': False,
+                'isSuperAdmin': False
+            }
+        ]
+    }
+]
+```
+
+---
+
+### whatsapp.message (Extendido)
+
+Extensión del modelo base de mensajes WhatsApp para soportar grupos.
+
+#### Campos Adicionales
+
+| Campo | Tipo | Descripción |
+|-------|------|-------------|
+| `recipient_type` | Selection | Tipo de destinatario: 'phone' o 'group' |
+| `whatsapp_group_id` | Many2one | Referencia al grupo (modelo ww.group) |
+| `final_recipient` | Char | Destinatario final calculado |
+
+#### Métodos
+
+##### `_compute_final_recipient()`
+Calcula el destinatario final basado en el tipo de destinatario.
+
+**Parámetros:** Ninguno (compute method)
+
+**Retorna:** `str` - ID del destinatario final
+
+##### `_get_final_destination()`
+Método mejorado para obtener destino final (grupo o teléfono).
+
+**Parámetros:** Ninguno
+
+**Retorna:** `str|False` - ID del destino final o False
+
+**Ejemplo:**
+```python
+message = self.env['whatsapp.message'].browse(1)
+destination = message._get_final_destination()
+```
+
+##### `_send_message(with_commit=False)`
+Override del método de envío para soportar WhatsApp Web.
+
+**Parámetros:**
+- `with_commit` (bool): Si hacer commit después del envío
+
+**Retorna:** `None`
+
+**Ejemplo:**
+```python
+message = self.env['whatsapp.message'].browse(1)
+message._send_message(with_commit=True)
+```
+
+---
+
+### whatsapp.composer (Extendido)
+
+Extensión del composer de WhatsApp para soportar grupos y mensajes libres.
+
+#### Campos Adicionales
+
+| Campo | Tipo | Descripción |
+|-------|------|-------------|
+| `recipient_type` | Selection | Tipo de destinatario: 'phone' o 'group' |
+| `whatsapp_group_id` | Many2one | Referencia al grupo |
+| `whatsapp_group_id_char` | Char | ID de grupo manual |
+| `body` | Html | Mensaje libre (sin plantilla) |
+
+#### Métodos
+
+##### `_check_recipient_configuration()`
+Valida la configuración del destinatario en el composer.
+
+**Parámetros:** Ninguno (constraint method)
+
+**Retorna:** `None` (raise ValidationError si hay error)
+
+##### `_compute_invalid_phone_number_count()`
+Override para casos específicos de grupos.
+
+**Parámetros:** Ninguno (compute method)
+
+**Retorna:** `None`
+
+##### `_onchange_recipient_type()`
+Limpia campos al cambiar tipo de destinatario.
+
+**Parámetros:** Ninguno (onchange method)
+
+**Retorna:** `None`
+
+##### `action_send_whatsapp_template()`
+Override del método de envío para casos específicos de WhatsApp Web.
+
+**Parámetros:** Ninguno
+
+**Retorna:** `dict` - Acción de ventana o resultado del envío
+
+##### `_send_whatsapp_web_message()`
+Envía mensaje WhatsApp Web sin plantilla.
+
+**Parámetros:** Ninguno
+
+**Retorna:** `dict` - Acción de cierre de ventana
+
+**Ejemplo:**
+```python
+composer = self.env['whatsapp.composer'].create({
+    'recipient_type': 'group',
+    'whatsapp_group_id': group.id,
+    'body': 'Hola grupo!'
+})
+result = composer._send_whatsapp_web_message()
+```
+
+##### `_prepare_whatsapp_message_values(record)`
+Override para agregar información de grupo.
+
+**Parámetros:**
+- `record` (recordset): Registro relacionado
+
+**Retorna:** `dict` - Valores para crear mensaje WhatsApp
+
+##### `_get_whatsapp_web_account()`
+Obtiene cuenta de WhatsApp Web disponible.
+
+**Parámetros:** Ninguno
+
+**Retorna:** `whatsapp.account` - Cuenta WhatsApp Web
+
+**Ejemplo:**
+```python
+composer = self.env['whatsapp.composer'].create({})
+account = composer._get_whatsapp_web_account()
+```
+
+##### `_send_whatsapp_message_without_template(body, phone=None, group_id=None)`
+Envía mensaje de WhatsApp sin plantilla.
+
+**Parámetros:**
+- `body` (str): Contenido del mensaje
+- `phone` (str, opcional): Número de teléfono
+- `group_id` (str, opcional): ID del grupo
+
+**Retorna:** `whatsapp.message` - Mensaje creado
+
+**Ejemplo:**
+```python
+composer = self.env['whatsapp.composer'].create({})
+message = composer._send_whatsapp_message_without_template(
+    body="Mensaje de prueba",
+    group_id="120363158956331133@g.us"
+)
+```
+
+---
+
+## Ejemplos de Uso
+
+### Envío de Mensaje Individual
+
+```python
+# Crear composer para número individual
+composer = self.env['whatsapp.composer'].create({
+    'recipient_type': 'phone',
+    'phone': '+525551234567',
+    'wa_template_id': template.id,
+    'res_model': 'res.partner',
+    'res_id': partner.id
+})
+
+# Enviar mensaje
+result = composer.action_send_whatsapp_template()
+```
+
+### Envío de Mensaje a Grupo
+
+```python
+# Crear composer para grupo
+composer = self.env['whatsapp.composer'].create({
+    'recipient_type': 'group',
+    'whatsapp_group_id': group.id,
+    'body': '<p>Hola grupo! Este es un mensaje libre.</p>',
+    'res_model': 'ww.group',
+    'res_id': group.id
+})
+
+# Enviar mensaje
+result = composer.action_send_whatsapp_template()
+```
+
+### Envío con Adjunto
+
+```python
+# Crear attachment
+attachment = self.env['ir.attachment'].create({
+    'name': 'documento.pdf',
+    'type': 'binary',
+    'datas': base64.b64encode(pdf_content),
+    'mimetype': 'application/pdf'
+})
+
+# Crear composer con adjunto
+composer = self.env['whatsapp.composer'].create({
+    'recipient_type': 'phone',
+    'phone': '+525551234567',
+    'body': 'Adjunto documento importante',
+    'attachment_id': attachment.id,
+    'res_model': 'res.partner',
+    'res_id': partner.id
+})
+
+# Enviar mensaje
+result = composer.action_send_whatsapp_template()
+```
+
+### Obtener Grupos de WhatsApp Web
+
+```python
+# Obtener cuenta WhatsApp Web
+account = self.env['whatsapp.account'].search([
+    ('whatsapp_web_url', '!=', False)
+], limit=1)
+
+# Obtener grupos
+groups = account.get_groups()
+
+# Procesar grupos
+for group_data in groups:
+    group_id = group_data['id']['_serialized']
+    group_name = group_data['name']
+    members = group_data.get('members', [])
+    
+    print(f"Grupo: {group_name} ({group_id})")
+    print(f"Miembros: {len(members)}")
+```
+
+### Envío Directo de Mensaje
+
+```python
+# Crear mensaje directamente
+message = self.env['whatsapp.message'].create({
+    'recipient_type': 'group',
+    'whatsapp_group_id': group.id,
+    'mobile_number': group.whatsapp_web_id,
+    'body': 'Mensaje directo al grupo',
+    'wa_account_id': account.id,
+    'state': 'outgoing'
+})
+
+# Enviar mensaje
+message._send_message(with_commit=True)
+```
+
+## Respuestas de API
+
+### Respuesta Exitosa de Envío
+
+```json
+{
+    "_data": {
+        "id": {
+            "_serialized": "3EB0C767D26A3D1B7B4A_5215551234567@c.us"
+        }
+    }
+}
+```
+
+### Respuesta de Grupos
+
+```json
+[
+    {
+        "id": {
+            "_serialized": "120363158956331133@g.us"
+        },
+        "name": "Mi Grupo de Trabajo",
+        "description": "Grupo para coordinación de proyectos",
+        "members": [
+            {
+                "id": {
+                    "_serialized": "5215551234567@c.us"
+                },
+                "number": "5551234567",
+                "name": "Juan Pérez",
+                "pushname": "Juan",
+                "isAdmin": true,
+                "isSuperAdmin": false
+            },
+            {
+                "id": {
+                    "_serialized": "5215557654321@c.us"
+                },
+                "number": "5557654321",
+                "name": "María García",
+                "pushname": "María",
+                "isAdmin": false,
+                "isSuperAdmin": false
+            }
+        ],
+        "messages": [
+            {
+                "id": {
+                    "_serialized": "3EB0C767D26A3D1B7B4A"
+                },
+                "body": "Hola grupo!",
+                "author": "5215551234567@c.us",
+                "timestamp": 1640995200,
+                "hasQuotedMsg": false
+            }
+        ]
+    }
+]
+```
+
+### Respuesta de Error
+
+```json
+{
+    "error": "Método no implementado",
+    "code": 501
+}
+```
+
+## Códigos de Estado HTTP
+
+| Código | Descripción |
+|--------|-------------|
+| 200 | Petición exitosa |
+| 400 | Error en los parámetros |
+| 401 | No autorizado |
+| 404 | Endpoint no encontrado |
+| 500 | Error interno del servidor |
+| 501 | Método no implementado |
+
+## Formato de Números
+
+### Números Individuales
+- **Formato de entrada:** `+525551234567`, `5551234567`, `5215551234567`
+- **Formato interno:** `5215551234567@c.us`
+- **Reglas de formateo:**
+  - Se eliminan espacios, guiones y el símbolo +
+  - Si empieza con "52" y tiene 12 dígitos, se agrega "1"
+  - Si tiene 10 dígitos, se agrega "521"
+  - Se agrega sufijo "@c.us"
+
+### Grupos
+- **Formato:** `120363158956331133@g.us`
+- **Identificación:** Termina en "@g.us"
+- **No se aplica formateo:** Se usa tal como viene de WhatsApp Web
+
+## Manejo de Errores
+
+### Excepciones Comunes
+
+#### ValidationError
+```python
+from odoo.exceptions import ValidationError
+
+try:
+    composer.action_send_whatsapp_template()
+except ValidationError as e:
+    print(f"Error de validación: {e}")
+```
+
+#### ConnectionError
+```python
+import requests
+
+try:
+    response = requests.post(url, data=payload, headers=headers)
+except requests.exceptions.ConnectionError:
+    print("Error de conexión con el servidor WhatsApp Web")
+```
+
+### Logs de Debugging
+
+```python
+import logging
+_logger = logging.getLogger(__name__)
+
+# Log de envío exitoso
+_logger.info('WHATSAPP WEB SEND MESSAGE: %s', url)
+
+# Log de error
+_logger.error("Error en la petición de groups: %s", response.text)
+```
+
+## Configuración de Timeouts
+
+```python
+# Configurar timeout para requests
+import requests
+
+response = requests.post(
+    url, 
+    data=payload, 
+    headers=headers,
+    timeout=30  # 30 segundos
+)
+```
+
+## Rate Limiting
+
+El módulo incluye delays aleatorios entre envíos:
+
+```python
+import time
+import random
+
+# Delay aleatorio entre 3-7 segundos
+time.sleep(random.randint(3, 7))
+```
+

+ 225 - 0
README.md

@@ -0,0 +1,225 @@
+# WhatsApp Web Integration for Odoo 18
+
+## Descripción
+
+Este módulo integra WhatsApp con la automatización de marketing de Odoo, permitiendo enviar mensajes de WhatsApp a través de una URL personalizada usando whatsapp-web.js. Proporciona funcionalidad completa para enviar mensajes tanto a números telefónicos como a grupos de WhatsApp.
+
+## Características Principales
+
+- ✅ Envío de mensajes a números telefónicos individuales
+- ✅ Envío de mensajes a grupos de WhatsApp
+- ✅ Soporte para plantillas de WhatsApp Business API
+- ✅ Mensajes de texto libre (sin plantilla) para WhatsApp Web
+- ✅ Gestión de adjuntos y archivos multimedia
+- ✅ Respuestas a mensajes (quoted messages)
+- ✅ Integración con el sistema de marketing de Odoo
+- ✅ Configuración flexible de cuentas WhatsApp Web
+
+## Requisitos
+
+- Odoo 18.0
+- Módulo `whatsapp` (dependencia)
+- Servidor whatsapp-web.js configurado y funcionando
+
+## Instalación
+
+1. **Instalar el módulo:**
+   ```bash
+   cd /var/odoo/mcteam.run
+   sudo -u odoo venv/bin/python3 src/odoo-bin -c odoo.conf -i whatsapp_web
+   ```
+
+2. **Reiniciar el servidor Odoo:**
+   ```bash
+   ./restart_odoo.sh
+   ```
+
+## Configuración
+
+### 1. Configuración General
+
+1. Ir a **Configuración > Técnico > Parámetros del Sistema**
+2. Buscar el parámetro `whatsapp_web.whatsapp_endpoint`
+3. Establecer la URL de tu servidor whatsapp-web.js (ej: `https://web.whatsapp.com/api`)
+
+### 2. Configuración de Cuenta WhatsApp
+
+1. Ir a **WhatsApp > Configuración > Cuentas WhatsApp**
+2. Crear o editar una cuenta WhatsApp
+3. En la sección "WhatsApp Web", configurar:
+   - **WhatsApp Web URL**: URL de tu servidor whatsapp-web.js
+4. Guardar la configuración
+
+### 3. Configuración de Grupos (Opcional)
+
+Si deseas enviar mensajes a grupos, instala también el módulo `whatsapp_web_groups`:
+
+```bash
+sudo -u odoo venv/bin/python3 src/odoo-bin -c odoo.conf -i whatsapp_web_groups
+```
+
+## Uso
+
+### Envío de Mensajes Individuales
+
+1. **Desde el Composer de WhatsApp:**
+   - Seleccionar "Phone Number" como tipo de destinatario
+   - Ingresar el número de teléfono
+   - Elegir una plantilla o escribir mensaje libre
+   - Enviar
+
+2. **Desde Marketing:**
+   - Crear una campaña de marketing
+   - Configurar plantillas WhatsApp
+   - Ejecutar campaña
+
+### Envío de Mensajes a Grupos
+
+1. **Desde el Composer:**
+   - Seleccionar "WhatsApp Group" como tipo de destinatario
+   - Elegir un grupo de la lista o ingresar ID manualmente
+   - Escribir mensaje libre o usar plantilla
+   - Enviar
+
+2. **Desde la vista de Grupos:**
+   - Ir a **WhatsApp Web > Grupos**
+   - Seleccionar un grupo
+   - Hacer clic en "Send WhatsApp Message"
+
+### Formato de Números
+
+- **Números individuales**: Se formatean automáticamente con prefijo de país (ej: `5215551234567@c.us`)
+- **Grupos**: Usan formato `120363158956331133@g.us`
+
+### Mensajes con Adjuntos
+
+El módulo soporta envío de:
+- Documentos PDF
+- Imágenes
+- Videos
+- Audio
+- Cualquier tipo de archivo soportado por WhatsApp
+
+## API del Módulo
+
+### Métodos Principales
+
+#### `whatsapp.account.get_groups()`
+Obtiene la lista de grupos disponibles en WhatsApp Web.
+
+```python
+account = self.env['whatsapp.account'].browse(1)
+groups = account.get_groups()
+```
+
+#### `whatsapp.message._send_message()`
+Envía un mensaje WhatsApp usando la configuración de la cuenta.
+
+#### `whatsapp.composer.action_send_whatsapp_template()`
+Procesa el envío desde el composer, soportando tanto plantillas como mensajes libres.
+
+## Estructura de Datos
+
+### Modelos Principales
+
+#### `whatsapp.account` (Extendido)
+- `whatsapp_web_url`: URL del servidor whatsapp-web.js
+
+#### `whatsapp.message` (Extendido)
+- `recipient_type`: Tipo de destinatario ('phone' o 'group')
+- `whatsapp_group_id`: Referencia al grupo (si aplica)
+- `final_recipient`: Destinatario final calculado
+
+#### `whatsapp.composer` (Extendido)
+- `recipient_type`: Tipo de destinatario
+- `whatsapp_group_id`: Grupo seleccionado
+- `whatsapp_group_id_char`: ID de grupo manual
+- `body`: Mensaje libre (sin plantilla)
+
+## Configuración del Servidor whatsapp-web.js
+
+### Ejemplo de Configuración
+
+```javascript
+// Ejemplo de servidor básico
+const express = require('express');
+const { Client } = require('whatsapp-web.js');
+
+const app = express();
+app.use(express.json());
+
+const client = new Client();
+
+client.on('ready', () => {
+    console.log('WhatsApp Web está listo');
+});
+
+// Endpoint para enviar mensajes
+app.post('/', async (req, res) => {
+    const { method, args } = req.body;
+    
+    if (method === 'sendMessage') {
+        const [to, content, options] = args;
+        const message = await client.sendMessage(to, content, options);
+        res.json({ _data: { id: { _serialized: message.id._serialized } } });
+    }
+});
+
+client.initialize();
+app.listen(3000);
+```
+
+## Solución de Problemas
+
+### Error: "No WhatsApp Web account configured"
+- Verificar que la cuenta WhatsApp tenga configurada la URL de WhatsApp Web
+- Confirmar que la URL sea accesible desde el servidor Odoo
+
+### Error: "Error en la petición de groups"
+- Verificar conectividad con el servidor whatsapp-web.js
+- Confirmar que el servidor esté ejecutándose y respondiendo
+- Revisar logs del servidor para errores específicos
+
+### Mensajes no se envían
+- Verificar que WhatsApp Web esté conectado en el servidor
+- Confirmar que el número de teléfono esté en formato correcto
+- Revisar logs de Odoo para errores de la API
+
+### Grupos no aparecen
+- Ejecutar sincronización manual desde el menú de grupos
+- Verificar que el método `getGroups` esté implementado en el servidor
+- Confirmar permisos de lectura de grupos en WhatsApp
+
+## Logs y Debugging
+
+Los logs se pueden encontrar en:
+- **Logs de Odoo**: `/var/odoo/stg2.mcteam.run/logs/odoo-server.log`
+- **Logs del servidor whatsapp-web.js**: Revisar configuración del servidor
+
+Para debugging, buscar en los logs:
+- `WHATSAPP WEB SEND MESSAGE`
+- `Error en la petición`
+- `Petición exitosa`
+
+## Actualizaciones
+
+Para actualizar el módulo:
+
+```bash
+cd /var/odoo/mcteam.run
+sudo -u odoo venv/bin/python3 src/odoo-bin -c odoo.conf -u whatsapp_web
+./restart_odoo.sh
+```
+
+## Soporte
+
+Para soporte técnico o reportar bugs, contactar al equipo de desarrollo.
+
+## Changelog
+
+### Versión 1.0
+- Implementación inicial
+- Soporte para mensajes individuales y grupos
+- Integración con whatsapp-web.js
+- Soporte para plantillas y mensajes libres
+

+ 661 - 0
TROUBLESHOOTING.md

@@ -0,0 +1,661 @@
+# Guía de Solución de Problemas - WhatsApp Web Integration
+
+## Índice
+- [Problemas de Configuración](#problemas-de-configuración)
+- [Problemas de Conexión](#problemas-de-conexión)
+- [Problemas de Envío de Mensajes](#problemas-de-envío-de-mensajes)
+- [Problemas de Grupos](#problemas-de-grupos)
+- [Problemas de Sincronización](#problemas-de-sincronización)
+- [Problemas de Rendimiento](#problemas-de-rendimiento)
+- [Logs y Debugging](#logs-y-debugging)
+
+## Problemas de Configuración
+
+### Error: "No WhatsApp Web account configured"
+
+**Síntomas:**
+- Mensaje de error al intentar enviar mensajes
+- No se pueden configurar grupos
+
+**Causas:**
+1. No se ha configurado la URL de WhatsApp Web en la cuenta
+2. La cuenta WhatsApp no tiene el campo `whatsapp_web_url` configurado
+3. El módulo `whatsapp_web` no está instalado
+
+**Soluciones:**
+```bash
+# 1. Verificar instalación del módulo
+cd /var/odoo/mcteam.run
+sudo -u odoo venv/bin/python3 src/odoo-bin shell -c odoo.conf
+
+# En el shell de Odoo:
+env['ir.module.module'].search([('name', '=', 'whatsapp_web')]).state
+```
+
+```python
+# 2. Verificar configuración de cuenta
+account = env['whatsapp.account'].search([], limit=1)
+print(f"URL configurada: {account.whatsapp_web_url}")
+```
+
+```bash
+# 3. Configurar cuenta manualmente desde base de datos
+su - postgres -c "psql stg2.mcteam.run -c \"UPDATE whatsapp_account SET whatsapp_web_url = 'https://tu-servidor.com/api' WHERE id = 1;\" | cat"
+```
+
+### Error: "Template is required for WhatsApp Business API"
+
+**Síntomas:**
+- No se pueden enviar mensajes libres (sin plantilla)
+- Error al usar composer sin plantilla
+
+**Causas:**
+1. No hay cuentas WhatsApp Web configuradas
+2. Se está intentando usar mensaje libre con API oficial
+
+**Soluciones:**
+```python
+# Verificar cuentas WhatsApp Web disponibles
+accounts = env['whatsapp.account'].search([('whatsapp_web_url', '!=', False)])
+print(f"Cuentas WhatsApp Web: {len(accounts)}")
+
+# Si no hay cuentas, configurar una
+account = env['whatsapp.account'].search([], limit=1)
+account.write({'whatsapp_web_url': 'https://tu-servidor.com/api'})
+```
+
+### Error: "Invalid recipient configuration"
+
+**Síntomas:**
+- Error al seleccionar destinatario en composer
+- Validación falla al enviar mensajes
+
+**Causas:**
+1. Tipo de destinatario no coincide con datos proporcionados
+2. Falta información requerida (teléfono o grupo)
+
+**Soluciones:**
+```python
+# Verificar configuración de destinatario
+composer = env['whatsapp.composer'].browse(composer_id)
+print(f"Tipo destinatario: {composer.recipient_type}")
+print(f"Teléfono: {composer.phone}")
+print(f"Grupo: {composer.whatsapp_group_id}")
+
+# Corregir configuración
+if composer.recipient_type == 'group' and not composer.whatsapp_group_id:
+    # Seleccionar grupo o cambiar a teléfono
+    composer.write({'recipient_type': 'phone'})
+```
+
+## Problemas de Conexión
+
+### Error: "Connection refused" o timeout
+
+**Síntomas:**
+- Timeouts en peticiones HTTP
+- Error de conexión al servidor whatsapp-web.js
+
+**Diagnóstico:**
+```bash
+# 1. Verificar conectividad desde servidor Odoo
+curl -X POST https://tu-servidor-whatsapp.com/api \
+  -H "Content-Type: application/json" \
+  -d '{"method": "ping", "args": []}'
+
+# 2. Verificar logs del servidor whatsapp-web.js
+tail -f /ruta/logs/whatsapp-web.log
+```
+
+**Soluciones:**
+```python
+# 1. Verificar URL en configuración
+account = env['whatsapp.account'].search([('whatsapp_web_url', '!=', False)], limit=1)
+print(f"URL configurada: {account.whatsapp_web_url}")
+
+# 2. Probar conectividad desde Odoo
+import requests
+try:
+    response = requests.post(account.whatsapp_web_url, 
+                           json={"method": "ping", "args": []}, 
+                           timeout=10)
+    print(f"Respuesta: {response.status_code}")
+except Exception as e:
+    print(f"Error de conexión: {e}")
+```
+
+### Error: "Invalid SSL certificate"
+
+**Síntomas:**
+- Error SSL al conectar con servidor HTTPS
+- Certificado no válido
+
+**Soluciones:**
+```python
+# Deshabilitar verificación SSL (solo para desarrollo)
+import requests
+requests.packages.urllib3.disable_warnings()
+
+response = requests.post(url, json=payload, verify=False)
+```
+
+```bash
+# Actualizar certificados del sistema
+sudo apt-get update
+sudo apt-get install ca-certificates
+```
+
+### Error: "Method not implemented"
+
+**Síntomas:**
+- El servidor responde pero no implementa métodos requeridos
+- Error 501 en peticiones
+
+**Diagnóstico:**
+```bash
+# Verificar métodos implementados en el servidor
+curl -X POST https://tu-servidor.com/api \
+  -H "Content-Type: application/json" \
+  -d '{"method": "getGroups", "args": []}'
+```
+
+**Soluciones:**
+```javascript
+// Implementar método getGroups en servidor whatsapp-web.js
+app.post('/', async (req, res) => {
+    const { method, args } = req.body;
+    
+    if (method === 'getGroups') {
+        try {
+            const chats = await client.getChats();
+            const groups = chats.filter(chat => chat.isGroup);
+            res.json(groups);
+        } catch (error) {
+            res.status(500).json({error: error.message});
+        }
+    }
+});
+```
+
+## Problemas de Envío de Mensajes
+
+### Error: "Message not sent"
+
+**Síntomas:**
+- Mensajes quedan en estado "outgoing"
+- No se reciben mensajes en WhatsApp
+
+**Diagnóstico:**
+```python
+# Verificar estado de mensajes
+messages = env['whatsapp.message'].search([
+    ('state', '=', 'outgoing'),
+    ('create_date', '>', '2024-01-01')
+])
+for msg in messages:
+    print(f"Mensaje {msg.id}: {msg.state} - {msg.mobile_number}")
+```
+
+**Soluciones:**
+```python
+# 1. Verificar configuración de cuenta
+message = env['whatsapp.message'].browse(message_id)
+print(f"Cuenta: {message.wa_account_id.name}")
+print(f"URL: {message.wa_account_id.whatsapp_web_url}")
+
+# 2. Reintentar envío
+message._send_message(with_commit=True)
+
+# 3. Verificar formato de número
+print(f"Número original: {message.mobile_number}")
+formatted = message._whatsapp_phone_format()
+print(f"Número formateado: {formatted}")
+```
+
+### Error: "Invalid phone number format"
+
+**Síntomas:**
+- Números no se formatean correctamente
+- Error en validación de teléfonos
+
+**Diagnóstico:**
+```python
+# Verificar formateo de números
+message = env['whatsapp.message'].browse(message_id)
+original = message.mobile_number
+formatted = message._whatsapp_phone_format()
+print(f"Original: {original}")
+print(f"Formateado: {formatted}")
+```
+
+**Soluciones:**
+```python
+# Corregir formato manualmente
+def format_phone_number(number):
+    # Limpiar número
+    clean = number.replace(' ', '').replace('+', '').replace('-', '')
+    
+    # Aplicar reglas de formateo
+    if clean.startswith("52") and len(clean) == 12:
+        clean = "521" + clean[2:]
+    elif len(clean) == 10:
+        clean = "521" + clean
+    
+    return clean + '@c.us'
+
+# Aplicar corrección
+message.write({
+    'mobile_number': format_phone_number(message.mobile_number)
+})
+```
+
+### Error: "Attachment upload failed"
+
+**Síntomas:**
+- Adjuntos no se envían
+- Error al procesar archivos
+
+**Diagnóstico:**
+```python
+# Verificar adjunto
+attachment = env['ir.attachment'].browse(attachment_id)
+print(f"Nombre: {attachment.name}")
+print(f"Tipo: {attachment.mimetype}")
+print(f"Tamaño: {attachment.file_size}")
+print(f"Datos: {len(attachment.raw) if attachment.raw else 'No hay datos'}")
+```
+
+**Soluciones:**
+```python
+# 1. Verificar tamaño de archivo (límite WhatsApp: 100MB)
+if attachment.file_size > 100 * 1024 * 1024:
+    print("Archivo demasiado grande")
+
+# 2. Verificar tipo MIME
+valid_types = ['image/', 'video/', 'audio/', 'application/pdf', 'text/']
+if not any(attachment.mimetype.startswith(t) for t in valid_types):
+    print(f"Tipo de archivo no soportado: {attachment.mimetype}")
+
+# 3. Regenerar datos del archivo
+attachment.write({'raw': base64.b64encode(file_content)})
+```
+
+## Problemas de Grupos
+
+### Error: "Group not found"
+
+**Síntomas:**
+- Grupos no aparecen en la lista
+- Error al seleccionar grupo
+
+**Diagnóstico:**
+```python
+# Verificar grupos disponibles
+groups = env['ww.group'].search([])
+print(f"Grupos en base de datos: {len(groups)}")
+
+# Verificar sincronización
+account = env['whatsapp.account'].search([('whatsapp_web_url', '!=', False)], limit=1)
+api_groups = account.get_groups()
+print(f"Grupos desde API: {len(api_groups)}")
+```
+
+**Soluciones:**
+```python
+# 1. Sincronizar grupos manualmente
+env['ww.group'].sync_ww_contacts_groups()
+
+# 2. Verificar respuesta de API
+for group_data in api_groups:
+    print(f"Grupo API: {group_data.get('name')} - {group_data.get('id', {}).get('_serialized')}")
+
+# 3. Crear grupo manualmente si es necesario
+group = env['ww.group'].create({
+    'name': 'Mi Grupo',
+    'whatsapp_web_id': '120363158956331133@g.us',
+    'whatsapp_account_id': account.id
+})
+```
+
+### Error: "No members in group"
+
+**Síntomas:**
+- Grupos sin contactos
+- Canales no se crean
+
+**Diagnóstico:**
+```python
+# Verificar miembros de grupo
+group = env['ww.group'].browse(group_id)
+print(f"Contactos en grupo: {len(group.contact_ids)}")
+
+# Verificar datos de API
+account = group.whatsapp_account_id
+api_groups = account.get_groups()
+for api_group in api_groups:
+    if api_group['id']['_serialized'] == group.whatsapp_web_id:
+        members = api_group.get('members', [])
+        print(f"Miembros en API: {len(members)}")
+        break
+```
+
+**Soluciones:**
+```python
+# 1. Sincronizar contactos del grupo
+group.write({'contact_ids': [(6, 0, contact_ids)]})
+
+# 2. Crear contactos si no existen
+for member in members:
+    contact = env['res.partner'].search([
+        ('whatsapp_web_id', '=', member['id']['_serialized'])
+    ])
+    
+    if not contact:
+        contact = env['res.partner'].create({
+            'name': member.get('name', 'Sin nombre'),
+            'mobile': member.get('number', ''),
+            'whatsapp_web_id': member['id']['_serialized']
+        })
+    
+    contact_ids.append(contact.id)
+```
+
+## Problemas de Sincronización
+
+### Error: "Sync failed" o timeout
+
+**Síntomas:**
+- Sincronización se interrumpe
+- Timeouts en operaciones largas
+
+**Diagnóstico:**
+```python
+# Verificar logs de sincronización
+import logging
+_logger = logging.getLogger(__name__)
+
+# Ejecutar sincronización con logging detallado
+try:
+    env['ww.group'].sync_ww_contacts_groups()
+except Exception as e:
+    _logger.error(f"Error en sincronización: {e}")
+```
+
+**Soluciones:**
+```python
+# 1. Sincronizar en lotes pequeños
+def sync_in_batches(batch_size=5):
+    accounts = env['whatsapp.account'].search([('whatsapp_web_url', '!=', False)])
+    
+    for account in accounts:
+        groups_data = account.get_groups()
+        
+        for i in range(0, len(groups_data), batch_size):
+            batch = groups_data[i:i + batch_size]
+            process_batch(batch, account)
+            env.cr.commit()  # Commit después de cada lote
+
+# 2. Aumentar timeout de requests
+import requests
+response = requests.post(url, json=payload, timeout=60)
+```
+
+### Error: "Duplicate contacts"
+
+**Síntomas:**
+- Contactos duplicados en grupos
+- Constraint violations
+
+**Diagnóstico:**
+```python
+# Buscar contactos duplicados
+contacts = env['res.partner'].search([
+    ('whatsapp_web_id', '!=', False)
+])
+
+# Verificar duplicados por número
+mobile_counts = {}
+for contact in contacts:
+    if contact.mobile:
+        mobile_counts[contact.mobile] = mobile_counts.get(contact.mobile, 0) + 1
+
+duplicates = {k: v for k, v in mobile_counts.items() if v > 1}
+print(f"Duplicados por móvil: {duplicates}")
+```
+
+**Soluciones:**
+```python
+# 1. Fusionar contactos duplicados
+def merge_duplicate_contacts():
+    contacts = env['res.partner'].search([('whatsapp_web_id', '!=', False)])
+    
+    for contact in contacts:
+        if contact.mobile and len(contact.mobile) >= 10:
+            last_10 = contact.mobile[-10:]
+            duplicates = env['res.partner'].search([
+                ('mobile', 'like', '%' + last_10),
+                ('id', '!=', contact.id)
+            ])
+            
+            if duplicates:
+                # Fusionar en el primer contacto
+                for dup in duplicates:
+                    dup.unlink()
+
+# 2. Limpiar relaciones duplicadas
+def cleanup_duplicate_relations():
+    relations = env['ww.group_contact_rel'].search([])
+    
+    for rel in relations:
+        duplicates = env['ww.group_contact_rel'].search([
+            ('group_id', '=', rel.group_id.id),
+            ('contact_id', '=', rel.contact_id.id),
+            ('id', '!=', rel.id)
+        ])
+        
+        if duplicates:
+            duplicates.unlink()
+```
+
+## Problemas de Rendimiento
+
+### Error: "Memory limit exceeded"
+
+**Síntomas:**
+- Procesos se terminan por falta de memoria
+- Sincronización falla con muchos datos
+
+**Soluciones:**
+```python
+# 1. Procesar en chunks más pequeños
+def process_large_dataset(data, chunk_size=100):
+    for i in range(0, len(data), chunk_size):
+        chunk = data[i:i + chunk_size]
+        process_chunk(chunk)
+        env.cr.commit()  # Commit frecuente
+        gc.collect()  # Liberar memoria
+
+# 2. Usar iteradores en lugar de listas
+def process_contacts_efficiently():
+    contacts = env['res.partner'].search([('whatsapp_web_id', '!=', False)])
+    
+    for contact in contacts:
+        process_contact(contact)
+        if contact.id % 100 == 0:  # Commit cada 100 registros
+            env.cr.commit()
+```
+
+### Error: "Database timeout"
+
+**Síntomas:**
+- Operaciones de base de datos timeout
+- Transacciones muy largas
+
+**Soluciones:**
+```python
+# 1. Usar transacciones más cortas
+def sync_with_short_transactions():
+    accounts = env['whatsapp.account'].search([])
+    
+    for account in accounts:
+        with env.cr.savepoint():  # Transacción por cuenta
+            sync_account_groups(account)
+            env.cr.commit()
+
+# 2. Deshabilitar autocommit para operaciones masivas
+def bulk_operations():
+    env.cr.autocommit = False
+    try:
+        # Operaciones masivas
+        bulk_create_contacts()
+        bulk_update_groups()
+        env.cr.commit()
+    except Exception:
+        env.cr.rollback()
+        raise
+    finally:
+        env.cr.autocommit = True
+```
+
+## Logs y Debugging
+
+### Configurar Logging Detallado
+
+```python
+# Configurar logging en código
+import logging
+_logger = logging.getLogger(__name__)
+
+# Logging detallado para debugging
+_logger.setLevel(logging.DEBUG)
+
+def debug_sync_process():
+    _logger.info("Iniciando sincronización")
+    
+    try:
+        # Proceso de sincronización
+        result = env['ww.group'].sync_ww_contacts_groups()
+        _logger.info(f"Sincronización completada: {result}")
+        
+    except Exception as e:
+        _logger.error(f"Error en sincronización: {e}", exc_info=True)
+```
+
+### Verificar Logs del Sistema
+
+```bash
+# Ver logs de Odoo en tiempo real
+tail -f /var/odoo/stg2.mcteam.run/logs/odoo-server.log | grep -i "whatsapp"
+
+# Filtrar por módulo específico
+tail -f /var/odoo/stg2.mcteam.run/logs/odoo-server.log | grep -i "whatsapp_web"
+
+# Buscar errores específicos
+grep -i "error\|exception\|traceback" /var/odoo/stg2.mcteam.run/logs/odoo-server.log | grep -i "whatsapp"
+```
+
+### Debug desde Shell de Odoo
+
+```bash
+# Acceder al shell de Odoo
+cd /var/odoo/mcteam.run
+sudo -u odoo venv/bin/python3 src/odoo-bin shell -c odoo.conf
+```
+
+```python
+# En el shell de Odoo
+# Verificar estado de módulos
+modules = env['ir.module.module'].search([('name', 'ilike', 'whatsapp')])
+for module in modules:
+    print(f"{module.name}: {module.state}")
+
+# Verificar configuración
+accounts = env['whatsapp.account'].search([])
+for account in accounts:
+    print(f"Cuenta: {account.name}")
+    print(f"URL: {account.whatsapp_web_url}")
+    print(f"Activa: {account.active}")
+
+# Probar conectividad
+import requests
+account = accounts[0]
+try:
+    response = requests.post(account.whatsapp_web_url, 
+                           json={"method": "ping", "args": []}, 
+                           timeout=10)
+    print(f"Respuesta: {response.status_code}")
+except Exception as e:
+    print(f"Error: {e}")
+```
+
+### Debug de Base de Datos
+
+```bash
+# Verificar registros en base de datos
+su - postgres -c "psql stg2.mcteam.run -c \"SELECT COUNT(*) FROM ww_group;\" | cat"
+
+# Verificar configuración de cuentas
+su - postgres -c "psql stg2.mcteam.run -c \"SELECT name, whatsapp_web_url FROM whatsapp_account;\" | cat"
+
+# Verificar mensajes pendientes
+su - postgres -c "psql stg2.mcteam.run -c \"SELECT COUNT(*) FROM whatsapp_message WHERE state = 'outgoing';\" | cat"
+```
+
+## Herramientas de Diagnóstico
+
+### Script de Diagnóstico Completo
+
+```python
+def diagnose_whatsapp_web():
+    """Script completo de diagnóstico"""
+    
+    print("=== DIAGNÓSTICO WHATSAPP WEB ===")
+    
+    # 1. Verificar módulos
+    print("\n1. Verificando módulos...")
+    modules = env['ir.module.module'].search([('name', 'ilike', 'whatsapp')])
+    for module in modules:
+        print(f"  {module.name}: {module.state}")
+    
+    # 2. Verificar cuentas
+    print("\n2. Verificando cuentas...")
+    accounts = env['whatsapp.account'].search([])
+    for account in accounts:
+        print(f"  {account.name}: {account.whatsapp_web_url or 'Sin URL'}")
+    
+    # 3. Verificar grupos
+    print("\n3. Verificando grupos...")
+    groups = env['ww.group'].search([])
+    print(f"  Total grupos: {len(groups)}")
+    for group in groups:
+        print(f"    {group.name}: {len(group.contact_ids)} contactos")
+    
+    # 4. Verificar mensajes
+    print("\n4. Verificando mensajes...")
+    messages = env['whatsapp.message'].search([])
+    print(f"  Total mensajes: {len(messages)}")
+    
+    outgoing = messages.filtered(lambda m: m.state == 'outgoing')
+    print(f"  Pendientes: {len(outgoing)}")
+    
+    # 5. Probar conectividad
+    print("\n5. Probando conectividad...")
+    web_account = accounts.filtered(lambda a: a.whatsapp_web_url)
+    if web_account:
+        try:
+            import requests
+            response = requests.post(web_account[0].whatsapp_web_url, 
+                                   json={"method": "ping", "args": []}, 
+                                   timeout=10)
+            print(f"  Conectividad: OK ({response.status_code})")
+        except Exception as e:
+            print(f"  Conectividad: ERROR - {e}")
+    else:
+        print("  Conectividad: No hay cuentas WhatsApp Web configuradas")
+
+# Ejecutar diagnóstico
+diagnose_whatsapp_web()
+```
+
+Este script proporciona una visión completa del estado del sistema y ayuda a identificar problemas comunes.
+

+ 22 - 13
__manifest__.py

@@ -1,18 +1,27 @@
 {
-    'name': 'WhatsApp Web',
-    'version': '1.0',
-    'category': 'Marketing/Marketing',
-    'summary': 'Integra WhatsApp con la automatización de marketing',
-    'description': """
+    "name": "WhatsApp Web",
+    "version": "1.0",
+    "category": "Marketing/Marketing",
+    "summary": "Integra WhatsApp con la automatización de marketing",
+    "description": """
         Este módulo integra WhatsApp con la automatización de marketing de Odoo,
         permitiendo enviar mensajes de WhatsApp a través de una URL personalizada.
     """,
-    'author': 'Tu Nombre',
-    'website': 'https://www.tuempresa.com',
-    'depends': ['whatsapp'],
-    'data': [
-        'views/whatsapp_account_views.xml'
+    "author": "Tu Nombre",
+    "website": "https://www.tuempresa.com",
+    "depends": ["whatsapp", "marketing_automation_whatsapp"],
+    "data": [
+        "views/whatsapp_account_views.xml",
+        "views/whatsapp_message_views.xml",
+        "views/whatsapp_composer_views.xml",
     ],
-    'installable': True,
-    'auto_install': False,
-}
+    "assets": {
+        "web.assets_backend": [
+            "whatsapp_web/static/src/overrides/thread_model_patch.js",
+            "whatsapp_web/static/src/overrides/composer_patch.js",
+            "whatsapp_web/static/src/overrides/channel_member_list_patch.js",
+        ],
+    },
+    "installable": True,
+    "auto_install": False,
+}

BIN
__pycache__/__init__.cpython-310.pyc


+ 4 - 1
models/__init__.py

@@ -1,3 +1,6 @@
+from . import whatsapp_account
 from . import whatsapp_message
+from . import whatsapp_composer
 from . import whatsapp_patch
-from . import whatsapp_account
+from . import mail_message
+from . import discuss_channel

BIN
models/__pycache__/__init__.cpython-310.pyc


BIN
models/__pycache__/marketing_activity.cpython-310.pyc


BIN
models/__pycache__/res_config_settings.cpython-310.pyc


BIN
models/__pycache__/whatsapp_message.cpython-310.pyc


+ 142 - 0
models/discuss_channel.py

@@ -0,0 +1,142 @@
+from datetime import datetime, timedelta
+from odoo import models, api, fields
+from odoo.addons.mail.tools.discuss import Store
+from odoo.exceptions import ValidationError
+
+
+class DiscussChannel(models.Model):
+    _inherit = "discuss.channel"
+
+    is_whatsapp_web = fields.Boolean(compute="_compute_is_whatsapp_web")
+
+    def write(self, vals):
+        """
+        Override write to debounce 'last_interest_dt' updates.
+        If the channel was updated less than 10 seconds ago, skip updating last_interest_dt.
+        This prevents 'concurrent update' errors during high traffic (e.g. active WhatsApp groups).
+        """
+        if 'last_interest_dt' in vals and len(self) == 1:
+            # Check if we have a recent update
+            if self.last_interest_dt:
+                # Calculate time since last update
+                # Note: last_interest_dt is usually UTC
+                time_since_last = datetime.now() - self.last_interest_dt
+                if time_since_last < timedelta(seconds=10):
+                    # Skip updating this field
+                    del vals['last_interest_dt']
+        
+        return super().write(vals)
+
+    @api.depends("channel_type", "wa_account_id.whatsapp_web_url")
+    def _compute_is_whatsapp_web(self):
+        for record in self:
+            record.is_whatsapp_web = record.channel_type == "whatsapp" and bool(
+                record.wa_account_id.whatsapp_web_url
+            )
+
+    def _to_store(self, store: Store):
+        """
+        Send is_whatsapp_web to the frontend via Store.
+        """
+        super()._to_store(store)
+        for channel in self:
+            if channel.is_whatsapp_web:
+                store.add(channel, {"is_whatsapp_web": True})
+
+    def message_post(self, **kwargs):
+        """
+        Override message_post to allow sending free text messages in WhatsApp Web channels.
+        Standard Odoo WhatsApp module might block or restrict messages without templates.
+        """
+        # Check if it's a WhatsApp channel with WhatsApp Web configured
+        if self.channel_type == "whatsapp" and self.wa_account_id.whatsapp_web_url:
+            # We want to use our custom logic for these channels
+            # Extract basic message data
+            body = kwargs.get("body", "")
+            attachment_ids = kwargs.get("attachment_ids", [])
+
+            # If it's a simple text message or has attachments, we handle it.
+            # Note: We need to ensure we don't break other message_post usages (like system notifications)
+            # System notifications usually have subtype_xmlid='mail.mt_note' or similar, strict check might be needed.
+
+            # Let's check if we should intervene.
+            # If the user is trying to send a message (comment)
+            if kwargs.get("message_type") == "comment" or not kwargs.get(
+                "message_type"
+            ):
+
+                # Check for attachments in kwargs (can be list of IDs or list of tuples)
+                # We mainly care about passing them to the mail.message
+
+                # 1. Create the mail.message manually to bypass potential blocks in super().message_post()
+                # We need to replicate some logic from mail.thread.message_post
+
+                # However, completely skipping super() is risky for notifications/followers.
+                # Let's try a hybrid approach:
+                # Create the message using mail.message.create() directly, then run necessary side effects?
+                # Or invoke mail.thread's message_post directly if possible?
+                # We can't easily invoke 'grandparent' methods in Odoo new API unless we are careful.
+
+                # Simplified approach: mimic whatsapp_composer logic
+
+                email_from = kwargs.get("email_from")
+                if not email_from:
+                    email_from = self.env.user.email_formatted
+
+                # Create mail.message
+                msg_values = {
+                    "body": body,
+                    "model": self._name,
+                    "res_id": self.id,
+                    "message_type": "whatsapp_message",  # Use whatsapp_message type so our other logic picks it up? Or 'comment'?
+                    # Standard WA uses 'whatsapp_message'
+                    "email_from": email_from,
+                    "partner_ids": [
+                        (4, p.id) for p in self.channel_partner_ids
+                    ],  # Add channel partners?
+                    # 'subtype_id': ...
+                    "attachment_ids": attachment_ids,
+                }
+
+                # Handle author
+                author_id = kwargs.get("author_id")
+                if author_id:
+                    msg_values["author_id"] = author_id
+                else:
+                    msg_values["author_id"] = self.env.user.partner_id.id
+
+                # Create the message
+                message = self.env["mail.message"].create(msg_values)
+
+                # Now create the whatsapp.message to trigger sending (via our overridden _send_message or similar)
+                # Note: whatsapp_message.create() triggers _send_message() if state is outgoing?
+                # In our whatsapp_composer, we called _send_message() explicitly.
+
+                # Determine recipient (Phone or Group)
+                mobile_number = self.whatsapp_number
+                recipient_type = "phone"
+                if mobile_number and mobile_number.endswith("@g.us"):
+                    recipient_type = "group"
+
+                wa_msg_values = {
+                    "mail_message_id": message.id,
+                    "wa_account_id": self.wa_account_id.id,
+                    "mobile_number": mobile_number,
+                    "recipient_type": recipient_type,
+                    "wa_template_id": False,
+                    "body": body,
+                    "state": "outgoing",
+                }
+
+                wa_msg = self.env["whatsapp.message"].create(wa_msg_values)
+
+                # Send it
+                wa_msg._send_message()
+
+                # Ensure the message is linked to the channel (standard mail.message behavior should handle res_id/model)
+                # But Discuss expects the message to be in the channel.
+
+                return message
+
+        # Default behavior for other channels or if conditions not met
+        return super(DiscussChannel, self).message_post(**kwargs)

+ 158 - 0
models/mail_message.py

@@ -0,0 +1,158 @@
+import logging
+import requests
+from odoo import models
+from odoo.addons.mail.tools.discuss import Store
+from odoo.addons.whatsapp.tools.whatsapp_exception import WhatsAppError
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class MailMessage(models.Model):
+    _inherit = "mail.message"
+
+    def _message_reaction(self, content, action, partner, guest, store: Store = None):
+        """Sobrescribir para usar WhatsApp Web API Gateway cuando esté configurado"""
+        # Si es mensaje de WhatsApp, verificar si usa WhatsApp Web
+        if self.message_type == "whatsapp_message" and self.wa_message_ids:
+            wa_msg = self.wa_message_ids[0]
+
+            # Verificar si la cuenta usa WhatsApp Web
+            if wa_msg.wa_account_id and wa_msg.wa_account_id.whatsapp_web_url:
+                # Usar API Gateway para WhatsApp Web
+                self._send_whatsapp_web_reaction(
+                    wa_msg, content, action, partner, guest, store
+                )
+                # Actualizar UI directamente usando el método base de mail (sin pasar por enterprise)
+                # Esto evita que el método del enterprise intente enviar de nuevo
+                return self._update_reaction_ui(content, action, partner, guest, store)
+            else:
+                # Usar método original para WhatsApp Business API (enterprise)
+                # Este llamará a super() al final para actualizar la UI
+                return super()._message_reaction(content, action, partner, guest, store)
+
+        # Para mensajes que no son de WhatsApp, usar método base
+        return super()._message_reaction(content, action, partner, guest, store)
+
+    def _update_reaction_ui(self, content, action, partner, guest, store: Store = None):
+        """Actualizar la UI de reacciones sin intentar enviar (para WhatsApp Web)"""
+        self.ensure_one()
+        # Buscar reacción existente
+        domain = [
+            ("message_id", "=", self.id),
+            ("partner_id", "=", partner.id),
+            ("guest_id", "=", guest.id),
+            ("content", "=", content),
+        ]
+        reaction = self.env["mail.message.reaction"].search(domain)
+        # Crear/eliminar reacción según la acción
+        if action == "add" and not reaction:
+            create_values = {
+                "message_id": self.id,
+                "content": content,
+                "partner_id": partner.id,
+                "guest_id": guest.id,
+            }
+            self.env["mail.message.reaction"].create(create_values)
+        if action == "remove" and reaction:
+            reaction.unlink()
+        if store:
+            # Llenar el store para usuarios portal no autenticados
+            self._reaction_group_to_store(store, content)
+        # Enviar el grupo de reacciones al bus para usuarios autenticados
+        self._bus_send_reaction_group(content)
+
+    def _send_whatsapp_web_reaction(
+        self, wa_msg, content, action, partner, guest, store: Store = None
+    ):
+        """Enviar reacción usando WhatsApp Web API Gateway"""
+        self.ensure_one()
+
+        account = wa_msg.wa_account_id
+        url = account.whatsapp_web_url
+        session_name = account.whatsapp_web_login
+        api_key = account.whatsapp_web_api_key
+
+        if not all([url, session_name, api_key]):
+            raise UserError(
+                "WhatsApp Web no está completamente configurado. Faltan URL, Login o API Key."
+            )
+
+        # Manejar reacciones previas (igual que el método original)
+        if action == "add":
+            previous_reaction = self.env["mail.message.reaction"].search(
+                [
+                    ("message_id", "=", self.id),
+                    ("partner_id", "=", partner.id),
+                    ("guest_id", "=", guest.id),
+                ],
+                limit=1,
+            )
+            if previous_reaction:
+                previous_reaction_emoji = previous_reaction.content
+                if previous_reaction_emoji == content:
+                    return
+                previous_reaction.unlink()
+                self._bus_send_reaction_group(previous_reaction_emoji)
+
+        # Obtener el ID del mensaje original
+        message_id = wa_msg.msg_uid
+        if not message_id:
+            raise UserError(
+                "No se puede enviar reacción: el mensaje no tiene ID válido."
+            )
+
+        # Obtener el ID del mensaje original
+        message_id = wa_msg.msg_uid
+        if not message_id:
+            raise UserError(
+                "No se puede enviar reacción: el mensaje no tiene ID válido."
+            )
+
+        _logger.info(
+            "DEBUG REACTION - Message Info: ID=%s, Body=%s, State=%s, Type=%s",
+            message_id,
+            wa_msg.body,
+            wa_msg.state,
+            wa_msg.message_type,
+        )
+
+        # Construir URL y payload para la API Gateway
+        base_url = url.rstrip("/")
+        endpoint = "react-message"
+        full_url = f"{base_url}/api/v1/{session_name}/{endpoint}"
+
+        # Determinar emoji (vacío si es remover)
+        emoji = content if action == "add" else ""
+
+        payload = {"msgId": message_id, "reaction": emoji}
+
+        headers = {"Content-Type": "application/json", "X-API-Key": api_key}
+
+        try:
+            _logger.info(
+                "Enviando reacción %s al mensaje %s", emoji or "vacía", message_id
+            )
+            response = requests.post(
+                full_url, json=payload, headers=headers, timeout=30
+            )
+
+            if response.status_code == 200:
+                _logger.info("Reacción enviada exitosamente a WhatsApp Web")
+                # No retornar aquí, dejar que el método padre actualice la UI
+                return
+            else:
+                error_text = response.text
+                _logger.error(
+                    "Error al enviar reacción. Código: %s, Respuesta: %s",
+                    response.status_code,
+                    error_text,
+                )
+                raise UserError(f"Error al enviar reacción: {error_text}")
+
+        except requests.exceptions.RequestException as e:
+            _logger.error("Error de conexión al enviar reacción: %s", str(e))
+            raise UserError(f"Error de conexión al enviar reacción: {str(e)}")
+        except Exception as e:
+            _logger.error("Error inesperado al enviar reacción: %s", str(e))
+            raise UserError(f"Error al enviar reacción: {str(e)}")

+ 421 - 3
models/whatsapp_account.py

@@ -1,10 +1,428 @@
 import logging
+import requests
+import json
+import mimetypes
+import base64
+from markupsafe import Markup
 
-from odoo import fields, models
+from odoo import fields, models, _
+from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
+from odoo.tools import plaintext2html
 
 _logger = logging.getLogger(__name__)
 
+
 class WhatsAppAccount(models.Model):
-    _inherit = ['whatsapp.account']
+    _inherit = "whatsapp.account"
+
+    whatsapp_web_url = fields.Char(
+        string="WhatsApp Web URL", readonly=False, copy=False
+    )
+    whatsapp_web_login = fields.Char(string="Login", readonly=False, copy=False)
+    whatsapp_web_api_key = fields.Char(string="API Key", readonly=False, copy=False)
+
+    def get_groups(self):
+        """
+        Obtiene los grupos de WhatsApp Web para la cuenta desde la base de datos de la plataforma.
+        Returns:
+            list: Lista de diccionarios con la información de los grupos en formato compatible con Odoo
+        """
+        self.ensure_one()
+
+        if not self.whatsapp_web_url:
+            _logger.warning(
+                "No se ha configurado la URL de WhatsApp Web para la cuenta %s",
+                self.name,
+            )
+            return []
+
+        if not self.whatsapp_web_login:
+            _logger.warning(
+                "No se ha configurado el Login (session_name) para la cuenta %s",
+                self.name,
+            )
+            return []
+
+        if not self.whatsapp_web_api_key:
+            _logger.warning(
+                "No se ha configurado la API Key para la cuenta %s", self.name
+            )
+            return []
+
+        try:
+            # Construir URL del nuevo endpoint
+            base_url = self.whatsapp_web_url.rstrip("/")
+            session_name = self.whatsapp_web_login
+            url = f"{base_url}/api/v1/{session_name}/groups"
+
+            headers = {
+                "Content-Type": "application/json",
+                "X-API-Key": self.whatsapp_web_api_key,
+            }
+
+            response = requests.get(url, headers=headers, timeout=30)
+
+            if response.status_code == 200:
+                groups = response.json()
+                _logger.info(
+                    "Grupos obtenidos desde la base de datos: %d grupos", len(groups)
+                )
+                return groups
+            else:
+                _logger.error(
+                    "Error al obtener groups: %s - %s",
+                    response.status_code,
+                    response.text,
+                )
+                return []
+
+        except Exception as e:
+            _logger.error("Error en la petición de groups: %s", str(e))
+            return []
+
+    def _find_or_create_partner_from_payload(self, contacts_data):
+        """
+        Identify or create a partner based on webhook contacts list.
+        Priority:
+        1. Mobile (last 10 digits match) -> Update WA ID if needed.
+        2. WA ID (exact match) -> Update Mobile/Name if needed.
+        3. Create new partner.
+        Returns: res.partner record
+        """
+        if not contacts_data:
+            return self.env["res.partner"]
+
+        contact_info = contacts_data[0]
+        wa_id = contact_info.get("wa_id")
+        mobile = contact_info.get("phone_number")  # Normalized phone from webhook
+        # Try to get profile name, fallback to wa_id
+        profile_name = contact_info.get("profile", {}).get("name") or wa_id
+
+        # Lid handling: If the main ID is an LID (Lyophilized ID used for privacy),
+        # we still prefer to link it to a real phone number if available.
+        # The payload might have "wa_id" as the LID and "phone_number" as the real number,
+        # or vice-versa depending on context. We trust 'phone_number' field for mobile search.
+
+        partner = self.env["res.partner"]
+
+        # Strategy 1: Search by Mobile (last 10 digits)
+        if mobile and len(mobile) >= 10:
+            last_10 = mobile[-10:]
+            partner = (
+                self.env["res.partner"]
+                .sudo()
+                .search([("mobile", "like", f"%{last_10}")], limit=1)
+            )
+            if partner:
+                _logger.info(f"Partner found by mobile {last_10}: {partner.name}")
+                # Update WA ID if missing or different (and valid)
+                if wa_id and partner.whatsapp_web_id != wa_id:
+                    partner.write({"whatsapp_web_id": wa_id})
+                return partner
+
+        # Strategy 2: Search by WhatsApp Web ID
+        if wa_id:
+            partner = (
+                self.env["res.partner"]
+                .sudo()
+                .search([("whatsapp_web_id", "=", wa_id)], limit=1)
+            )
+            if partner:
+                _logger.info(f"Partner found by WA ID {wa_id}: {partner.name}")
+                # Update mobile if missing
+                if mobile and not partner.mobile:
+                    partner.write({"mobile": mobile})
+                return partner
+
+        # Strategy 3: Create New Partner
+        vals = {
+            "name": profile_name,
+            "whatsapp_web_id": wa_id,
+            "mobile": mobile,
+        }
+        _logger.info(f"Creating new partner from webhook: {vals}")
+        partner = self.env["res.partner"].sudo().create(vals)
+        return partner
+
+    def _process_messages(self, value):
+        """
+        Sobrescritura completa para manejar mensajes enviados desde la misma cuenta (self-messages)
+        y rutearlos al chat correcto.
+        Refactorizado para soportar grupos vía metadata y creación Lazy.
+        """
+        # Log del payload recibido para debug
+        _logger.info(
+            "DEBUG - WhatsApp Webhook Value: %s",
+            json.dumps(value, indent=4, default=str),
+        )
+
+        if "messages" not in value and value.get("whatsapp_business_api_data", {}).get(
+            "messages"
+        ):
+            value = value["whatsapp_business_api_data"]
+
+        wa_api = WhatsAppApi(self)
+
+        # 1. Identificar Remitente (Sender)
+        contacts_data = value.get("contacts", [])
+        sender_partner = self._find_or_create_partner_from_payload(contacts_data)
+
+        # Fallback Name if partner creation failed (rare)
+        sender_name = sender_partner.name if sender_partner else "Unknown"
+
+        # Determinar el ID del teléfono actual (para detectar auto-mensajes)
+        my_phone_id = value.get("metadata", {}).get("phone_number_id")
+
+        for messages in value.get("messages", []):
+            parent_msg_id = False
+            parent_id = False
+            channel = False
+            sender_mobile = messages["from"]
+            message_type = messages["type"]
+
+            # Lógica para detectar self-messages
+            is_self_message = False
+            if my_phone_id and sender_mobile == my_phone_id:
+                is_self_message = True
+                # Intentar obtener el destinatario real
+                if "to" in messages:
+                    sender_mobile = messages["to"]
+                elif (
+                    "id" in messages
+                    and "true_" in messages["id"]
+                    and "@c.us" in messages["id"]
+                ):
+                    # Fallback: intentar parsear del ID (formato true_NUMBER@c.us_ID)
+                    try:
+                        # Extraer parte entre true_ y @
+                        parts = messages["id"].split("_")
+                        if len(parts) > 1:
+                            jid = parts[1]  # 5215581845273@c.us
+                            sender_mobile = jid
+                    except:
+                        pass
+                _logger.info(
+                    "Detectado self-message. Redirigiendo a chat de: %s", sender_mobile
+                )
+            # --- RECONCILIATION LOGIC ---
+            # Si viene un job_id en metadata, reconciliar el ID antes de chequear duplicados.
+            # Esto maneja el caso donde el "Echo" del mensaje trae el ID real y confirma el envío del worker.
+            job_id = value.get("metadata", {}).get("job_id")
+            if job_id:
+                pending_msg = (
+                    self.env["whatsapp.message"]
+                    .sudo()
+                    .search([("job_id", "=", job_id)], limit=1)
+                )
+                if pending_msg and pending_msg.msg_uid != messages["id"]:
+                    _logger.info(
+                        "Reconciliando Message ID desde payload de mensajes: JobID %s -> Real ID %s",
+                        job_id,
+                        messages["id"],
+                    )
+                    pending_msg.msg_uid = messages["id"]
+                    # Opcional: Asegurar estado si es necesario, aunque si es un echo,
+                    # el estado 'sent' ya debería estar set por el envío inicial.
+            # ----------------------------
+
+            # --- DEDUPLICATION LOGIC ---
+            # Check if this message was already processed or sent by Odoo
+            # This prevents the "Echo" effect when Odoo sends a message and Webhook confirms it
+            existing_wa_msg = (
+                self.env["whatsapp.message"]
+                .sudo()
+                .search([("msg_uid", "=", messages["id"])], limit=1)
+            )
+            if existing_wa_msg and message_type != "reaction":
+                _logger.info(
+                    "Skipping duplicate message %s (already exists)", messages["id"]
+                )
+                # Optionally update status here if needed, but avoiding duplicate mail.message is key
+                continue
+            # ---------------------------
+
+            # Context / Reply Handling
+            if "context" in messages and messages["context"].get("id"):
+                parent_whatsapp_message = (
+                    self.env["whatsapp.message"]
+                    .sudo()
+                    .search([("msg_uid", "=", messages["context"]["id"])])
+                )
+                if parent_whatsapp_message:
+                    parent_msg_id = parent_whatsapp_message.id
+                    parent_id = parent_whatsapp_message.mail_message_id
+                if parent_id:
+                    channel = (
+                        self.env["discuss.channel"]
+                        .sudo()
+                        .search([("message_ids", "in", parent_id.id)], limit=1)
+                    )
+
+            # 2. Lógica de Grupos (Metadata - Decoupled & Lazy)
+            group_metadata = value.get("metadata", {}).get("group")
+            # Support legacy group_id only if group dict missing
+            if not group_metadata and value.get("metadata", {}).get("group_id"):
+                group_metadata = {"id": value.get("metadata", {}).get("group_id")}
+
+            if group_metadata:
+                # Check if group module is installed (Use 'in' operator for models with dots)
+                if "ww.group" in self.env:
+                    # Process Group (Lazy Create + Organic Member Add)
+                    group = self.env["ww.group"].process_webhook_group(
+                        self, group_metadata, sender_partner
+                    )
+
+                    if group and group.channel_id and not channel:
+                        channel = group.channel_id
+                        _logger.info(
+                            "Mensaje de grupo ruteado a canal: %s", channel.name
+                        )
+                else:
+                    _logger.warning(
+                        "Recibido mensaje de grupo pero ww.group no está instalado."
+                    )
+
+            # 3. Canal Directo (Si no es grupo)
+            if not channel:
+                channel = self._find_active_channel(
+                    sender_mobile, sender_name=sender_name, create_if_not_found=True
+                )
+
+            # --- RENAME LOGIC FOR 1:1 CHATS ---
+            # Si el canal es tipo WhatsApp y no es un grupo (no termina en @g.us),
+            # aseguramos que el nombre del canal coincida con el del Partner.
+            # Esto corrige canales con nombres numéricos o "Unknown".
+            if channel and channel.channel_type == "whatsapp" and sender_partner:
+                is_group_channel = False
+                if channel.whatsapp_number and channel.whatsapp_number.endswith(
+                    "@g.us"
+                ):
+                    is_group_channel = True
+
+                if not is_group_channel and channel.name != sender_partner.name:
+                    _logger.info(
+                        f"Renaming channel {channel.id} from '{channel.name}' to '{sender_partner.name}'"
+                    )
+                    channel.sudo().write({"name": sender_partner.name})
+            # -----------------------------------
+
+            # Determinar autor (Author ID)
+            # Preferimos usar el partner identificado del payload
+            author_id = (
+                sender_partner.id if sender_partner else channel.whatsapp_partner_id.id
+            )
+
+            if is_self_message:
+                # Si es mensaje propio, usar el partner de la compañía o OdooBot
+                author_id = self.env.ref("base.partner_root").id
+
+            kwargs = {
+                "message_type": "whatsapp_message",
+                "author_id": author_id,
+                "parent_msg_id": parent_msg_id,
+                "subtype_xmlid": "mail.mt_comment",
+                "parent_id": parent_id.id if parent_id else None,
+            }
+            if message_type == "text":
+                kwargs["body"] = plaintext2html(messages["text"]["body"])
+            elif message_type == "button":
+                kwargs["body"] = messages["button"]["text"]
+            elif message_type in ("document", "image", "audio", "video", "sticker"):
+                filename = messages[message_type].get("filename")
+                is_voice = messages[message_type].get("voice")
+                mime_type = messages[message_type].get("mime_type")
+                caption = messages[message_type].get("caption")
+
+                # Hybrid Handling: Check for local base64 content
+                data_base64 = messages[message_type].get("data_base64")
+                if data_base64:
+                    _logger.info("Usando contenido base64 local para %s", message_type)
+                    try:
+                        datas = base64.b64decode(data_base64)
+                    except Exception as e:
+                        _logger.error("Error al decodificar data_base64: %s", e)
+                        datas = b""
+                else:
+                    # Fallback to standard flow (download from Meta)
+                    datas = wa_api._get_whatsapp_document(messages[message_type]["id"])
+
+                if not filename:
+                    extension = mimetypes.guess_extension(mime_type) or ""
+                    filename = message_type + extension
+                kwargs["attachments"] = [(filename, datas, {"voice": is_voice})]
+                if caption:
+                    kwargs["body"] = plaintext2html(caption)
+            elif message_type == "location":
+                url = Markup(
+                    "https://maps.google.com/maps?q={latitude},{longitude}"
+                ).format(
+                    latitude=messages["location"]["latitude"],
+                    longitude=messages["location"]["longitude"],
+                )
+                body = Markup(
+                    '<a target="_blank" href="{url}"> <i class="fa fa-map-marker"/> {location_string} </a>'
+                ).format(url=url, location_string=_("Location"))
+                if messages["location"].get("name"):
+                    body += Markup("<br/>{location_name}").format(
+                        location_name=messages["location"]["name"]
+                    )
+                if messages["location"].get("address"):
+                    body += Markup("<br/>{location_address}").format(
+                        location_address=messages["location"]["address"]
+                    )
+                kwargs["body"] = body
+            elif message_type == "contacts":
+                body = ""
+                for contact in messages["contacts"]:
+                    body += Markup(
+                        "<i class='fa fa-address-book'/> {contact_name} <br/>"
+                    ).format(
+                        contact_name=contact.get("name", {}).get("formatted_name", "")
+                    )
+                    for phone in contact.get("phones", []):
+                        body += Markup("{phone_type}: {phone_number}<br/>").format(
+                            phone_type=phone.get("type"),
+                            phone_number=phone.get("phone"),
+                        )
+                kwargs["body"] = body
+            elif message_type == "reaction":
+                msg_uid = messages["reaction"].get("message_id")
+                whatsapp_message = (
+                    self.env["whatsapp.message"]
+                    .sudo()
+                    .search([("msg_uid", "=", msg_uid)])
+                )
+                if whatsapp_message:
+                    # Use sender_partner for reaction if available
+                    partner_id = sender_partner
+
+                    # FALLBACK: If no sender_partner found (common in Groups where contacts is empty),
+                    # try to find partner by the 'from' field (mobile number)
+                    if not partner_id and messages.get("from"):
+                        mobile = messages["from"]
+                        if len(mobile) >= 10:
+                            partner_id = (
+                                self.env["res.partner"]
+                                .sudo()
+                                .search(
+                                    [("mobile", "like", f"%{mobile[-10:]}")], limit=1
+                                )
+                            )
+
+                    if not partner_id:
+                        partner_id = channel.whatsapp_partner_id
+                    emoji = messages["reaction"].get("emoji")
+                    whatsapp_message.mail_message_id._post_whatsapp_reaction(
+                        reaction_content=emoji, partner_id=partner_id
+                    )
+                    continue
+            else:
+                _logger.warning("Unsupported whatsapp message type: %s", messages)
+                continue
+
+            # Fix: Only pass whatsapp_inbound_msg_uid if valid for this channel type
+            # Standard channels (like groups) do not support this param and will crash
+            if channel.channel_type == "whatsapp":
+                kwargs["whatsapp_inbound_msg_uid"] = messages["id"]
 
-    whatsapp_web_url = fields.Char(string="WhatsApp Web URL", readonly=False, copy=False)
+            channel.message_post(**kwargs)

+ 280 - 0
models/whatsapp_composer.py

@@ -0,0 +1,280 @@
+from odoo import models, fields, api
+from odoo.exceptions import ValidationError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class WhatsAppComposer(models.TransientModel):
+    _inherit = "whatsapp.composer"
+
+    # Campos para soporte básico de grupos (solo por ID string, sin Many2one)
+    # La funcionalidad completa de grupos con Many2one está en whatsapp_web_groups
+    recipient_type = fields.Selection(
+        [("phone", "Phone Number"), ("group", "WhatsApp Group")],
+        string="Send To",
+        default="phone",
+        help="Choose recipient type",
+    )
+
+    # Campo para ID de grupo como string
+    whatsapp_group_id_char = fields.Char(
+        string="Group ID", help="WhatsApp Group ID (e.g., 120363158956331133@g.us)"
+    )
+
+    # Campo para mensaje libre (sin plantilla)
+    body = fields.Html(
+        string="Message Body",
+        help="Free text message (for WhatsApp Web accounts without template)",
+    )
+
+    @api.constrains(
+        "recipient_type", "phone", "whatsapp_group_id_char", "wa_template_id", "body"
+    )
+    def _check_recipient_configuration(self):
+        """Validar configuración de destinatario en composer"""
+        for record in self:
+            # Si está en contexto de skip_template_validation, saltar validaciones de plantilla
+            if self.env.context.get("skip_template_validation"):
+                # Solo validar configuración básica de destinatario
+                if (
+                    record.recipient_type == "group"
+                    and not record.whatsapp_group_id_char
+                ):
+                    raise ValidationError(
+                        "Please enter a Group ID when sending to groups"
+                    )
+                elif record.recipient_type == "phone" and not record.phone:
+                    raise ValidationError(
+                        "Please provide a phone number when sending to individuals"
+                    )
+                return  # Saltar el resto de validaciones
+
+            # Detectar si hay cuentas de WhatsApp Web disponibles
+            whatsapp_web_accounts = record.env["whatsapp.account"].search(
+                [("whatsapp_web_url", "!=", False)]
+            )
+            has_whatsapp_web = bool(whatsapp_web_accounts)
+
+            if record.recipient_type == "group" and not record.whatsapp_group_id_char:
+                raise ValidationError("Please enter a Group ID when sending to groups")
+            elif record.recipient_type == "phone" and not record.phone:
+                raise ValidationError(
+                    "Please provide a phone number when sending to individuals"
+                )
+
+            # Validar que haya contenido (plantilla o mensaje libre)
+            if not record.wa_template_id and not record.body:
+                if has_whatsapp_web:
+                    raise ValidationError(
+                        "Please provide either a template or write a free text message"
+                    )
+                else:
+                    raise ValidationError(
+                        "Template is required for WhatsApp Business API"
+                    )
+
+            # Si usa mensaje libre, debe haber WhatsApp Web
+            if record.body and not record.wa_template_id and not has_whatsapp_web:
+                raise ValidationError(
+                    "Free text messages require WhatsApp Web account configuration"
+                )
+
+    @api.depends("phone", "batch_mode", "recipient_type", "whatsapp_group_id_char")
+    def _compute_invalid_phone_number_count(self):
+        """Override SOLO para casos específicos de grupos - NO interferir con funcionalidad nativa"""
+        for composer in self:
+            # SOLO intervenir si es un caso muy específico de grupo
+            if (
+                hasattr(composer, "recipient_type")
+                and composer.recipient_type == "group"
+            ):
+                composer.invalid_phone_number_count = 0
+                continue
+
+            # SOLO intervenir si el phone es explícitamente un ID de grupo
+            if composer.phone and composer.phone.endswith("@g.us"):
+                composer.invalid_phone_number_count = 0
+                continue
+
+            # TODOS LOS DEMÁS CASOS: usar lógica original sin modificar
+            super(WhatsAppComposer, composer)._compute_invalid_phone_number_count()
+
+    def action_send_whatsapp_template(self):
+        """Override del método de envío SOLO para casos específicos de WhatsApp Web sin plantilla"""
+
+        # SOLO intervenir si es un caso muy específico:
+        # 1. No hay plantilla
+        # 2. Hay mensaje libre (body)
+        # 3. Es tipo grupo O hay WhatsApp Web disponible
+        whatsapp_web_accounts = self.env["whatsapp.account"].search(
+            [("whatsapp_web_url", "!=", False)]
+        )
+        has_whatsapp_web = bool(whatsapp_web_accounts)
+
+        # CONDICIÓN MUY ESPECÍFICA para no interferir con funcionalidad nativa
+        is_special_case = (
+            not self.wa_template_id  # Sin plantilla
+            and self.body  # Con mensaje libre
+            and has_whatsapp_web  # Con WhatsApp Web disponible
+            and (
+                self.recipient_type == "group"  # Es grupo
+                or (
+                    hasattr(self, "phone")
+                    and self.phone
+                    and self.phone.endswith("@g.us")
+                )
+            )  # O es ID de grupo directo
+        )
+
+        if is_special_case:
+            return self._send_whatsapp_web_message()
+
+        # TODOS LOS DEMÁS CASOS: usar método original sin modificar
+        return super().action_send_whatsapp_template()
+
+    def _send_whatsapp_web_message(self):
+        """Enviar mensaje WhatsApp Web sin plantilla - siguiendo lógica original"""
+        records = self._get_active_records()
+
+        for record in records:
+            # Determinar destinatario
+            if self.recipient_type == "group":
+                if self.whatsapp_group_id_char:
+                    mobile_number = self.whatsapp_group_id_char
+                else:
+                    raise ValidationError("Please specify a group ID")
+            else:
+                mobile_number = self.phone
+                if not mobile_number:
+                    raise ValidationError("Please provide a phone number")
+
+            # Crear mail.message con adjuntos si existen (siguiendo lógica original)
+            post_values = {
+                "attachment_ids": [self.attachment_id.id] if self.attachment_id else [],
+                "body": self.body,
+                "message_type": "whatsapp_message",
+                "partner_ids": hasattr(record, "_mail_get_partners")
+                and record._mail_get_partners()[record.id].ids
+                or record._whatsapp_get_responsible().partner_id.ids,
+            }
+
+            if hasattr(records, "_message_log"):
+                message = record._message_log(**post_values)
+            else:
+                message = self.env["mail.message"].create(
+                    dict(
+                        post_values,
+                        res_id=record.id,
+                        model=self.res_model,
+                        subtype_id=self.env["ir.model.data"]._xmlid_to_res_id(
+                            "mail.mt_note"
+                        ),
+                    )
+                )
+
+            # Crear mensaje WhatsApp (siguiendo estructura original)
+            whatsapp_message = self.env["whatsapp.message"].create(
+                {
+                    "mail_message_id": message.id,
+                    "mobile_number": mobile_number,
+                    "mobile_number_formatted": mobile_number,
+                    "recipient_type": self.recipient_type,
+                    "wa_template_id": False,  # Sin plantilla
+                    "wa_account_id": self._get_whatsapp_web_account().id,
+                    "state": "outgoing",
+                }
+            )
+
+            # Enviar mensaje usando la lógica original de _send_message
+            whatsapp_message._send_message()
+
+        return {"type": "ir.actions.act_window_close"}
+
+    def _prepare_whatsapp_message_values(self, record):
+        """Override SOLO para agregar información de grupo - NO interferir con funcionalidad nativa"""
+
+        # SIEMPRE usar lógica original primero
+        values = super()._prepare_whatsapp_message_values(record)
+
+        # SOLO agregar información de grupo si es caso específico
+        if hasattr(self, "recipient_type") and self.recipient_type == "group":
+            if self.whatsapp_group_id_char:
+                values.update(
+                    {
+                        "recipient_type": "group",
+                        "mobile_number": self.whatsapp_group_id_char,
+                        "mobile_number_formatted": self.whatsapp_group_id_char,
+                    }
+                )
+
+        # Siempre agregar recipient_type para compatibilidad
+        if not values.get("recipient_type"):
+            values["recipient_type"] = "phone"
+
+        return values
+
+    def _get_whatsapp_web_account(self):
+        """Obtener cuenta de WhatsApp Web disponible"""
+        # Primero intentar usar la cuenta de la plantilla si existe
+        if (
+            self.wa_template_id
+            and self.wa_template_id.wa_account_id
+            and self.wa_template_id.wa_account_id.whatsapp_web_url
+        ):
+            return self.wa_template_id.wa_account_id
+
+        # Si no, buscar cualquier cuenta con WhatsApp Web
+        account = self.env["whatsapp.account"].search(
+            [("whatsapp_web_url", "!=", False)], limit=1
+        )
+
+        if not account:
+            raise ValidationError("No WhatsApp Web account configured")
+
+        return account
+
+    def _send_whatsapp_message_without_template(self, body, phone=None, group_id=None):
+        """Enviar mensaje de WhatsApp sin plantilla (solo para WhatsApp Web)"""
+        # Solo funciona con WhatsApp Web, no con API oficial
+        if not (phone or group_id):
+            raise ValidationError("Debe especificar teléfono o grupo")
+
+        # Crear mensaje directamente
+        message_vals = {
+            "body": body,
+            "mobile_number": group_id or phone,
+            "recipient_type": "group" if group_id else "phone",
+            "wa_template_id": False,  # Sin plantilla
+            "state": "outgoing",
+        }
+
+        # Nota: El campo whatsapp_group_id Many2one está en whatsapp_web_groups
+
+        # Crear mail.message
+        mail_message = self.env["mail.message"].create(
+            {
+                "body": body,
+                "message_type": "whatsapp_message",
+            }
+        )
+
+        message_vals["mail_message_id"] = mail_message.id
+
+        # Crear y enviar mensaje WhatsApp
+        whatsapp_message = self.env["whatsapp.message"].create(message_vals)
+        whatsapp_message._send_message()
+
+        return whatsapp_message
+
+    def _create_whatsapp_messages(self, force_create=False):
+        """
+        Sobrescritura para inyectar 'is_batch' = True si el composer está en modo batch.
+        """
+        messages = super()._create_whatsapp_messages(force_create=force_create)
+
+        if self.batch_mode and messages:
+            _logger.info("Marcando %d mensajes como BATCH (Lote)", len(messages))
+            messages.write({"is_batch": True})
+
+        return messages

+ 528 - 98
models/whatsapp_message.py

@@ -1,146 +1,576 @@
 from odoo import models, fields, api
 from odoo.tools import groupby
+from odoo.exceptions import ValidationError
 import logging
 import markupsafe
 import requests
 import json
-import time
-import random
+
 import re
 import html
 import base64
 
 _logger = logging.getLogger(__name__)
 
+
 class WhatsAppMessage(models.Model):
-    _inherit = 'whatsapp.message'
+    _inherit = "whatsapp.message"
+
+    # Campos para soporte básico de grupos (solo por ID string, sin Many2one)
+    # La funcionalidad completa de grupos con Many2one está en whatsapp_web_groups
+    recipient_type = fields.Selection(
+        [("phone", "Phone Number"), ("group", "WhatsApp Group")],
+        string="Recipient Type",
+        default="phone",
+        help="Type of recipient: phone number or WhatsApp group",
+    )
+    job_id = fields.Char(string="Job ID", index=True, copy=False)
+    is_batch = fields.Boolean(
+        string="Is Batch Message",
+        default=False,
+        help="Indicates if the message was sent as part of a batch/mass action.",
+    )
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        """Override create to handle messages coming from standard Discuss channel"""
+        for vals in vals_list:
+            mobile_number = vals.get("mobile_number")
+            if mobile_number and "@g.us" in mobile_number:
+                # 1. Clean up "discuss" formatting (e.g. +123456@g.us -> 123456@g.us)
+                if mobile_number.startswith("+"):
+                    vals["mobile_number"] = mobile_number.lstrip("+")
+
+                # 2. Force recipient_type to group logic
+                vals["recipient_type"] = "group"
+
+                _logger.info(
+                    "WhatsAppMessage: Auto-detected group message to %s",
+                    vals["mobile_number"],
+                )
+
+        return super().create(vals_list)
+
+    @api.depends("recipient_type", "mobile_number")
+    def _compute_final_recipient(self):
+        """Compute the final recipient based on type"""
+        for record in self:
+            # Si es grupo y mobile_number termina en @g.us, usarlo directamente
+            if (
+                record.recipient_type == "group"
+                and record.mobile_number
+                and record.mobile_number.endswith("@g.us")
+            ):
+                record.final_recipient = record.mobile_number
+            else:
+                record.final_recipient = record.mobile_number
+
+    final_recipient = fields.Char(
+        "Final Recipient",
+        compute="_compute_final_recipient",
+        help="Final recipient (phone or group ID)",
+    )
+
+    @api.depends("mobile_number", "recipient_type")
+    def _compute_mobile_number_formatted(self):
+        """Override SOLO para casos específicos de grupos con WhatsApp Web"""
+        for message in self:
+            # SOLO intervenir si es grupo CON WhatsApp Web configurado
+            if (
+                hasattr(message, "recipient_type")
+                and message.recipient_type == "group"
+                and message.wa_account_id
+                and message.wa_account_id.whatsapp_web_url
+                and message.mobile_number
+                and message.mobile_number.endswith("@g.us")
+            ):
+
+                # CRITICAL Fix for Blacklist Crash:
+                # Odoo Enterprise tries to check/blacklist inbound numbers.
+                # Group IDs (@g.us) fail phone validation, resulting in None, which causes SQL Null Constraint error.
+                # By setting formatted number to empty string for INBOUND groups, we skip that potentially crashing logic.
+                if message.message_type == "inbound":
+                    message.mobile_number_formatted = ""
+                else:
+                    message.mobile_number_formatted = message.mobile_number
+            else:
+                # TODOS LOS DEMÁS CASOS: usar lógica original sin modificar
+                super(WhatsAppMessage, message)._compute_mobile_number_formatted()
+
+    @api.constrains("recipient_type", "mobile_number")
+    def _check_recipient_configuration(self):
+        """Validar configuración de destinatario"""
+        for record in self:
+            if record.recipient_type == "group":
+                if not (
+                    record.mobile_number and record.mobile_number.endswith("@g.us")
+                ):
+                    raise ValidationError(
+                        "Para mensajes a grupos, debe proporcionar un ID de grupo válido (@g.us)"
+                    )
+            elif record.recipient_type == "phone":
+                if not record.mobile_number or record.mobile_number.endswith("@g.us"):
+                    raise ValidationError(
+                        "Para mensajes a teléfonos, debe proporcionar un número telefónico válido"
+                    )
+
+    def _whatsapp_phone_format(
+        self, fpath=None, number=None, raise_on_format_error=False
+    ):
+        """Override SOLO para casos específicos de grupos - NO interferir con funcionalidad nativa"""
+        self.ensure_one()
+
+        # SOLO intervenir en casos muy específicos de grupos
+        # Si es un mensaje a grupo Y tiene WhatsApp Web configurado
+        if (
+            hasattr(self, "recipient_type")
+            and self.recipient_type == "group"
+            and self.wa_account_id
+            and self.wa_account_id.whatsapp_web_url
+        ):
+
+            # Si el número es un ID de grupo (termina en @g.us), retornarlo sin validar
+            if number and number.endswith("@g.us"):
+                return number
+            elif self.mobile_number and self.mobile_number.endswith("@g.us"):
+                return self.mobile_number
+
+        # TODOS LOS DEMÁS CASOS: usar validación original sin modificar
+        return super()._whatsapp_phone_format(fpath, number, raise_on_format_error)
+
+    def _get_final_destination(self):
+        """Método mejorado para obtener destino final (grupo o teléfono)"""
+        self.ensure_one()
+
+        # Si el mobile_number es un ID de grupo (termina en @g.us)
+        if self.mobile_number and self.mobile_number.endswith("@g.us"):
+            return self.mobile_number
+
+        return False
 
-    
     def _send_message(self, with_commit=False):
 
-        url = ''
+        url = ""
+        session_name = ""
+        api_key = ""
 
         if self.wa_account_id and self.wa_account_id.whatsapp_web_url:
             url = self.wa_account_id.whatsapp_web_url
-            _logger.info('WHATSAPP WEB SEND MESSAGE' + url)
+            session_name = self.wa_account_id.whatsapp_web_login or ""
+            api_key = self.wa_account_id.whatsapp_web_api_key or ""
+            _logger.info(
+                "WHATSAPP WEB SEND MESSAGE - URL: %s, Session: %s", url, session_name
+            )
 
-        group = ''
-        
-        if not url:
+        group = ""
+
+        if not url or not session_name or not api_key:
+            # Si no hay configuración de WhatsApp Web, usar método original
             super()._send_message(with_commit)
-            
+            return
+
         for whatsapp_message in self:
-            #verificacion envio a grupo
-            #plantilla dada de alta en x_plantillas_whatsapp 
-            if 'marketing.trace' in self.env:
-                marketing_traces = self.env['marketing.trace'].sudo().search([('whatsapp_message_id', '=', whatsapp_message.id)])
-                for marketing_trace in marketing_traces:
-                    if 'x_studio_grupo_whatsapp' in marketing_trace.activity_id and marketing_trace.activity_id.x_studio_grupo_whatsapp:
-                        group = marketing_trace.activity_id.x_studio_grupo_whatsapp.x_studio_destinatario
-
-            if 'x_notificaciones_whats' in self.env and not group: 
-                notificaciones = self.env['x_notificaciones_whats'].sudo().search([('x_studio_plantilla_de_whatsapp', '=', whatsapp_message.wa_template_id.id)])
-                if notificaciones:
-                    _logger.info('template encontrado')
-                    if not group: 
-                        for notificacion in notificaciones:
-                            if not notificacion.x_studio_partner_unico:
-                                group = notificacion.x_studio_destinatario
-                                break
-            
+            # Determinar destinatario final usando solo la nueva lógica
+            final_destination = whatsapp_message._get_final_destination()
+
+            if final_destination:
+                group = final_destination
+
             attachment = False
 
             if whatsapp_message.wa_template_id:
-                record = self.env[whatsapp_message.wa_template_id.model].browse(whatsapp_message.mail_message_id.res_id)
-                #codigo con base a whatsapp.message y whatsapp.template para generacion de adjuntos
-                RecordModel = self.env[whatsapp_message.mail_message_id.model].with_user(whatsapp_message.create_uid)
-                from_record = RecordModel.browse(whatsapp_message.mail_message_id.res_id)
+                record = self.env[whatsapp_message.wa_template_id.model].browse(
+                    whatsapp_message.mail_message_id.res_id
+                )
+                # codigo con base a whatsapp.message y whatsapp.template para generacion de adjuntos
+                RecordModel = self.env[
+                    whatsapp_message.mail_message_id.model
+                ].with_user(whatsapp_message.create_uid)
+                from_record = RecordModel.browse(
+                    whatsapp_message.mail_message_id.res_id
+                )
 
                 # if retrying message then we need to unlink previous attachment
                 # in case of header with report in order to generate it again
-                if whatsapp_message.wa_template_id.report_id and whatsapp_message.wa_template_id.header_type == 'document' and whatsapp_message.mail_message_id.attachment_ids:
+                if (
+                    whatsapp_message.wa_template_id.report_id
+                    and whatsapp_message.wa_template_id.header_type == "document"
+                    and whatsapp_message.mail_message_id.attachment_ids
+                ):
                     whatsapp_message.mail_message_id.attachment_ids.unlink()
 
                 if not attachment and whatsapp_message.wa_template_id.report_id:
-                    attachment = whatsapp_message.wa_template_id._generate_attachment_from_report(record)
-                if not attachment and whatsapp_message.wa_template_id.header_attachment_ids:
-                    attachment = whatsapp_message.wa_template_id.header_attachment_ids[0]
-                
-                if attachment and attachment not in whatsapp_message.mail_message_id.attachment_ids:
-                    whatsapp_message.mail_message_id.attachment_ids = [(4, attachment.id)]
-             # no template
+                    attachment = whatsapp_message.wa_template_id._generate_attachment_from_report(
+                        record
+                    )
+                if (
+                    not attachment
+                    and whatsapp_message.wa_template_id.header_attachment_ids
+                ):
+                    attachment = whatsapp_message.wa_template_id.header_attachment_ids[
+                        0
+                    ]
+
+                if (
+                    attachment
+                    and attachment
+                    not in whatsapp_message.mail_message_id.attachment_ids
+                ):
+                    whatsapp_message.mail_message_id.attachment_ids = [
+                        (4, attachment.id)
+                    ]
+            # no template
             elif whatsapp_message.mail_message_id.attachment_ids:
                 attachment = whatsapp_message.mail_message_id.attachment_ids[0]
 
-            #codigo para limpiar body y numero
+            # codigo para limpiar body y numero
             body = whatsapp_message.body
-            if isinstance(body, markupsafe.Markup):
-                text = html.unescape(str(body))
-            
+
+            # Asegurar que body sea string y limpiar HTML
+            if body:
+                if isinstance(body, markupsafe.Markup):
+                    text = html.unescape(str(body))
+                else:
+                    text = str(body)
+
                 # Reemplazamos las etiquetas BR y P
-                text = re.sub(r'<br\s*/?>|<BR\s*/?>', '\n', text)
-                text = re.sub(r'<p>|<P>', '\n\n', text)
-                text = re.sub(r'</p>|</P>', '', text)
-                
+                text = re.sub(r"<br\s*/?>|<BR\s*/?>", "\n", text)
+                text = re.sub(r"<p>|<P>", "\n\n", text)
+                text = re.sub(r"</p>|</P>", "", text)
+
                 # Eliminamos el resto de etiquetas HTML
-                text = re.sub(r'<[^>]+>', '', text)
-                
+                text = re.sub(r"<[^>]+>", "", text)
+
                 # Limpiamos múltiples saltos de línea
-                text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
-                
+                text = re.sub(r"\n\s*\n\s*\n", "\n\n", text)
+
                 # Limpiamos espacios en blanco al inicio y final
                 body = text.strip()
 
-            number = whatsapp_message.mobile_number
-            number = number.replace(' ', '').replace('+','')
-
-            if number.startswith("52") and len(number) == 12:
-                number = "521" + number[2:]
-
-            # ENVIO DE MENSAJE
-            # Headers de la petición, si es necesario
-            headers = {
-                "Content-Type": "application/json"
-            }
-
-            number = group if group else number + '@c.us'
-            
-            #$wa::sendMessage("521{$fields_data[$settings['borax_whatsapp_mobile']]}@c.us", ['type' => 'MessageMedia', 'args' => [mime_content_type($file), base64_encode(file_get_contents($file)), $filename, $filesize]], ['caption' => $borax_whatsapp_mensaje]);
-            parent_message_id = ''
-            if whatsapp_message.mail_message_id and whatsapp_message.mail_message_id.parent_id:
-                    parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids
-                    if parent_id:
-                        parent_message_id = parent_id[0].msg_uid
+                # Asegurar que no esté vacío (solo si no hay adjunto)
+                if not body:
+                    body = "" if attachment else "Mensaje de WhatsApp"
+            else:
+                body = "" if attachment else "Mensaje de WhatsApp"
+
+            # Determinar número/destinatario final
+            if group:
+                # Si ya hay un grupo determinado, usarlo
+                number = group
+            else:
+                # Formatear número según el tipo de destinatario
+                if whatsapp_message.recipient_type == "group":
+                    if (
+                        whatsapp_message.mobile_number
+                        and whatsapp_message.mobile_number.endswith("@g.us")
+                    ):
+                        number = whatsapp_message.mobile_number
+                    else:
+                        _logger.error(
+                            "Mensaje configurado como grupo pero sin destinatario válido"
+                        )
+                        continue
+                else:
+                    # Lógica original para números de teléfono
+                    number = whatsapp_message.mobile_number
+                    if number:
+                        number = (
+                            number.replace(" ", "").replace("+", "").replace("-", "")
+                        )
+
+                        if number.startswith("52") and len(number) == 12:
+                            number = "521" + number[2:]
+
+                        if len(number) == 10:
+                            number = "521" + number
+
+                        number = number + "@c.us"
+
+            # ENVIO DE MENSAJE - Nueva API Gateway
+            parent_message_id = ""
+            if (
+                whatsapp_message.mail_message_id
+                and whatsapp_message.mail_message_id.parent_id
+            ):
+                parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids
+                if parent_id:
+                    parent_message_id = parent_id[0].msg_uid
+
+            # Validar que tenemos un destinatario válido
+            if not number:
+                _logger.error("No se pudo determinar el destinatario para el mensaje")
+                continue
+
+            # Validar que tenemos un cuerpo de mensaje válido
+            if not body and not attachment:
+                _logger.error("Cuerpo del mensaje inválido: %s", body)
+                body = "Mensaje de WhatsApp"
+            elif body and not isinstance(body, str):
+                _logger.error("Cuerpo del mensaje inválido (tipo incorrecto): %s", body)
+                body = "Mensaje de WhatsApp"
+
+            # Determinar si es grupo
+            is_group = number.endswith("@g.us") if number else False
+
+            # Construir URL base
+            base_url = url.rstrip("/")
+            endpoint = "send-message"
+
+            # Headers con autenticación
+            headers = {"Content-Type": "application/json", "X-API-Key": api_key}
+
+            # Preparar payload según tipo de mensaje
             if attachment:
+                # Determinar endpoint según tipo de archivo
+                mimetype = attachment.mimetype or "application/octet-stream"
+                if mimetype.startswith("image/"):
+                    endpoint = "send-image"
+                elif mimetype.startswith("video/"):
+                    endpoint = "send-video"
+                elif mimetype.startswith("audio/"):
+                    endpoint = "send-voice"
+                else:
+                    endpoint = "send-file"
+
+                # Convertir archivo a base64 con prefijo data URI
+                file_base64 = base64.b64encode(attachment.raw).decode("utf-8")
+                base64_with_prefix = f"data:{mimetype};base64,{file_base64}"
+
+                # En wppconnect-server send-image/file espera 'phone' (singular) o 'phone' (array)
+                # Para consistencia con wpp.js, usamos la misma estructura
                 payload = {
-                    "method": "sendMessage",  
-                    "args": [number, {'type': 'MessageMedia', 'args': [attachment.mimetype, base64.b64encode(attachment.raw).decode('utf-8'), attachment.name, attachment.file_size]}, {'caption': body}]
-                }
-            else: 
-                payload = {
-                    "method": "sendMessage",  
-                    "args": [number, body, {}]
+                    "phone": number,
+                    "base64": base64_with_prefix,
+                    "filename": attachment.name or "file",
+                    "caption": body,
+                    "isGroup": is_group,
                 }
 
-            if parent_message_id:
-                payload['args'][2]['quotedMessageId'] = parent_message_id
-
-            # Realizando la petición POST
-            response = requests.post(url, data=json.dumps(payload), headers=headers)
-
-            # Verificando si la respuesta contiene data->id
-            if response.status_code == 200:
-                response_json = response.json()
-                if "_data" in response_json and "id" in response_json["_data"]:
-                    _logger.info(f"Petición exitosa. ID: {response_json['_data']['id']['id']}")
-                    whatsapp_message.write({
-                        'state': 'sent',
-                        'msg_uid': response_json['_data']['id']['_serialized']
-                    })
+                if parent_message_id:
+                    payload["quotedMessageId"] = parent_message_id
+            else:
+                # Mensaje de texto
+                # Alineación con wpp.js: phone, message, isGroup
+                payload = {"phone": number, "message": body, "isGroup": is_group}
+
+                if parent_message_id:
+                    payload["quotedMessageId"] = parent_message_id
+
+            # Priority Logic:
+            # If it's NOT a batch message AND NOT a marketing campaign message, give it high priority.
+            # Marketing messages are identifiable by having associated marketing_trace_ids.
+            is_marketing = bool(
+                hasattr(whatsapp_message, "marketing_trace_ids")
+                and whatsapp_message.marketing_trace_ids
+            )
+            if not whatsapp_message.is_batch and not is_marketing:
+                payload["priority"] = 10
+
+            # Construir URL completa
+            full_url = f"{base_url}/api/v1/{session_name}/{endpoint}"
+
+            # Log del payload para debugging
+            _logger.info(
+                "Enviando mensaje a %s (%s) usando endpoint %s",
+                number,
+                "grupo" if is_group else "contacto",
+                endpoint,
+            )
+
+            # Realizar petición POST
+            try:
+                response = requests.post(
+                    full_url, json=payload, headers=headers, timeout=60
+                )
+
+                # Procesar respuesta
+                _logger.info("WhatsApp API Response: %s", response.text)
+                if response.status_code == 200:
+                    try:
+                        response_json = response.json()
+
+                        # La nueva API puede devolver jobId (mensaje encolado) o id (enviado directamente)
+                        if "jobId" in response_json:
+                            # Mensaje encolado - si la API devuelve jobId, significa que el mensaje fue aceptado
+                            # y está en proceso de envío, por lo que lo marcamos como 'sent'
+                            job_id = response_json.get("jobId")
+                            _logger.info(
+                                "Mensaje aceptado por la API. Job ID: %s - Marcando como enviado",
+                                job_id,
+                            )
+                            whatsapp_message.write(
+                                {
+                                    "state": "sent",  # Marcar como enviado ya que fue aceptado por la API
+                                    "msg_uid": job_id,
+                                    "job_id": job_id,
+                                }
+                            )
+                            if with_commit:
+                                self._cr.commit()
+                        elif "id" in response_json:
+                            # Mensaje enviado directamente
+                            msg_id = response_json.get("id")
+                            if isinstance(msg_id, dict) and "_serialized" in msg_id:
+                                msg_uid = msg_id["_serialized"]
+                            elif isinstance(msg_id, str):
+                                msg_uid = msg_id
+                            else:
+                                msg_uid = str(msg_id)
+
+                            _logger.info(
+                                "Mensaje enviado exitosamente. ID: %s", msg_uid
+                            )
+                            whatsapp_message.write(
+                                {"state": "sent", "msg_uid": msg_uid}
+                            )
+                            if with_commit:
+                                self._cr.commit()
+                        else:
+                            _logger.warning(
+                                "Respuesta exitosa pero sin jobId ni id: %s",
+                                response_json,
+                            )
+                            whatsapp_message.write({"state": "outgoing"})
+                            if with_commit:
+                                self._cr.commit()
+                    except ValueError:
+                        _logger.error(
+                            "La respuesta no es JSON válido: %s", response.text
+                        )
+                        whatsapp_message.write({"state": "error"})
+                        if with_commit:
+                            self._cr.commit()
+                else:
+                    _logger.error(
+                        "Error en la petición. Código: %s, Respuesta: %s",
+                        response.status_code,
+                        response.text,
+                    )
+                    whatsapp_message.write({"state": "error"})
+                    if with_commit:
+                        self._cr.commit()
+            except requests.exceptions.RequestException as e:
+                _logger.error("Error de conexión al enviar mensaje: %s", str(e))
+                whatsapp_message.write({"state": "error"})
+                if with_commit:
                     self._cr.commit()
+
+    def _process_statuses(self, value):
+        """
+        Sobrescritura para manejar la reconciliación de IDs usando job_id.
+        Si la notificación trae un job_id en metadata, buscamos el mensaje por ese ID
+        y actualizamos el msg_uid al ID real de WhatsApp antes de procesar el estado.
+        """
+        # Pre-process statuses to reconcile IDs
+        metadata = value.get("metadata", {})
+        job_id = metadata.get("job_id")
+
+        for status in value.get("statuses", []):
+            real_wa_id = status.get("id")
+
+            if job_id and real_wa_id:
+                # Buscar mensaje por job_id que tenga un msg_uid incorrecto (el del worker)
+                # O simplemente buscar por job_id y asegurar que msg_uid sea el real
+                message = (
+                    self.env["whatsapp.message"]
+                    .sudo()
+                    .search([("job_id", "=", job_id)], limit=1)
+                )
+
+                if message:
+                    if message.msg_uid != real_wa_id:
+                        _logger.info(
+                            "Reconciliando WhatsApp ID: JobID %s -> Real ID %s",
+                            job_id,
+                            real_wa_id,
+                        )
+                        # Actualizamos msg_uid al real para que el super() lo encuentre
+                        message.msg_uid = real_wa_id
+                    else:
+                        _logger.info("Mensaje ya reconciliado para JobID %s", job_id)
                 else:
-                    _logger.info("La respuesta no contiene 'data->id'.")
-            else:
-                _logger.info(f"Error en la petición. Código de estado: {response.status_code}")
+                    _logger.warning(
+                        "Recibido status con JobID %s pero no se encontró mensaje en Odoo",
+                        job_id,
+                    )
+
+        # Call original implementation to handle standard status updates (sent, delivered, read, etc.)
+        return super()._process_statuses(value)
+
+    def _update_message_fetched_seen(self):
+        """
+        Sobrescritura para manejar concurrencia en la actualización de discuss.channel.member.
+        Usa bloqueo de fila (NO KEY UPDATE SKIP LOCKED) para evitar SERIALIZATION_FAILURE.
+        """
+        self.ensure_one()
+        if self.mail_message_id.model != "discuss.channel":
+            return
+
+        channel = self.env["discuss.channel"].browse(self.mail_message_id.res_id)
+
+        # Buscar el miembro asociado al partner de WhatsApp
+        channel_member = channel.channel_member_ids.filtered(
+            lambda cm: cm.partner_id == channel.whatsapp_partner_id
+        )
+
+        if not channel_member:
+            return
+
+        channel_member = channel_member[0]
+        member_id = channel_member.id
+
+        notification_type = None
+        updated_rows = 0
+
+        if self.state == "read":
+            # Intentar actualizar usando SKIP LOCKED
+            # Si el registro está bloqueado, simplemente saltamos esta actualización
+            # ya que fetched/seen son contadores monotónicos o de estado reciente
+            self.env.cr.execute(
+                """
+                UPDATE discuss_channel_member
+                SET fetched_message_id = GREATEST(fetched_message_id, %s),
+                    seen_message_id = %s,
+                    last_seen_dt = NOW()
+                WHERE id IN (
+                    SELECT id FROM discuss_channel_member WHERE id = %s
+                    FOR NO KEY UPDATE SKIP LOCKED
+                )
+                RETURNING id
+                """,
+                (self.mail_message_id.id, self.mail_message_id.id, member_id),
+            )
+            # Si fetchone devuelve algo, significa que actualizó. Si es None, saltó por bloqueo.
+            if self.env.cr.fetchone():
+                channel_member.invalidate_recordset(
+                    ["fetched_message_id", "seen_message_id", "last_seen_dt"]
+                )
+                notification_type = "discuss.channel.member/seen"
+
+        elif self.state == "delivered":
+            self.env.cr.execute(
+                """
+                UPDATE discuss_channel_member
+                SET fetched_message_id = GREATEST(fetched_message_id, %s)
+                WHERE id IN (
+                    SELECT id FROM discuss_channel_member WHERE id = %s
+                    FOR NO KEY UPDATE SKIP LOCKED
+                )
+                RETURNING id
+                """,
+                (self.mail_message_id.id, member_id),
+            )
+            if self.env.cr.fetchone():
+                channel_member.invalidate_recordset(["fetched_message_id"])
+                notification_type = "discuss.channel.member/fetched"
 
-            time.sleep(random.randint(3, 7))
+        if notification_type:
+            channel._bus_send(
+                notification_type,
+                {
+                    "channel_id": channel.id,
+                    "id": channel_member.id,
+                    "last_message_id": self.mail_message_id.id,
+                    "partner_id": channel.whatsapp_partner_id.id,
+                },
+            )

+ 173 - 3
models/whatsapp_patch.py

@@ -7,17 +7,187 @@ _logger = logging.getLogger(__name__)
 # Guarda una referencia al método original
 original_get_whatsapp_document = WhatsAppApi._get_whatsapp_document
 
+
 def custom_get_whatsapp_document(self, document_id):
     _logger.info("Ejecutando versión modificada de _get_whatsapp_document")
 
-    if self.wa_account_id.whatsapp_web_url: 
-        _logger.info("Ejecutando versión modificada de _get_whatsapp_document con whatsapp web")
-        result = base64.b64decode(document_id)
+    if self.wa_account_id.whatsapp_web_url:
+        _logger.info(
+            "Ejecutando versión modificada de _get_whatsapp_document con whatsapp web"
+        )
+        try:
+            result = base64.b64decode(document_id)
+        except Exception:
+            # Si falla la decodificación (ej. es un ID y no base64), devolvemos vacío o el ID raw
+            # para evitar crash, aunque el archivo estará corrupto.
+            # TODO: Implementar fetch real a WPPConnect
+            _logger.warning(
+                "No se pudo decodificar base64 en _get_whatsapp_document, retornando vacío"
+            )
+            result = b""
     else:
         result = original_get_whatsapp_document(self, document_id)
 
     # Aquí puedes modificar 'result' si es necesario antes de devolverlo
     return result
 
+
 # Sobrescribir el método en tiempo de ejecución
 WhatsAppApi._get_whatsapp_document = custom_get_whatsapp_document
+
+# Parche para el método _post_whatsapp_reaction para evitar errores de constraint
+try:
+    from odoo.addons.whatsapp.models.mail_message import MailMessage
+
+    # Guardar referencia al método original
+    original_post_whatsapp_reaction = MailMessage._post_whatsapp_reaction
+
+    def custom_post_whatsapp_reaction(self, reaction_content, partner_id):
+        """Parche para evitar error de constraint cuando partner_id es None"""
+        self.ensure_one()
+
+        # Si no hay partner_id, no procesar la reacción
+        if not partner_id:
+            _logger.warning(
+                "Reacción de WhatsApp recibida sin partner_id para mensaje %s - ignorando",
+                self.id,
+            )
+            return
+
+        # Llamar al método original si hay partner_id
+        return original_post_whatsapp_reaction(self, reaction_content, partner_id)
+
+    # Aplicar el parche
+    MailMessage._post_whatsapp_reaction = custom_post_whatsapp_reaction
+    _logger.info("Parche aplicado exitosamente para _post_whatsapp_reaction")
+
+except ImportError as e:
+    _logger.warning("No se pudo aplicar el parche para _post_whatsapp_reaction: %s", e)
+
+# Parche para el método wa_phone_format de phone_validation para evitar AttributeError
+try:
+    from odoo.addons.whatsapp.tools import phone_validation
+
+    # Guardar referencia al método original
+    original_wa_phone_format = phone_validation.wa_phone_format
+
+    def custom_wa_phone_format(
+        record,
+        fname=False,
+        number=False,
+        country=None,
+        force_format="INTERNATIONAL",
+        raise_exception=True,
+    ):
+        """Parche para evitar AttributeError: 'bool' object has no attribute 'italian_leading_zero'"""
+
+        # Ejecutar lógica original, pero capturando errores
+        try:
+            # Reimplementar la parte final del método original que falla
+            # Primero llamamos al método original, si funciona, perfecto
+            return original_wa_phone_format(
+                record, fname, number, country, force_format, raise_exception
+            )
+        except AttributeError as e:
+            if "italian_leading_zero" in str(e):
+                _logger.warning(
+                    "Capturado AttributeError en wa_phone_format, intentando recuperación segura: %s",
+                    e,
+                )
+
+                # Intentar replicar la lógica segura aquí si es necesario
+                # Por ahora, simplemente devolvemos el número formateado si es posible obtenerlo
+                # o relanzamos si no podemos manejarlo
+
+                # Obtener el número base
+                if not number and record and fname:
+                    record.ensure_one()
+                    number = record[fname]
+
+                if not number:
+                    return False
+
+                # Si llegamos aquí es porque falló el acceso a atributos de parsed
+                # Devolvemos el número original o intentamos un formateo básico
+                return number
+            raise e
+
+    # Una mejor aproximación: Monkey Patch directo a la función interna si es posible,
+    # o redefinir completamente la función si el error está dentro de ella y no podemos envolverla fácilmente.
+    # Dado que el error ocurre DENTRO de la función original al acceder a parsed.italian_leading_zero,
+    # necesitamos redefinir la función completa para corregir el acceso al atributo.
+
+    def safe_wa_phone_format(
+        record,
+        fname=False,
+        number=False,
+        country=None,
+        force_format="INTERNATIONAL",
+        raise_exception=True,
+    ):
+        """Versión segura de wa_phone_format que maneja correctamente los atributos de parsed"""
+
+        # Importar dependencias necesarias
+        from odoo.addons.phone_validation.tools import phone_validation as pv_tools
+
+        if not number and record and fname:
+            record.ensure_one()
+            number = record[fname]
+        if not number:
+            return False
+
+        if not country and record:
+            country = record._phone_get_country().get(record.id)
+        if not country:
+            country = record.env.company.country_id
+
+        try:
+            formatted = pv_tools.phone_format(
+                number,
+                country.code,
+                country.phone_code,
+                force_format=force_format if force_format != "WHATSAPP" else "E164",
+                raise_exception=True,
+            )
+        except Exception:
+            if raise_exception:
+                raise
+            formatted = False
+
+        if formatted and force_format == "WHATSAPP":
+            try:
+                parsed = pv_tools.phone_parse(formatted, country.code)
+            except Exception:
+                if raise_exception:
+                    raise
+                return False
+
+            zeros = ""
+            # USO SEGURO DE ATRIBUTOS (Corrección del bug original)
+            if getattr(parsed, "italian_leading_zero", False):
+                zeros = "0"
+                if getattr(parsed, "number_of_leading_zeros", False):
+                    zeros = "0" * parsed.number_of_leading_zeros
+
+            # Verificación adicional para country_code y national_number
+            country_code = getattr(parsed, "country_code", "")
+            national_number = getattr(parsed, "national_number", "")
+
+            if not country_code or not national_number:
+                # Si no se pueden obtener estos datos, intentar usar el formatted original o un fallback
+                if formatted:
+                    return formatted
+                return number
+
+            return f"{country_code}" + zeros + f"{national_number}"
+
+        return formatted
+
+    # Aplicar el parche reemplazando la función en el módulo
+    phone_validation.wa_phone_format = safe_wa_phone_format
+    _logger.info("Parche aplicado exitosamente para phone_validation.wa_phone_format")
+
+except ImportError as e:
+    _logger.warning("No se pudo aplicar el parche para phone_validation: %s", e)
+except Exception as e:
+    _logger.warning("Error al aplicar parche para phone_validation: %s", e)

+ 20 - 0
static/src/overrides/channel_member_list_patch.js

@@ -0,0 +1,20 @@
+/* @odoo-module */
+
+import { ChannelMemberList } from "@mail/discuss/core/common/channel_member_list";
+import { patch } from "@web/core/utils/patch";
+
+patch(ChannelMemberList.prototype, {
+    /**
+     * Override to prevent crash when clicking on a member without a system user ID.
+     * WhatsApp contacts are partners but not users, so they don't have a userId.
+     * The AvatarCardPopover requires a valid userId.
+     */
+    onClickAvatar(ev, member) {
+        if (!member.persona.userId) {
+            // If the member has no userId (e.g. WhatsApp contact), do not open the popover.
+            // This prevents the "Invalid props: id is not a number" error.
+            return;
+        }
+        super.onClickAvatar(ev, member);
+    },
+});

+ 98 - 0
static/src/overrides/composer_patch.js

@@ -0,0 +1,98 @@
+/* @odoo-module */
+
+import { Composer } from "@mail/core/common/composer";
+import { patch } from "@web/core/utils/patch";
+
+patch(Composer.prototype, {
+    /** @override */
+    get placeholder() {
+        if (
+            this.thread &&
+            this.thread.channel_type === "whatsapp" &&
+            this.thread.is_whatsapp_web
+        ) {
+            // Bypass the 24h restriction placeholder
+            return "Type your message...";
+        }
+        return super.placeholder;
+    },
+
+    /** @override */
+    checkComposerDisabled() {
+        if (
+            this.thread &&
+            this.thread.channel_type === "whatsapp" &&
+            this.thread.is_whatsapp_web
+        ) {
+            // Force active state for WhatsApp Web channels
+            this.state.active = true;
+            this.props.composer.threadExpired = false;
+            // Clear any existing timeout that might disable it
+            if (this.composerDisableCheckTimeout) {
+                clearTimeout(this.composerDisableCheckTimeout);
+                this.composerDisableCheckTimeout = null;
+            }
+            return;
+        }
+        super.checkComposerDisabled();
+    },
+
+    /** @override */
+    get hasSendButtonNonEditing() {
+        if (
+            this.thread &&
+            this.thread.channel_type === "whatsapp" &&
+            this.thread.is_whatsapp_web
+        ) {
+            return true;
+        }
+        return super.hasSendButtonNonEditing;
+    },
+
+    /** @override */
+    get isSendButtonDisabled() {
+        if (
+            this.thread &&
+            this.thread.channel_type === "whatsapp" &&
+            this.thread.is_whatsapp_web
+        ) {
+            // If it's WhatsApp Web, only rely on standard validation (like empty message)
+            // accessing 'super.isSendButtonDisabled' might still trigger the unwanted check if implementations change,
+            // but usually super checks mainly for empty content or uploading status.
+            // However, the enterprise patch explicitly ADDS the inactive check.
+            // By overriding and managing the logic priority, we can bypass it.
+
+            // Re-implement base Composer logic for disabled button:
+            // "return this.props.composer.isUploading || this.props.composer.isEmpty;"
+            // But we can't easily access 'super' of the 'original' base if there are multiple patches.
+
+            // The enterprise patch does: return super.isSendButtonDisabled || whatsappInactive;
+            // Since we patch ON TOP of enterprise (hopefully, depends on load order), 
+            // if we call super, we get the enterprise logic which returns true if inactive.
+
+            // Strategy: We forced `this.state.active = true` in `checkComposerDisabled`.
+            // So `whatsappInactive` in enterprise patch should be false.
+            // Let's rely on that first.
+
+            return super.isSendButtonDisabled;
+        }
+        return super.isSendButtonDisabled;
+    },
+
+    /** @override */
+    processFileUploading(ev, superCb) {
+        if (
+            this.thread &&
+            this.thread.channel_type === "whatsapp" &&
+            this.thread.is_whatsapp_web
+        ) {
+            // Bypass the single attachment restriction?
+            // Discuss for WhatsApp Web might support multiple attachments.
+            // Standard Odoo Whatsapp restricts to 1. 
+            // Let's allow standard behavior (call superCb directly without the check)
+            superCb(ev);
+            return;
+        }
+        super.processFileUploading(ev, superCb);
+    }
+});

+ 16 - 0
static/src/overrides/thread_model_patch.js

@@ -0,0 +1,16 @@
+/* @odoo-module */
+
+import { Thread } from "@mail/core/common/thread_model";
+import { patch } from "@web/core/utils/patch";
+
+patch(Thread.prototype, {
+    /**
+     * @override
+     */
+    update(data) {
+        if ("is_whatsapp_web" in data) {
+            this.is_whatsapp_web = data.is_whatsapp_web;
+        }
+        super.update(data);
+    },
+});

+ 3 - 1
views/whatsapp_account_views.xml

@@ -11,7 +11,9 @@
                     WhatsApp Web
                 </div>
                 <group>
-                    <field name="whatsapp_web_url" placeholder="e.g. https://web.whatsapp.com/"/>
+                    <field name="whatsapp_web_url" placeholder="https://wsrvb.crm.m22.mx" help="Base URL de la API Gateway"/>
+                    <field name="whatsapp_web_login" placeholder="mcteam" help="Nombre de sesión (session_name)"/>
+                    <field name="whatsapp_web_api_key" placeholder="API Key" password="True" help="API Key para autenticación en la API Gateway"/>
                 </group>
             </sheet>
         </field>

+ 47 - 0
views/whatsapp_composer_views.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <!-- Extender vista de formulario del composer de WhatsApp -->
+        <record id="whatsapp_composer_view_form_groups" model="ir.ui.view">
+            <field name="name">whatsapp.composer.view.form.groups</field>
+            <field name="model">whatsapp.composer</field>
+            <field name="inherit_id" ref="whatsapp.whatsapp_composer_view_form"/>
+            <field name="arch" type="xml">
+                <!-- Agregar opción de envío a grupos -->
+                <xpath expr="//field[@name='phone']" position="before">
+                    <group string="Recipient Configuration" name="recipient_config">
+                        <field name="recipient_type" widget="radio" options="{'horizontal': true}"/>
+                    </group>
+                </xpath>
+                
+                <!-- Hacer wa_template_id opcional para WhatsApp Web -->
+                <xpath expr="//field[@name='wa_template_id']" position="attributes">
+                    <attribute name="help">Template required for WhatsApp Business API. Optional for WhatsApp Web accounts.</attribute>
+                </xpath>
+                
+                <!-- Modificar campo phone para que sea condicional -->
+                <xpath expr="//field[@name='phone']" position="attributes">
+                    <attribute name="invisible">recipient_type == 'group'</attribute>
+                    <attribute name="required">False</attribute>
+                </xpath>
+                
+                <!-- Agregar campos de grupo después del teléfono -->
+                <!-- Nota: El campo whatsapp_group_id Many2one está en whatsapp_web_groups -->
+                <xpath expr="//field[@name='phone']" position="after">
+                    <field name="whatsapp_group_id_char" 
+                           invisible="recipient_type != 'group'"
+                           placeholder="Enter Group ID manually (e.g., 120363158956331133@g.us)"
+                           string="Group ID"/>
+                </xpath>
+                
+                <!-- Agregar campo de mensaje libre para WhatsApp Web -->
+                <xpath expr="//field[@name='wa_template_id']" position="after">
+                    <field name="body" 
+                           invisible="wa_template_id"
+                           placeholder="Write your free text message here (only for WhatsApp Web accounts)..."
+                           string="Free Text Message"/>
+                </xpath>
+            </field>
+        </record>
+    </data>
+</odoo>

+ 57 - 0
views/whatsapp_message_views.xml

@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+        <!-- Extender vista de formulario de WhatsApp Message -->
+        <record id="whatsapp_message_view_form_groups" model="ir.ui.view">
+            <field name="name">whatsapp.message.view.form.groups</field>
+            <field name="model">whatsapp.message</field>
+            <field name="inherit_id" ref="whatsapp.whatsapp_message_view_form"/>
+            <field name="arch" type="xml">
+                <!-- Agregar campos de grupo después del campo mobile_number -->
+                <!-- Nota: El campo whatsapp_group_id Many2one está en whatsapp_web_groups -->
+                <xpath expr="//field[@name='mobile_number']" position="after">
+                    <field name="recipient_type" widget="radio" options="{'horizontal': true}"/>
+                    <field name="final_recipient" readonly="1" 
+                           invisible="not final_recipient"/>
+                </xpath>
+                
+                <!-- Hacer mobile_number invisible cuando es grupo -->
+                <xpath expr="//field[@name='mobile_number']" position="attributes">
+                    <attribute name="invisible">recipient_type == 'group'</attribute>
+                    <attribute name="required">recipient_type == 'phone'</attribute>
+                </xpath>
+            </field>
+        </record>
+
+        <!-- Extender vista de lista de WhatsApp Message -->
+        <record id="whatsapp_message_view_tree_groups" model="ir.ui.view">
+            <field name="name">whatsapp.message.view.tree.groups</field>
+            <field name="model">whatsapp.message</field>
+            <field name="inherit_id" ref="whatsapp.whatsapp_message_view_tree"/>
+            <field name="arch" type="xml">
+                <!-- Agregar columna de tipo de destinatario -->
+                <xpath expr="//field[@name='mobile_number']" position="after">
+                    <field name="recipient_type"/>
+                </xpath>
+            </field>
+        </record>
+
+        <!-- Extender vista de búsqueda -->
+        <record id="whatsapp_message_view_search_groups" model="ir.ui.view">
+            <field name="name">whatsapp.message.view.search.groups</field>
+            <field name="model">whatsapp.message</field>
+            <field name="inherit_id" ref="whatsapp.whatsapp_message_view_search"/>
+            <field name="arch" type="xml">
+                <!-- Agregar filtros para grupos al final -->
+                <xpath expr="//search" position="inside">
+                    <separator/>
+                    <filter string="Phone Messages" name="phone_messages" domain="[('recipient_type', '=', 'phone')]"/>
+                    <filter string="Group Messages" name="group_messages" domain="[('recipient_type', '=', 'group')]"/>
+                    <group expand="0" string="Group By">
+                        <filter string="Recipient Type" name="group_by_recipient_type" context="{'group_by': 'recipient_type'}"/>
+                    </group>
+                </xpath>
+            </field>
+        </record>
+    </data>
+</odoo>