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:
sender_name = False
# 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,
"whatsapp_inbound_msg_uid": messages["id"],
}
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)