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 time import random 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") @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')): 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 if not body: body = "Mensaje de WhatsApp" else: body = "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 or not isinstance(body, str): _logger.error("Cuerpo del mensaje inválido: %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 # 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 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 }) 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 }) self._cr.commit() else: _logger.warning("Respuesta exitosa pero sin jobId ni id: %s", response_json) whatsapp_message.write({ 'state': 'outgoing' }) self._cr.commit() except ValueError: _logger.error("La respuesta no es JSON válido: %s", response.text) whatsapp_message.write({ 'state': 'error' }) 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' }) 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' }) self._cr.commit() time.sleep(random.randint(3, 7))