| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- 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
- if not is_self_message and my_phone_id and sender_mobile == my_phone_id:
- 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(
- '<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
- 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)
|