Browse Source

Update whatsapp_web and whatsapp_web_groups modules

odoo 3 tháng trước cách đây
mục cha
commit
0537d89930

+ 21 - 15
__manifest__.py

@@ -1,20 +1,26 @@
 {
-    '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', 'marketing_automation_whatsapp'],
-    'data': [
-        'views/whatsapp_account_views.xml',
-        'views/whatsapp_message_views.xml',
-        'views/whatsapp_composer_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",
+        ],
+    },
+    "installable": True,
+    "auto_install": False,
+}

+ 2 - 1
models/__init__.py

@@ -2,4 +2,5 @@ from . import whatsapp_account
 from . import whatsapp_message
 from . import whatsapp_composer
 from . import whatsapp_patch
-from . import mail_message
+from . import mail_message
+from . import discuss_channel

+ 123 - 0
models/discuss_channel.py

@@ -0,0 +1,123 @@
+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")
+
+    @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)

+ 66 - 38
models/mail_message.py

@@ -7,19 +7,22 @@ from odoo.exceptions import UserError
 
 _logger = logging.getLogger(__name__)
 
+
 class MailMessage(models.Model):
-    _inherit = 'mail.message'
+    _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)
+                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)
@@ -27,10 +30,10 @@ class MailMessage(models.Model):
                 # 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()
@@ -59,72 +62,97 @@ class MailMessage(models.Model):
         # 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):
+    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.")
-        
+            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)
+            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.")
-        
+            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 = 'send-reaction'
+        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 = {
-            "messageId": message_id,
-            "emoji": emoji
-        }
-        
-        headers = {
-            "Content-Type": "application/json",
-            "X-API-Key": api_key
-        }
-        
+
+        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)
-            
+            _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)
+                _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)}")
-

+ 385 - 16
models/whatsapp_account.py

@@ -1,15 +1,23 @@
 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_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)
 
@@ -20,40 +28,401 @@ class WhatsAppAccount(models.Model):
             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)
+            _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)
+            _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)
+            _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('/')
+            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
+                "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))
+                _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)
+                _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 []
+            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 not channel and 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:
+                        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"]
+
+            channel.message_post(**kwargs)

+ 177 - 114
models/whatsapp_composer.py

@@ -4,109 +4,142 @@ import logging
 
 _logger = logging.getLogger(__name__)
 
+
 class WhatsAppComposer(models.TransientModel):
-    _inherit = 'whatsapp.composer'
+    _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")
-    
+    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)")
-    
+    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')
+    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'):
+            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")
+                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)
-            ])
+            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:
+
+            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")
-            
+            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")
+                    raise ValidationError(
+                        "Please provide either a template or write a free text message"
+                    )
                 else:
-                    raise ValidationError("Template is required for WhatsApp Business API")
-            
+                    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')
+                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'):
+            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'):
+            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)
-        ])
+        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 and  # Sin plantilla
-            self.body and  # Con mensaje libre
-            has_whatsapp_web and  # Con WhatsApp Web disponible
-            (self.recipient_type == 'group' or  # Es grupo
-             (hasattr(self, 'phone') and self.phone and self.phone.endswith('@g.us')))  # O es ID de grupo directo
+            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.recipient_type == "group":
                 if self.whatsapp_group_id_char:
                     mobile_number = self.whatsapp_group_id_char
                 else:
@@ -115,103 +148,133 @@ class WhatsAppComposer(models.TransientModel):
                 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,
+                "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'):
+
+            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"))
+                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',
-            })
-            
+            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'}
-    
+
+        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 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,
-                })
-        
+                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'
-        
+        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:
+        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)
-        
+        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',
+            "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
-        
+        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 = 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

+ 394 - 168
models/whatsapp_message.py

@@ -5,164 +5,248 @@ 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")
-    
-    @api.depends('recipient_type', 'mobile_number')
+    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'):
+            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')
+
+    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')):
-                
-                message.mobile_number_formatted = message.mobile_number
+            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')
+
+    @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):
+            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):
-            
+        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'):
+            if number and number.endswith("@g.us"):
                 return number
-            elif self.mobile_number and self.mobile_number.endswith('@g.us'):
+            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'):
+        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 = ''
-        session_name = ''
-        api_key = ''
+        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
-            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)
+            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 = ""
 
