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 from odoo.tools import html2plaintext _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] # Fallback: check parent message attachments (e.g. from Chatter) elif ( whatsapp_message.mail_message_id.parent_id and whatsapp_message.mail_message_id.parent_id.attachment_ids ): attachment = whatsapp_message.mail_message_id.parent_id.attachment_ids[ 0 ] # codigo para limpiar body y numero body = whatsapp_message.body # Asegurar que body sea string y limpiar HTML usando html2plaintext para robustez if body: # Log raw body to debug inputs _logger.info(f"WHATSAPP RAW BODY Input: {body!r}") # Use html2plaintext to strip all tags and entities correctly text = html2plaintext(str(body)) # Limpiamos espacios en blanco al inicio y final body = text.strip() _logger.info(f"WHATSAPP CLEAN BODY Output: {body!r}") # 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, }, )