whatsapp_message.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  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 (solo si no hay adjunto)
  211. if not body:
  212. body = "" if attachment else "Mensaje de WhatsApp"
  213. else:
  214. body = "" if attachment else "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 and not attachment:
  259. _logger.error("Cuerpo del mensaje inválido: %s", body)
  260. body = "Mensaje de WhatsApp"
  261. elif body and not isinstance(body, str):
  262. _logger.error("Cuerpo del mensaje inválido (tipo incorrecto): %s", body)
  263. body = "Mensaje de WhatsApp"
  264. # Determinar si es grupo
  265. is_group = number.endswith("@g.us") if number else False
  266. # Construir URL base
  267. base_url = url.rstrip("/")
  268. endpoint = "send-message"
  269. # Headers con autenticación
  270. headers = {"Content-Type": "application/json", "X-API-Key": api_key}
  271. # Preparar payload según tipo de mensaje
  272. if attachment:
  273. # Determinar endpoint según tipo de archivo
  274. mimetype = attachment.mimetype or "application/octet-stream"
  275. if mimetype.startswith("image/"):
  276. endpoint = "send-image"
  277. elif mimetype.startswith("video/"):
  278. endpoint = "send-video"
  279. elif mimetype.startswith("audio/"):
  280. endpoint = "send-voice"
  281. else:
  282. endpoint = "send-file"
  283. # Convertir archivo a base64 con prefijo data URI
  284. file_base64 = base64.b64encode(attachment.raw).decode("utf-8")
  285. base64_with_prefix = f"data:{mimetype};base64,{file_base64}"
  286. # En wppconnect-server send-image/file espera 'phone' (singular) o 'phone' (array)
  287. # Para consistencia con wpp.js, usamos la misma estructura
  288. payload = {
  289. "phone": number,
  290. "base64": base64_with_prefix,
  291. "filename": attachment.name or "file",
  292. "caption": body,
  293. "isGroup": is_group,
  294. }
  295. if parent_message_id:
  296. payload["quotedMessageId"] = parent_message_id
  297. else:
  298. # Mensaje de texto
  299. # Alineación con wpp.js: phone, message, isGroup
  300. payload = {"phone": number, "message": body, "isGroup": is_group}
  301. if parent_message_id:
  302. payload["quotedMessageId"] = parent_message_id
  303. # Priority Logic:
  304. # If it's NOT a batch message AND NOT a marketing campaign message, give it high priority.
  305. # Marketing messages are identifiable by having associated marketing_trace_ids.
  306. is_marketing = bool(
  307. hasattr(whatsapp_message, "marketing_trace_ids")
  308. and whatsapp_message.marketing_trace_ids
  309. )
  310. if not whatsapp_message.is_batch and not is_marketing:
  311. payload["priority"] = 10
  312. # Construir URL completa
  313. full_url = f"{base_url}/api/v1/{session_name}/{endpoint}"
  314. # Log del payload para debugging
  315. _logger.info(
  316. "Enviando mensaje a %s (%s) usando endpoint %s",
  317. number,
  318. "grupo" if is_group else "contacto",
  319. endpoint,
  320. )
  321. # Realizar petición POST
  322. try:
  323. response = requests.post(
  324. full_url, json=payload, headers=headers, timeout=60
  325. )
  326. # Procesar respuesta
  327. _logger.info("WhatsApp API Response: %s", response.text)
  328. if response.status_code == 200:
  329. try:
  330. response_json = response.json()
  331. # La nueva API puede devolver jobId (mensaje encolado) o id (enviado directamente)
  332. if "jobId" in response_json:
  333. # Mensaje encolado - si la API devuelve jobId, significa que el mensaje fue aceptado
  334. # y está en proceso de envío, por lo que lo marcamos como 'sent'
  335. job_id = response_json.get("jobId")
  336. _logger.info(
  337. "Mensaje aceptado por la API. Job ID: %s - Marcando como enviado",
  338. job_id,
  339. )
  340. whatsapp_message.write(
  341. {
  342. "state": "sent", # Marcar como enviado ya que fue aceptado por la API
  343. "msg_uid": job_id,
  344. "job_id": job_id,
  345. }
  346. )
  347. if with_commit:
  348. self._cr.commit()
  349. elif "id" in response_json:
  350. # Mensaje enviado directamente
  351. msg_id = response_json.get("id")
  352. if isinstance(msg_id, dict) and "_serialized" in msg_id:
  353. msg_uid = msg_id["_serialized"]
  354. elif isinstance(msg_id, str):
  355. msg_uid = msg_id
  356. else:
  357. msg_uid = str(msg_id)
  358. _logger.info(
  359. "Mensaje enviado exitosamente. ID: %s", msg_uid
  360. )
  361. whatsapp_message.write(
  362. {"state": "sent", "msg_uid": msg_uid}
  363. )
  364. if with_commit:
  365. self._cr.commit()
  366. else:
  367. _logger.warning(
  368. "Respuesta exitosa pero sin jobId ni id: %s",
  369. response_json,
  370. )
  371. whatsapp_message.write({"state": "outgoing"})
  372. if with_commit:
  373. self._cr.commit()
  374. except ValueError:
  375. _logger.error(
  376. "La respuesta no es JSON válido: %s", response.text
  377. )
  378. whatsapp_message.write({"state": "error"})
  379. if with_commit:
  380. self._cr.commit()
  381. else:
  382. _logger.error(
  383. "Error en la petición. Código: %s, Respuesta: %s",
  384. response.status_code,
  385. response.text,
  386. )
  387. whatsapp_message.write({"state": "error"})
  388. if with_commit:
  389. self._cr.commit()
  390. except requests.exceptions.RequestException as e:
  391. _logger.error("Error de conexión al enviar mensaje: %s", str(e))
  392. whatsapp_message.write({"state": "error"})
  393. if with_commit:
  394. self._cr.commit()
  395. def _process_statuses(self, value):
  396. """
  397. Sobrescritura para manejar la reconciliación de IDs usando job_id.
  398. Si la notificación trae un job_id en metadata, buscamos el mensaje por ese ID
  399. y actualizamos el msg_uid al ID real de WhatsApp antes de procesar el estado.
  400. """
  401. # Pre-process statuses to reconcile IDs
  402. metadata = value.get("metadata", {})
  403. job_id = metadata.get("job_id")
  404. for status in value.get("statuses", []):
  405. real_wa_id = status.get("id")
  406. if job_id and real_wa_id:
  407. # Buscar mensaje por job_id que tenga un msg_uid incorrecto (el del worker)
  408. # O simplemente buscar por job_id y asegurar que msg_uid sea el real
  409. message = (
  410. self.env["whatsapp.message"]
  411. .sudo()
  412. .search([("job_id", "=", job_id)], limit=1)
  413. )
  414. if message:
  415. if message.msg_uid != real_wa_id:
  416. _logger.info(
  417. "Reconciliando WhatsApp ID: JobID %s -> Real ID %s",
  418. job_id,
  419. real_wa_id,
  420. )
  421. # Actualizamos msg_uid al real para que el super() lo encuentre
  422. message.msg_uid = real_wa_id
  423. else:
  424. _logger.info("Mensaje ya reconciliado para JobID %s", job_id)
  425. else:
  426. _logger.warning(
  427. "Recibido status con JobID %s pero no se encontró mensaje en Odoo",
  428. job_id,
  429. )
  430. # Call original implementation to handle standard status updates (sent, delivered, read, etc.)
  431. return super()._process_statuses(value)
  432. def _update_message_fetched_seen(self):
  433. """
  434. Sobrescritura para manejar concurrencia en la actualización de discuss.channel.member.
  435. Usa bloqueo de fila (NO KEY UPDATE SKIP LOCKED) para evitar SERIALIZATION_FAILURE.
  436. """
  437. self.ensure_one()
  438. if self.mail_message_id.model != "discuss.channel":
  439. return
  440. channel = self.env["discuss.channel"].browse(self.mail_message_id.res_id)
  441. # Buscar el miembro asociado al partner de WhatsApp
  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. member_id = channel_member.id
  449. notification_type = None
  450. updated_rows = 0
  451. if self.state == "read":
  452. # Intentar actualizar usando SKIP LOCKED
  453. # Si el registro está bloqueado, simplemente saltamos esta actualización
  454. # ya que fetched/seen son contadores monotónicos o de estado reciente
  455. self.env.cr.execute(
  456. """
  457. UPDATE discuss_channel_member
  458. SET fetched_message_id = GREATEST(fetched_message_id, %s),
  459. seen_message_id = %s,
  460. last_seen_dt = NOW()
  461. WHERE id IN (
  462. SELECT id FROM discuss_channel_member WHERE id = %s
  463. FOR NO KEY UPDATE SKIP LOCKED
  464. )
  465. RETURNING id
  466. """,
  467. (self.mail_message_id.id, self.mail_message_id.id, member_id),
  468. )
  469. # Si fetchone devuelve algo, significa que actualizó. Si es None, saltó por bloqueo.
  470. if self.env.cr.fetchone():
  471. channel_member.invalidate_recordset(
  472. ["fetched_message_id", "seen_message_id", "last_seen_dt"]
  473. )
  474. notification_type = "discuss.channel.member/seen"
  475. elif self.state == "delivered":
  476. self.env.cr.execute(
  477. """
  478. UPDATE discuss_channel_member
  479. SET fetched_message_id = GREATEST(fetched_message_id, %s)
  480. WHERE id IN (
  481. SELECT id FROM discuss_channel_member WHERE id = %s
  482. FOR NO KEY UPDATE SKIP LOCKED
  483. )
  484. RETURNING id
  485. """,
  486. (self.mail_message_id.id, member_id),
  487. )
  488. if self.env.cr.fetchone():
  489. channel_member.invalidate_recordset(["fetched_message_id"])
  490. notification_type = "discuss.channel.member/fetched"
  491. if notification_type:
  492. channel._bus_send(
  493. notification_type,
  494. {
  495. "channel_id": channel.id,
  496. "id": channel_member.id,
  497. "last_message_id": self.mail_message_id.id,
  498. "partner_id": channel.whatsapp_partner_id.id,
  499. },
  500. )