whatsapp_message.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. from odoo import models, fields, api
  2. from odoo.tools import groupby
  3. from odoo.exceptions import ValidationError
  4. import logging
  5. import markupsafe
  6. import requests
  7. import json
  8. import re
  9. import html
  10. import base64
  11. _logger = logging.getLogger(__name__)
  12. class WhatsAppMessage(models.Model):
  13. _inherit = "whatsapp.message"
  14. # Campos para soporte básico de grupos (solo por ID string, sin Many2one)
  15. # La funcionalidad completa de grupos con Many2one está en whatsapp_web_groups
  16. recipient_type = fields.Selection(
  17. [("phone", "Phone Number"), ("group", "WhatsApp Group")],
  18. string="Recipient Type",
  19. default="phone",
  20. help="Type of recipient: phone number or WhatsApp group",
  21. )
  22. job_id = fields.Char(string="Job ID", index=True, copy=False)
  23. is_batch = fields.Boolean(
  24. string="Is Batch Message",
  25. default=False,
  26. help="Indicates if the message was sent as part of a batch/mass action.",
  27. )
  28. @api.model_create_multi
  29. def create(self, vals_list):
  30. """Override create to handle messages coming from standard Discuss channel"""
  31. for vals in vals_list:
  32. mobile_number = vals.get("mobile_number")
  33. if mobile_number and "@g.us" in mobile_number:
  34. # 1. Clean up "discuss" formatting (e.g. +123456@g.us -> 123456@g.us)
  35. if mobile_number.startswith("+"):
  36. vals["mobile_number"] = mobile_number.lstrip("+")
  37. # 2. Force recipient_type to group logic
  38. vals["recipient_type"] = "group"
  39. _logger.info(
  40. "WhatsAppMessage: Auto-detected group message to %s",
  41. vals["mobile_number"],
  42. )
  43. return super().create(vals_list)
  44. @api.depends("recipient_type", "mobile_number")
  45. def _compute_final_recipient(self):
  46. """Compute the final recipient based on type"""
  47. for record in self:
  48. # Si es grupo y mobile_number termina en @g.us, usarlo directamente
  49. if (
  50. record.recipient_type == "group"
  51. and record.mobile_number
  52. and record.mobile_number.endswith("@g.us")
  53. ):
  54. record.final_recipient = record.mobile_number
  55. else:
  56. record.final_recipient = record.mobile_number
  57. final_recipient = fields.Char(
  58. "Final Recipient",
  59. compute="_compute_final_recipient",
  60. help="Final recipient (phone or group ID)",
  61. )
  62. @api.depends("mobile_number", "recipient_type")
  63. def _compute_mobile_number_formatted(self):
  64. """Override SOLO para casos específicos de grupos con WhatsApp Web"""
  65. for message in self:
  66. # SOLO intervenir si es grupo CON WhatsApp Web configurado
  67. if (
  68. hasattr(message, "recipient_type")
  69. and message.recipient_type == "group"
  70. and message.wa_account_id
  71. and message.wa_account_id.whatsapp_web_url
  72. and message.mobile_number
  73. and message.mobile_number.endswith("@g.us")
  74. ):
  75. # CRITICAL Fix for Blacklist Crash:
  76. # Odoo Enterprise tries to check/blacklist inbound numbers.
  77. # Group IDs (@g.us) fail phone validation, resulting in None, which causes SQL Null Constraint error.
  78. # By setting formatted number to empty string for INBOUND groups, we skip that potentially crashing logic.
  79. if message.message_type == "inbound":
  80. message.mobile_number_formatted = ""
  81. else:
  82. message.mobile_number_formatted = message.mobile_number
  83. else:
  84. # TODOS LOS DEMÁS CASOS: usar lógica original sin modificar
  85. super(WhatsAppMessage, message)._compute_mobile_number_formatted()
  86. @api.constrains("recipient_type", "mobile_number")
  87. def _check_recipient_configuration(self):
  88. """Validar configuración de destinatario"""
  89. for record in self:
  90. if record.recipient_type == "group":
  91. if not (
  92. record.mobile_number and record.mobile_number.endswith("@g.us")
  93. ):
  94. raise ValidationError(
  95. "Para mensajes a grupos, debe proporcionar un ID de grupo válido (@g.us)"
  96. )
  97. elif record.recipient_type == "phone":
  98. if not record.mobile_number or record.mobile_number.endswith("@g.us"):
  99. raise ValidationError(
  100. "Para mensajes a teléfonos, debe proporcionar un número telefónico válido"
  101. )
  102. def _whatsapp_phone_format(
  103. self, fpath=None, number=None, raise_on_format_error=False
  104. ):
  105. """Override SOLO para casos específicos de grupos - NO interferir con funcionalidad nativa"""
  106. self.ensure_one()
  107. # SOLO intervenir en casos muy específicos de grupos
  108. # Si es un mensaje a grupo Y tiene WhatsApp Web configurado
  109. if (
  110. hasattr(self, "recipient_type")
  111. and self.recipient_type == "group"
  112. and self.wa_account_id
  113. and self.wa_account_id.whatsapp_web_url
  114. ):
  115. # Si el número es un ID de grupo (termina en @g.us), retornarlo sin validar
  116. if number and number.endswith("@g.us"):
  117. return number
  118. elif self.mobile_number and self.mobile_number.endswith("@g.us"):
  119. return self.mobile_number
  120. # TODOS LOS DEMÁS CASOS: usar validación original sin modificar
  121. return super()._whatsapp_phone_format(fpath, number, raise_on_format_error)
  122. def _get_final_destination(self):
  123. """Método mejorado para obtener destino final (grupo o teléfono)"""
  124. self.ensure_one()
  125. # Si el mobile_number es un ID de grupo (termina en @g.us)
  126. if self.mobile_number and self.mobile_number.endswith("@g.us"):
  127. return self.mobile_number
  128. return False
  129. def _send_message(self, with_commit=False):
  130. url = ""
  131. session_name = ""
  132. api_key = ""
  133. if self.wa_account_id and self.wa_account_id.whatsapp_web_url:
  134. url = self.wa_account_id.whatsapp_web_url
  135. session_name = self.wa_account_id.whatsapp_web_login or ""
  136. api_key = self.wa_account_id.whatsapp_web_api_key or ""
  137. _logger.info(
  138. "WHATSAPP WEB SEND MESSAGE - URL: %s, Session: %s", url, session_name
  139. )
  140. group = ""
  141. if not url or not session_name or not api_key:
  142. # Si no hay configuración de WhatsApp Web, usar método original
  143. super()._send_message(with_commit)
  144. return
  145. for whatsapp_message in self:
  146. # Determinar destinatario final usando solo la nueva lógica
  147. final_destination = whatsapp_message._get_final_destination()
  148. if final_destination:
  149. group = final_destination
  150. attachment = False
  151. if whatsapp_message.wa_template_id:
  152. record = self.env[whatsapp_message.wa_template_id.model].browse(
  153. whatsapp_message.mail_message_id.res_id
  154. )
  155. # codigo con base a whatsapp.message y whatsapp.template para generacion de adjuntos
  156. RecordModel = self.env[
  157. whatsapp_message.mail_message_id.model
  158. ].with_user(whatsapp_message.create_uid)
  159. from_record = RecordModel.browse(
  160. whatsapp_message.mail_message_id.res_id
  161. )
  162. # if retrying message then we need to unlink previous attachment
  163. # in case of header with report in order to generate it again
  164. if (
  165. whatsapp_message.wa_template_id.report_id
  166. and whatsapp_message.wa_template_id.header_type == "document"
  167. and whatsapp_message.mail_message_id.attachment_ids
  168. ):
  169. whatsapp_message.mail_message_id.attachment_ids.unlink()
  170. if not attachment and whatsapp_message.wa_template_id.report_id:
  171. attachment = whatsapp_message.wa_template_id._generate_attachment_from_report(
  172. record
  173. )
  174. if (
  175. not attachment
  176. and whatsapp_message.wa_template_id.header_attachment_ids
  177. ):
  178. attachment = whatsapp_message.wa_template_id.header_attachment_ids[
  179. 0
  180. ]
  181. if (
  182. attachment
  183. and attachment
  184. not in whatsapp_message.mail_message_id.attachment_ids
  185. ):
  186. whatsapp_message.mail_message_id.attachment_ids = [
  187. (4, attachment.id)
  188. ]
  189. # no template
  190. elif whatsapp_message.mail_message_id.attachment_ids:
  191. attachment = whatsapp_message.mail_message_id.attachment_ids[0]
  192. # codigo para limpiar body y numero
  193. body = whatsapp_message.body
  194. # Asegurar que body sea string y limpiar HTML
  195. if body:
  196. if isinstance(body, markupsafe.Markup):
  197. text = html.unescape(str(body))
  198. else:
  199. text = str(body)
  200. # Reemplazamos las etiquetas BR y P
  201. text = re.sub(r"<br\s*/?>|<BR\s*/?>", "\n", text)
  202. text = re.sub(r"<p>|<P>", "\n\n", text)
  203. text = re.sub(r"</p>|</P>", "", text)
  204. # Eliminamos el resto de etiquetas HTML
  205. text = re.sub(r"<[^>]+>", "", text)
  206. # Limpiamos múltiples saltos de línea
  207. text = re.sub(r"\n\s*\n\s*\n", "\n\n", text)
  208. # Limpiamos espacios en blanco al inicio y final
  209. body = text.strip()
  210. # Asegurar que no esté vacío
  211. if not body:
  212. body = "Mensaje de WhatsApp"
  213. else:
  214. body = "Mensaje de WhatsApp"
  215. # Determinar número/destinatario final
  216. if group:
  217. # Si ya hay un grupo determinado, usarlo
  218. number = group
  219. else:
  220. # Formatear número según el tipo de destinatario
  221. if whatsapp_message.recipient_type == "group":
  222. if (
  223. whatsapp_message.mobile_number
  224. and whatsapp_message.mobile_number.endswith("@g.us")
  225. ):
  226. number = whatsapp_message.mobile_number
  227. else:
  228. _logger.error(
  229. "Mensaje configurado como grupo pero sin destinatario válido"
  230. )
  231. continue
  232. else:
  233. # Lógica original para números de teléfono
  234. number = whatsapp_message.mobile_number
  235. if number:
  236. number = (
  237. number.replace(" ", "").replace("+", "").replace("-", "")
  238. )
  239. if number.startswith("52") and len(number) == 12:
  240. number = "521" + number[2:]
  241. if len(number) == 10:
  242. number = "521" + number
  243. number = number + "@c.us"
  244. # ENVIO DE MENSAJE - Nueva API Gateway
  245. parent_message_id = ""
  246. if (
  247. whatsapp_message.mail_message_id
  248. and whatsapp_message.mail_message_id.parent_id
  249. ):
  250. parent_id = whatsapp_message.mail_message_id.parent_id.wa_message_ids
  251. if parent_id:
  252. parent_message_id = parent_id[0].msg_uid
  253. # Validar que tenemos un destinatario válido
  254. if not number:
  255. _logger.error("No se pudo determinar el destinatario para el mensaje")
  256. continue
  257. # Validar que tenemos un cuerpo de mensaje válido
  258. if not body or not isinstance(body, str):
  259. _logger.error("Cuerpo del mensaje inválido: %s", body)
  260. body = "Mensaje de WhatsApp"
  261. # Determinar si es grupo
  262. is_group = number.endswith("@g.us") if number else False
  263. # Construir URL base
  264. base_url = url.rstrip("/")
  265. endpoint = "send-message"
  266. # Headers con autenticación
  267. headers = {"Content-Type": "application/json", "X-API-Key": api_key}
  268. # Preparar payload según tipo de mensaje
  269. if attachment:
  270. # Determinar endpoint según tipo de archivo
  271. mimetype = attachment.mimetype or "application/octet-stream"
  272. if mimetype.startswith("image/"):
  273. endpoint = "send-image"
  274. elif mimetype.startswith("video/"):
  275. endpoint = "send-video"
  276. elif mimetype.startswith("audio/"):
  277. endpoint = "send-voice"
  278. else:
  279. endpoint = "send-file"
  280. # Convertir archivo a base64 con prefijo data URI
  281. file_base64 = base64.b64encode(attachment.raw).decode("utf-8")
  282. base64_with_prefix = f"data:{mimetype};base64,{file_base64}"
  283. # En wppconnect-server send-image/file espera 'phone' (singular) o 'phone' (array)
  284. # Para consistencia con wpp.js, usamos la misma estructura
  285. payload = {
  286. "phone": number,
  287. "base64": base64_with_prefix,
  288. "filename": attachment.name or "file",
  289. "caption": body,
  290. "isGroup": is_group,
  291. }
  292. if parent_message_id:
  293. payload["quotedMessageId"] = parent_message_id
  294. else:
  295. # Mensaje de texto
  296. # Alineación con wpp.js: phone, message, isGroup
  297. payload = {"phone": number, "message": body, "isGroup": is_group}
  298. if parent_message_id:
  299. payload["quotedMessageId"] = parent_message_id
  300. # Priority Logic:
  301. # If it's NOT a batch message AND NOT a marketing campaign message, give it high priority.
  302. # Marketing messages are identifiable by having associated marketing_trace_ids.
  303. is_marketing = bool(
  304. hasattr(whatsapp_message, "marketing_trace_ids")
  305. and whatsapp_message.marketing_trace_ids
  306. )
  307. if not whatsapp_message.is_batch and not is_marketing:
  308. payload["priority"] = 10
  309. # Construir URL completa
  310. full_url = f"{base_url}/api/v1/{session_name}/{endpoint}"
  311. # Log del payload para debugging
  312. _logger.info(
  313. "Enviando mensaje a %s (%s) usando endpoint %s",
  314. number,
  315. "grupo" if is_group else "contacto",
  316. endpoint,
  317. )
  318. # Realizar petición POST
  319. try:
  320. response = requests.post(
  321. full_url, json=payload, headers=headers, timeout=60
  322. )
  323. # Procesar respuesta
  324. _logger.info("WhatsApp API Response: %s", response.text)
  325. if response.status_code == 200:
  326. try:
  327. response_json = response.json()
  328. # La nueva API puede devolver jobId (mensaje encolado) o id (enviado directamente)
  329. if "jobId" in response_json:
  330. # Mensaje encolado - si la API devuelve jobId, significa que el mensaje fue aceptado
  331. # y está en proceso de envío, por lo que lo marcamos como 'sent'
  332. job_id = response_json.get("jobId")
  333. _logger.info(
  334. "Mensaje aceptado por la API. Job ID: %s - Marcando como enviado",
  335. job_id,
  336. )
  337. whatsapp_message.write(
  338. {
  339. "state": "sent", # Marcar como enviado ya que fue aceptado por la API
  340. "msg_uid": job_id,
  341. "job_id": job_id,
  342. }
  343. )
  344. if with_commit:
  345. self._cr.commit()
  346. elif "id" in response_json:
  347. # Mensaje enviado directamente
  348. msg_id = response_json.get("id")
  349. if isinstance(msg_id, dict) and "_serialized" in msg_id:
  350. msg_uid = msg_id["_serialized"]
  351. elif isinstance(msg_id, str):
  352. msg_uid = msg_id
  353. else:
  354. msg_uid = str(msg_id)
  355. _logger.info(
  356. "Mensaje enviado exitosamente. ID: %s", msg_uid
  357. )
  358. whatsapp_message.write(
  359. {"state": "sent", "msg_uid": msg_uid}
  360. )
  361. if with_commit:
  362. self._cr.commit()
  363. else:
  364. _logger.warning(
  365. "Respuesta exitosa pero sin jobId ni id: %s",
  366. response_json,
  367. )
  368. whatsapp_message.write({"state": "outgoing"})
  369. if with_commit:
  370. self._cr.commit()
  371. except ValueError:
  372. _logger.error(
  373. "La respuesta no es JSON válido: %s", response.text
  374. )
  375. whatsapp_message.write({"state": "error"})
  376. if with_commit:
  377. self._cr.commit()
  378. else:
  379. _logger.error(
  380. "Error en la petición. Código: %s, Respuesta: %s",
  381. response.status_code,
  382. response.text,
  383. )
  384. whatsapp_message.write({"state": "error"})
  385. if with_commit:
  386. self._cr.commit()
  387. except requests.exceptions.RequestException as e:
  388. _logger.error("Error de conexión al enviar mensaje: %s", str(e))
  389. whatsapp_message.write({"state": "error"})
  390. if with_commit:
  391. self._cr.commit()
  392. def _process_statuses(self, value):
  393. """
  394. Sobrescritura para manejar la reconciliación de IDs usando job_id.
  395. Si la notificación trae un job_id en metadata, buscamos el mensaje por ese ID
  396. y actualizamos el msg_uid al ID real de WhatsApp antes de procesar el estado.
  397. """
  398. # Pre-process statuses to reconcile IDs
  399. for status in value.get("statuses", []):
  400. real_wa_id = status.get("id")
  401. # Buscar job_id en metadata (según requerimiento del usuario)
  402. # Estructura esperada: metadata: { job_id: "..." }
  403. metadata = status.get("metadata", {})
  404. job_id = metadata.get("job_id")
  405. if job_id and real_wa_id:
  406. # Buscar mensaje por job_id que tenga un msg_uid incorrecto (el del worker)
  407. # O simplemente buscar por job_id y asegurar que msg_uid sea el real
  408. message = (
  409. self.env["whatsapp.message"]
  410. .sudo()
  411. .search([("job_id", "=", job_id)], limit=1)
  412. )
  413. if message:
  414. if message.msg_uid != real_wa_id:
  415. _logger.info(
  416. "Reconciliando WhatsApp ID: JobID %s -> Real ID %s",
  417. job_id,
  418. real_wa_id,
  419. )
  420. # Actualizamos msg_uid al real para que el super() lo encuentre
  421. message.msg_uid = real_wa_id
  422. else:
  423. _logger.info("Mensaje ya reconciliado para JobID %s", job_id)
  424. else:
  425. _logger.warning(
  426. "Recibido status con JobID %s pero no se encontró mensaje en Odoo",
  427. job_id,
  428. )
  429. # Call original implementation to handle standard status updates (sent, delivered, read, etc.)
  430. return super()._process_statuses(value)
  431. def _update_message_fetched_seen(self):
  432. """
  433. Sobrescritura para manejar concurrencia en la actualización de discuss.channel.member.
  434. Usa bloqueo de fila (SELECT FOR UPDATE) para evitar SERIALIZATION_FAILURE.
  435. """
  436. self.ensure_one()
  437. if self.mail_message_id.model != "discuss.channel":
  438. return
  439. channel = self.env["discuss.channel"].browse(self.mail_message_id.res_id)
  440. # Buscar el miembro usando SQL directo para bloquear la fila antes de leer/escribir
  441. # Odoo ORM no soporta lock explícito fácil en search(), así que primero buscamos ID y luego bloqueamos.
  442. channel_member = channel.channel_member_ids.filtered(
  443. lambda cm: cm.partner_id == channel.whatsapp_partner_id
  444. )
  445. if not channel_member:
  446. return
  447. channel_member = channel_member[0]
  448. # --- CONCURRENCY FIX START ---
  449. try:
  450. # Bloquear la fila específica del miembro del canal
  451. self.env.cr.execute(
  452. "SELECT id FROM discuss_channel_member WHERE id = %s FOR UPDATE",
  453. (channel_member.id,),
  454. )
  455. # Invalidar caché para asegurar que leemos datos frescos después del bloqueo
  456. channel_member.invalidate_recordset()
  457. except Exception as e:
  458. _logger.warning(
  459. "No se pudo bloquear discuss_channel_member %s: %s",
  460. channel_member.id,
  461. e,
  462. )
  463. # --- CONCURRENCY FIX END ---
  464. notification_type = None
  465. if self.state == "read":
  466. channel_member.write(
  467. {
  468. "fetched_message_id": max(
  469. channel_member.fetched_message_id.id, self.mail_message_id.id
  470. ),
  471. "seen_message_id": self.mail_message_id.id,
  472. "last_seen_dt": fields.Datetime.now(),
  473. }
  474. )
  475. notification_type = "discuss.channel.member/seen"
  476. elif self.state == "delivered":
  477. channel_member.write({"fetched_message_id": self.mail_message_id.id})
  478. notification_type = "discuss.channel.member/fetched"
  479. if notification_type:
  480. channel._bus_send(
  481. notification_type,
  482. {
  483. "channel_id": channel.id,
  484. "id": channel_member.id,
  485. "last_message_id": self.mail_message_id.id,
  486. "partner_id": channel.whatsapp_partner_id.id,
  487. },
  488. )