-        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:
             # 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
-            
+
             # 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()
-                
+
                 # Asegurar que no esté vacío
                 if not body:
                     body = "Mensaje de WhatsApp"
@@ -175,162 +259,304 @@ class WhatsAppMessage(models.Model):
                 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'):
+                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")
+                        _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('-','')
+                        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'
+
+                        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
-            
+            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 or not isinstance(body, str):
                 _logger.error("Cuerpo del mensaje inválido: %s", body)
                 body = "Mensaje de WhatsApp"
-            
+
             # Determinar si es grupo
-            is_group = number.endswith('@g.us') if number else False
-            
+            is_group = number.endswith("@g.us") if number else False
+
             # Construir URL base
-            base_url = url.rstrip('/')
-            endpoint = 'send-message'
-            
+            base_url = url.rstrip("/")
+            endpoint = "send-message"
+
             # Headers con autenticación
-            headers = {
-                "Content-Type": "application/json",
-                "X-API-Key": api_key
-            }
-            
+            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'
+                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'
-                
+                    endpoint = "send-file"
+
                 # Convertir archivo a base64 con prefijo data URI
-                file_base64 = base64.b64encode(attachment.raw).decode('utf-8')
+                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 = {
-                    "phone": number,  
+                    "phone": number,
                     "base64": base64_with_prefix,
                     "filename": attachment.name or "file",
                     "caption": body,
-                    "isGroup": is_group
+                    "isGroup": is_group,
                 }
-                
+
                 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
-                }
-                
+                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)
-            
+            _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)
-                
+                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:
+                        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
-                            })
-                            self._cr.commit()
-                        elif 'id' in response_json:
+                            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']
+                            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
-                            })
-                            self._cr.commit()
+
+                            _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'
-                            })
-                            self._cr.commit()
+                            _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'
-                        })
-                        self._cr.commit()
+                        _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'
-                    })
-                    self._cr.commit()
+                    _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'
-                })
-                self._cr.commit()
+                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
+        for status in value.get("statuses", []):
+            real_wa_id = status.get("id")
+            # Buscar job_id en metadata (según requerimiento del usuario)
+            # Estructura esperada: metadata: { job_id: "..." }
+            metadata = status.get("metadata", {})
+            job_id = metadata.get("job_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.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 (SELECT FOR UPDATE) 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 usando SQL directo para bloquear la fila antes de leer/escribir
+        # Odoo ORM no soporta lock explícito fácil en search(), así que primero buscamos ID y luego bloqueamos.
+        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]
+
+        # --- CONCURRENCY FIX START ---
+        try:
+            # Bloquear la fila específica del miembro del canal
+            self.env.cr.execute(
+                "SELECT id FROM discuss_channel_member WHERE id = %s FOR UPDATE",
+                (channel_member.id,),
+            )
+            # Invalidar caché para asegurar que leemos datos frescos después del bloqueo
+            channel_member.invalidate_recordset()
+        except Exception as e:
+            _logger.warning(
+                "No se pudo bloquear discuss_channel_member %s: %s",
+                channel_member.id,
+                e,
+            )
+        # --- CONCURRENCY FIX END ---
+
+        notification_type = None
+        if self.state == "read":
+            channel_member.write(
+                {
+                    "fetched_message_id": max(
+                        channel_member.fetched_message_id.id, self.mail_message_id.id
+                    ),
+                    "seen_message_id": self.mail_message_id.id,
+                    "last_seen_dt": fields.Datetime.now(),
+                }
+            )
+            notification_type = "discuss.channel.member/seen"
+        elif self.state == "delivered":
+            channel_member.write({"fetched_message_id": self.mail_message_id.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,
+                },
+            )

+ 76 - 44
models/whatsapp_patch.py

@@ -7,96 +7,129 @@ _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)
+            _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):
+
+    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)
+            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)
-                
+                _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):
+
+    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]
@@ -128,26 +161,26 @@ try:
                 if raise_exception:
                     raise
                 return False
-            
-            zeros = ''
+
+            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
-            
+            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', '')
-            
+            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
+                # 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 f'{country_code}' + zeros + f'{national_number}'
-        
         return formatted
 
     # Aplicar el parche reemplazando la función en el módulo
@@ -158,4 +191,3 @@ 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)
-

+ 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);
+    },
+});