21 Commits c5dd4a027c ... e53790c888

Tác giả SHA1 Thông báo Ngày
  root e53790c888 Perf: Add debounce to discuss_channel write to prevent concurrent updates 3 tháng trước cách đây
  odoo 2100f4a10a chore: commit pending changes before subtree update 3 tháng trước cách đây
  odoo c4c2695610 fix(whatsapp): resolve serialization failure and owl error in discuss 3 tháng trước cách đây
  odoo 0537d89930 Update whatsapp_web and whatsapp_web_groups modules 3 tháng trước cách đây
  odoo 583a9e9e9e [FIX] whatsapp_web: fix phone validation and align payload with wppconnect 3 tháng trước cách đây
  odoo 18b7b66855 Update whatsapp_web subtree from develop 3 tháng trước cách đây
  odoo 218c34eebc Merge commit 'd8b06773a605e46f9e257586a49b9cea64e4f638' into TC 3 tháng trước cách đây
  root 0135327dc1 Merge remote-tracking branch 'origin/develop' into develop 4 tháng trước cách đây
  root acbf0d5a8f update status mensaje 4 tháng trước cách đây
  root 1720df3765 fix: corregir sintaxis de herencia y orden de carga de modelos 4 tháng trước cách đây
  root b530bc88a2 fix: agregar archivo mail_message.py faltante al repositorio 4 tháng trước cách đây
  root dc44d89d7f refactor: eliminar dependencia circular - remover campos Many2one a ww.group 4 tháng trước cách đây
  MC Team deb2f0344b Migrar a nueva API Gateway: usar session_name y api_key, endpoint groups desde BD 4 tháng trước cách đây
  root 06d6e7a05c Add API key and login fields, add .gitignore 4 tháng trước cách đây
  root b94917aba8 Add comprehensive WhatsApp Web integration features 6 tháng trước cách đây
  root cf6a1a232d fix mobile send whatsapp web 8 tháng trước cách đây
  root a4e54cf1c1 fix mobile 8 tháng trước cách đây
  root af86bc3015 log whatsapp web json 8 tháng trước cách đây
  root ae8070919a Merge branch 'develop' of ssh://g1t.m22.mx:12765/M22/whatsapp_web into develop 10 tháng trước cách đây
  root 9fea7d2355 nueva funcion para integracion con whatsapp web groups 10 tháng trước cách đây
  root 334bbe3965 Merge tag 'v0.1' into develop 11 tháng trước cách đây

+ 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>