whatsapp_message.py 24 KB

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