from odoo import models, fields, api
from odoo.tools import groupby
from odoo.exceptions import ValidationError
import logging
import markupsafe
import requests
import json
import re
import html
import base64
_logger = logging.getLogger(__name__)
class WhatsAppMessage(models.Model):
_inherit = "whatsapp.message"
# Campos para soporte básico de grupos (solo por ID string, sin Many2one)
# La funcionalidad completa de grupos con Many2one está en whatsapp_web_groups
recipient_type = fields.Selection(
[("phone", "Phone Number"), ("group", "WhatsApp Group")],
string="Recipient Type",
default="phone",
help="Type of recipient: phone number or WhatsApp group",
)
job_id = fields.Char(string="Job ID", index=True, copy=False)
is_batch = fields.Boolean(
string="Is Batch Message",
default=False,
help="Indicates if the message was sent as part of a batch/mass action.",
)
@api.model_create_multi
def create(self, vals_list):
"""Override create to handle messages coming from standard Discuss channel"""
for vals in vals_list:
mobile_number = vals.get("mobile_number")
if mobile_number and "@g.us" in mobile_number:
# 1. Clean up "discuss" formatting (e.g. +123456@g.us -> 123456@g.us)
if mobile_number.startswith("+"):
vals["mobile_number"] = mobile_number.lstrip("+")
# 2. Force recipient_type to group logic
vals["recipient_type"] = "group"
_logger.info(
"WhatsAppMessage: Auto-detected group message to %s",
vals["mobile_number"],
)
return super().create(vals_list)
@api.depends("recipient_type", "mobile_number")
def _compute_final_recipient(self):
"""Compute the final recipient based on type"""
for record in self:
# Si es grupo y mobile_number termina en @g.us, usarlo directamente
if (
record.recipient_type == "group"
and record.mobile_number
and record.mobile_number.endswith("@g.us")
):
record.final_recipient = record.mobile_number
else:
record.final_recipient = record.mobile_number
final_recipient = fields.Char(
"Final Recipient",
compute="_compute_final_recipient",
help="Final recipient (phone or group ID)",
)
@api.depends("mobile_number", "recipient_type")
def _compute_mobile_number_formatted(self):
"""Override SOLO para casos específicos de grupos con WhatsApp Web"""
for message in self:
# SOLO intervenir si es grupo CON WhatsApp Web configurado
if (
hasattr(message, "recipient_type")
and message.recipient_type == "group"
and message.wa_account_id
and message.wa_account_id.whatsapp_web_url
and message.mobile_number
and message.mobile_number.endswith("@g.us")
):
# CRITICAL Fix for Blacklist Crash:
# Odoo Enterprise tries to check/blacklist inbound numbers.
# Group IDs (@g.us) fail phone validation, resulting in None, which causes SQL Null Constraint error.
# By setting formatted number to empty string for INBOUND groups, we skip that potentially crashing logic.
if message.message_type == "inbound":
message.mobile_number_formatted = ""
else:
message.mobile_number_formatted = message.mobile_number
else:
# TODOS LOS DEMÁS CASOS: usar lógica original sin modificar
super(WhatsAppMessage, message)._compute_mobile_number_formatted()
@api.constrains("recipient_type", "mobile_number")
def _check_recipient_configuration(self):
"""Validar configuración de destinatario"""
for record in self:
if record.recipient_type == "group":
if not (
record.mobile_number and record.mobile_number.endswith("@g.us")
):
raise ValidationError(
"Para mensajes a grupos, debe proporcionar un ID de grupo válido (@g.us)"
)
elif record.recipient_type == "phone":
if not record.mobile_number or record.mobile_number.endswith("@g.us"):
raise ValidationError(
"Para mensajes a teléfonos, debe proporcionar un número telefónico válido"
)
def _whatsapp_phone_format(
self, fpath=None, number=None, raise_on_format_error=False
):
"""Override SOLO para casos específicos de grupos - NO interferir con funcionalidad nativa"""
self.ensure_one()
# SOLO intervenir en casos muy específicos de grupos
# Si es un mensaje a grupo Y tiene WhatsApp Web configurado
if (
hasattr(self, "recipient_type")
and self.recipient_type == "group"
and self.wa_account_id
and self.wa_account_id.whatsapp_web_url
):
# Si el número es un ID de grupo (termina en @g.us), retornarlo sin validar
if number and number.endswith("@g.us"):
return number
elif self.mobile_number and self.mobile_number.endswith("@g.us"):
return self.mobile_number
# TODOS LOS DEMÁS CASOS: usar validación original sin modificar
return super()._whatsapp_phone_format(fpath, number, raise_on_format_error)
def _get_final_destination(self):
"""Método mejorado para obtener destino final (grupo o teléfono)"""
self.ensure_one()
# Si el mobile_number es un ID de grupo (termina en @g.us)
if self.mobile_number and self.mobile_number.endswith("@g.us"):
return self.mobile_number
return False
def _send_message(self, with_commit=False):
url = ""
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
)
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
)
# 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
):
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
elif whatsapp_message.mail_message_id.attachment_ids:
attachment = whatsapp_message.mail_message_id.attachment_ids[0]
# 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"
|
", "\n", text)
text = re.sub(r"
|
", "\n\n", text) text = re.sub(r"
|", "", text) # Eliminamos el resto de etiquetas HTML text = re.sub(r"<[^>]+>", "", text) # Limpiamos múltiples saltos de línea 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 (solo si no hay adjunto) if not body: body = "" if attachment else "Mensaje de WhatsApp" else: body = "" if attachment else "Mensaje de WhatsApp" # Determinar número/destinatario final if group: # Si ya hay un grupo determinado, usarlo number = group else: # Formatear número según el tipo de destinatario if whatsapp_message.recipient_type == "group": if ( whatsapp_message.mobile_number and whatsapp_message.mobile_number.endswith("@g.us") ): number = whatsapp_message.mobile_number else: _logger.error( "Mensaje configurado como grupo pero sin destinatario válido" ) continue else: # Lógica original para números de teléfono number = whatsapp_message.mobile_number if number: number = ( number.replace(" ", "").replace("+", "").replace("-", "") ) if number.startswith("52") and len(number) == 12: number = "521" + number[2:] if len(number) == 10: number = "521" + number number = number + "@c.us" # ENVIO DE MENSAJE - Nueva API Gateway parent_message_id = "" if ( whatsapp_message.mail_message_id and whatsapp_message.mail_message_id.parent_id ): parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids if parent_id: parent_message_id = parent_id[0].msg_uid # Validar que tenemos un destinatario válido if not number: _logger.error("No se pudo determinar el destinatario para el mensaje") continue # Validar que tenemos un cuerpo de mensaje válido if not body and not attachment: _logger.error("Cuerpo del mensaje inválido: %s", body) body = "Mensaje de WhatsApp" elif body and not isinstance(body, str): _logger.error("Cuerpo del mensaje inválido (tipo incorrecto): %s", body) body = "Mensaje de WhatsApp" # Determinar si es grupo is_group = number.endswith("@g.us") if number else False # Construir URL base base_url = url.rstrip("/") endpoint = "send-message" # Headers con autenticación headers = {"Content-Type": "application/json", "X-API-Key": api_key} # Preparar payload según tipo de mensaje if attachment: # Determinar endpoint según tipo de archivo mimetype = attachment.mimetype or "application/octet-stream" if mimetype.startswith("image/"): endpoint = "send-image" elif mimetype.startswith("video/"): endpoint = "send-video" elif mimetype.startswith("audio/"): endpoint = "send-voice" else: endpoint = "send-file" # Convertir archivo a base64 con prefijo data URI file_base64 = base64.b64encode(attachment.raw).decode("utf-8") base64_with_prefix = f"data:{mimetype};base64,{file_base64}" # En wppconnect-server send-image/file espera 'phone' (singular) o 'phone' (array) # Para consistencia con wpp.js, usamos la misma estructura payload = { "phone": number, "base64": base64_with_prefix, "filename": attachment.name or "file", "caption": body, "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} if parent_message_id: payload["quotedMessageId"] = parent_message_id # Priority Logic: # If it's NOT a batch message AND NOT a marketing campaign message, give it high priority. # Marketing messages are identifiable by having associated marketing_trace_ids. is_marketing = bool( hasattr(whatsapp_message, "marketing_trace_ids") and whatsapp_message.marketing_trace_ids ) if not whatsapp_message.is_batch and not is_marketing: payload["priority"] = 10 # Construir URL completa full_url = f"{base_url}/api/v1/{session_name}/{endpoint}" # Log del payload para debugging _logger.info( "Enviando mensaje a %s (%s) usando endpoint %s", number, "grupo" if is_group else "contacto", endpoint, ) # Realizar petición POST try: response = requests.post( full_url, json=payload, headers=headers, timeout=60 ) # Procesar respuesta _logger.info("WhatsApp API Response: %s", response.text) if response.status_code == 200: try: response_json = response.json() # La nueva API puede devolver jobId (mensaje encolado) o id (enviado directamente) if "jobId" in response_json: # Mensaje encolado - si la API devuelve jobId, significa que el mensaje fue aceptado # y está en proceso de envío, por lo que lo marcamos como 'sent' job_id = response_json.get("jobId") _logger.info( "Mensaje aceptado por la API. Job ID: %s - Marcando como enviado", job_id, ) whatsapp_message.write( { "state": "sent", # Marcar como enviado ya que fue aceptado por la API "msg_uid": job_id, "job_id": job_id, } ) if with_commit: self._cr.commit() elif "id" in response_json: # Mensaje enviado directamente msg_id = response_json.get("id") if isinstance(msg_id, dict) and "_serialized" in msg_id: msg_uid = msg_id["_serialized"] elif isinstance(msg_id, str): msg_uid = msg_id else: msg_uid = str(msg_id) _logger.info( "Mensaje enviado exitosamente. ID: %s", msg_uid ) whatsapp_message.write( {"state": "sent", "msg_uid": msg_uid} ) if with_commit: self._cr.commit() else: _logger.warning( "Respuesta exitosa pero sin jobId ni id: %s", response_json, ) whatsapp_message.write({"state": "outgoing"}) if with_commit: self._cr.commit() except ValueError: _logger.error( "La respuesta no es JSON válido: %s", response.text ) whatsapp_message.write({"state": "error"}) if with_commit: self._cr.commit() else: _logger.error( "Error en la petición. Código: %s, Respuesta: %s", response.status_code, response.text, ) whatsapp_message.write({"state": "error"}) if with_commit: self._cr.commit() except requests.exceptions.RequestException as e: _logger.error("Error de conexión al enviar mensaje: %s", str(e)) whatsapp_message.write({"state": "error"}) if with_commit: self._cr.commit() def _process_statuses(self, value): """ Sobrescritura para manejar la reconciliación de IDs usando job_id. Si la notificación trae un job_id en metadata, buscamos el mensaje por ese ID y actualizamos el msg_uid al ID real de WhatsApp antes de procesar el estado. """ # Pre-process statuses to reconcile IDs metadata = value.get("metadata", {}) job_id = metadata.get("job_id") for status in value.get("statuses", []): real_wa_id = status.get("id") if job_id and real_wa_id: # Buscar mensaje por job_id que tenga un msg_uid incorrecto (el del worker) # O simplemente buscar por job_id y asegurar que msg_uid sea el real message = ( self.env["whatsapp.message"] .sudo() .search([("job_id", "=", job_id)], limit=1) ) if message: if message.msg_uid != real_wa_id: _logger.info( "Reconciliando WhatsApp ID: JobID %s -> Real ID %s", job_id, real_wa_id, ) # Actualizamos msg_uid al real para que el super() lo encuentre message.msg_uid = real_wa_id else: _logger.info("Mensaje ya reconciliado para JobID %s", job_id) else: _logger.warning( "Recibido status con JobID %s pero no se encontró mensaje en Odoo", job_id, ) # Call original implementation to handle standard status updates (sent, delivered, read, etc.) return super()._process_statuses(value) def _update_message_fetched_seen(self): """ Sobrescritura para manejar concurrencia en la actualización de discuss.channel.member. Usa bloqueo de fila (NO KEY UPDATE SKIP LOCKED) para evitar SERIALIZATION_FAILURE. """ self.ensure_one() if self.mail_message_id.model != "discuss.channel": return channel = self.env["discuss.channel"].browse(self.mail_message_id.res_id) # Buscar el miembro asociado al partner de WhatsApp channel_member = channel.channel_member_ids.filtered( lambda cm: cm.partner_id == channel.whatsapp_partner_id ) if not channel_member: return channel_member = channel_member[0] member_id = channel_member.id notification_type = None updated_rows = 0 if self.state == "read": # Intentar actualizar usando SKIP LOCKED # Si el registro está bloqueado, simplemente saltamos esta actualización # ya que fetched/seen son contadores monotónicos o de estado reciente self.env.cr.execute( """ UPDATE discuss_channel_member SET fetched_message_id = GREATEST(fetched_message_id, %s), seen_message_id = %s, last_seen_dt = NOW() WHERE id IN ( SELECT id FROM discuss_channel_member WHERE id = %s FOR NO KEY UPDATE SKIP LOCKED ) RETURNING id """, (self.mail_message_id.id, self.mail_message_id.id, member_id), ) # Si fetchone devuelve algo, significa que actualizó. Si es None, saltó por bloqueo. if self.env.cr.fetchone(): channel_member.invalidate_recordset( ["fetched_message_id", "seen_message_id", "last_seen_dt"] ) notification_type = "discuss.channel.member/seen" elif self.state == "delivered": self.env.cr.execute( """ UPDATE discuss_channel_member SET fetched_message_id = GREATEST(fetched_message_id, %s) WHERE id IN ( SELECT id FROM discuss_channel_member WHERE id = %s FOR NO KEY UPDATE SKIP LOCKED ) RETURNING id """, (self.mail_message_id.id, member_id), ) if self.env.cr.fetchone(): channel_member.invalidate_recordset(["fetched_message_id"]) notification_type = "discuss.channel.member/fetched" 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, }, )