import logging import requests import json import mimetypes import base64 from markupsafe import Markup 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" whatsapp_web_url = fields.Char( string="WhatsApp Web URL", readonly=False, copy=False ) whatsapp_web_login = fields.Char(string="Login", readonly=False, copy=False) whatsapp_web_api_key = fields.Char(string="API Key", readonly=False, copy=False) def get_groups(self): """ Obtiene los grupos de WhatsApp Web para la cuenta desde la base de datos de la plataforma. Returns: list: Lista de diccionarios con la información de los grupos en formato compatible con Odoo """ self.ensure_one() if not self.whatsapp_web_url: _logger.warning( "No se ha configurado la URL de WhatsApp Web para la cuenta %s", self.name, ) return [] if not self.whatsapp_web_login: _logger.warning( "No se ha configurado el Login (session_name) para la cuenta %s", self.name, ) return [] if not self.whatsapp_web_api_key: _logger.warning( "No se ha configurado la API Key para la cuenta %s", self.name ) return [] try: # Construir URL del nuevo endpoint base_url = self.whatsapp_web_url.rstrip("/") session_name = self.whatsapp_web_login url = f"{base_url}/api/v1/{session_name}/groups" headers = { "Content-Type": "application/json", "X-API-Key": self.whatsapp_web_api_key, } response = requests.get(url, headers=headers, timeout=30) if response.status_code == 200: groups = response.json() _logger.info( "Grupos obtenidos desde la base de datos: %d grupos", len(groups) ) return groups else: _logger.error( "Error al obtener groups: %s - %s", response.status_code, response.text, ) return [] except Exception as e: _logger.error("Error en la petición de groups: %s", str(e)) return [] def _find_or_create_partner_from_payload(self, contacts_data): """ Identify or create a partner based on webhook contacts list. Priority: 1. Mobile (last 10 digits match) -> Update WA ID if needed. 2. WA ID (exact match) -> Update Mobile/Name if needed. 3. Create new partner. Returns: res.partner record """ if not contacts_data: return self.env["res.partner"] contact_info = contacts_data[0] wa_id = contact_info.get("wa_id") mobile = contact_info.get("phone_number") # Normalized phone from webhook # Try to get profile name, fallback to wa_id profile_name = contact_info.get("profile", {}).get("name") or wa_id # Lid handling: If the main ID is an LID (Lyophilized ID used for privacy), # we still prefer to link it to a real phone number if available. # The payload might have "wa_id" as the LID and "phone_number" as the real number, # or vice-versa depending on context. We trust 'phone_number' field for mobile search. partner = self.env["res.partner"] # Strategy 1: Search by Mobile (last 10 digits) if mobile and len(mobile) >= 10: last_10 = mobile[-10:] partner = ( self.env["res.partner"] .sudo() .search([("mobile", "like", f"%{last_10}")], limit=1) ) if partner: _logger.info(f"Partner found by mobile {last_10}: {partner.name}") # Update WA ID if missing or different (and valid) if wa_id and partner.whatsapp_web_id != wa_id: partner.write({"whatsapp_web_id": wa_id}) return partner # Strategy 2: Search by WhatsApp Web ID if wa_id: partner = ( self.env["res.partner"] .sudo() .search([("whatsapp_web_id", "=", wa_id)], limit=1) ) if partner: _logger.info(f"Partner found by WA ID {wa_id}: {partner.name}") # Update mobile if missing if mobile and not partner.mobile: partner.write({"mobile": mobile}) return partner # Strategy 3: Create New Partner vals = { "name": profile_name, "whatsapp_web_id": wa_id, "mobile": mobile, } _logger.info(f"Creating new partner from webhook: {vals}") partner = self.env["res.partner"].sudo().create(vals) return partner def _process_messages(self, value): """ Sobrescritura completa para manejar mensajes enviados desde la misma cuenta (self-messages) y rutearlos al chat correcto. Refactorizado para soportar grupos vía metadata y creación Lazy. """ # Log del payload recibido para debug _logger.info( "DEBUG - WhatsApp Webhook Value: %s", json.dumps(value, indent=4, default=str), ) if "messages" not in value and value.get("whatsapp_business_api_data", {}).get( "messages" ): value = value["whatsapp_business_api_data"] wa_api = WhatsAppApi(self) # 1. Identificar Remitente (Sender) contacts_data = value.get("contacts", []) sender_partner = self._find_or_create_partner_from_payload(contacts_data) # Fallback Name if partner creation failed (rare) sender_name = sender_partner.name if sender_partner else "Unknown" # Determinar el ID del teléfono actual (para detectar auto-mensajes) my_phone_id = value.get("metadata", {}).get("phone_number_id") for messages in value.get("messages", []): parent_msg_id = False parent_id = False channel = False sender_mobile = messages["from"] message_type = messages["type"] # Lógica para detectar self-messages is_self_message = False # Check explicit flag first (if provided by bridge) if messages.get("fromMe") or messages.get("from_me"): is_self_message = True # Check phone ID match as fallback # Check phone ID match as fallback ( Metadata OR stored phone_uid) if not is_self_message: sender_clean = sender_mobile.replace("@c.us", "") # Compare vs Metadata ID if my_phone_id: my_clean = my_phone_id.replace("@c.us", "") if sender_clean == my_clean: is_self_message = True # Compare vs Account Phone UID (if metadata failed/missing) if not is_self_message and self.phone_uid: account_clean = self.phone_uid.replace("@c.us", "") if sender_clean == account_clean: is_self_message = True if is_self_message: # Intentar obtener el destinatario real if "to" in messages: sender_mobile = messages["to"] elif "id" in messages and "true_" in messages["id"]: # Fallback: intentar parsear del ID (formato true_NUMBER@c.us_ID o true_NUMBER_ID) # Relaxed check: Removed mandatory @c.us to support raw numbers try: # Extraer parte entre true_ y @ o primer _ parts = messages["id"].split("_") if len(parts) > 1: jid = parts[1] # 5215581845273@c.us or 5215581845273 sender_mobile = jid _logger.info( "Recuperado destinatario real desde ID: %s", sender_mobile, ) except Exception as e: _logger.warning("Error parseando ID de self-message: %s", e) _logger.info( "Detectado self-message. Redirigiendo a chat de: %s (Original From: %s)", sender_mobile, messages["from"], ) # --- 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 target_record = False 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: # Check where the parent message belongs if ( parent_id.model and parent_id.model != "discuss.channel" and parent_id.res_id ): # It's a reply to a document (Ticket, Order, etc.) try: target_record = self.env[parent_id.model].browse( parent_id.res_id ) _logger.info( f"Reply routed to Document: {parent_id.model} #{parent_id.res_id}" ) except Exception as e: _logger.warning( f"Could not load target record {parent_id.model} #{parent_id.res_id}: {e}" ) target_record = False else: # It's a reply in a channel channel = ( self.env["discuss.channel"] .sudo() .search([("message_ids", "in", parent_id.id)], limit=1) ) # 2. Lógica de Grupos (Metadata - Decoupled & Lazy) group_metadata = value.get("metadata", {}).get("group") # Support legacy group_id only if group dict missing if not group_metadata and value.get("metadata", {}).get("group_id"): group_metadata = {"id": value.get("metadata", {}).get("group_id")} if group_metadata and not target_record: # Check if group module is installed (Use 'in' operator for models with dots) if "ww.group" in self.env: # Process Group (Lazy Create + Organic Member Add) group = self.env["ww.group"].process_webhook_group( self, group_metadata, sender_partner ) if group and group.channel_id and not channel: channel = group.channel_id _logger.info( "Mensaje de grupo ruteado a canal: %s", channel.name ) else: _logger.warning( "Recibido mensaje de grupo pero ww.group no está instalado." ) # 3. Canal Directo (Si no es grupo y no tenemos target ni channel aun) if not target_record and not channel: channel = self._find_active_channel( sender_mobile, sender_name=sender_name, create_if_not_found=True ) # --- RENAME LOGIC FOR 1:1 CHATS --- # Solo si estamos usando un canal (no si vamos a un documento) 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}) # ----------------------------------- # Define Target Record if not set (fallback to channel) if not target_record: target_record = channel if not target_record: _logger.error("Could not determine target record for message") continue # Determinar autor (Author ID) # Preferimos usar el partner identificado del payload author_id = sender_partner.id if sender_partner else False # If no sender partner, try channel partner if target is channel if ( not author_id and getattr(target_record, "_name", "") == "discuss.channel" ): author_id = target_record.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, "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)] 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( ' {location_string} ' ).format(url=url, location_string=_("Location")) if messages["location"].get("name"): body += Markup("
{location_name}").format( location_name=messages["location"]["name"] ) if messages["location"].get("address"): body += Markup("
{location_address}").format( location_address=messages["location"]["address"] ) kwargs["body"] = body elif message_type == "contacts": body = "" for contact in messages["contacts"]: body += Markup( " {contact_name}
" ).format( contact_name=contact.get("name", {}).get("formatted_name", "") ) for phone in contact.get("phones", []): body += Markup("{phone_type}: {phone_number}
").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 and getattr(target_record, "_name", "") == "discuss.channel" ): partner_id = target_record.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 getattr(target_record, "_name", "") == "discuss.channel": if ( hasattr(target_record, "channel_type") and target_record.channel_type == "whatsapp" ): kwargs["whatsapp_inbound_msg_uid"] = messages["id"] target_record.message_post(**kwargs)