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