whatsapp_account.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. import logging
  2. import requests
  3. import json
  4. import mimetypes
  5. import base64
  6. import traceback
  7. from markupsafe import Markup
  8. from odoo import fields, models, _
  9. from odoo.addons.whatsapp.tools.whatsapp_api import WhatsAppApi
  10. from odoo.tools import plaintext2html
  11. _logger = logging.getLogger(__name__)
  12. class WhatsAppAccount(models.Model):
  13. _inherit = "whatsapp.account"
  14. whatsapp_web_url = fields.Char(
  15. string="WhatsApp Web URL", readonly=False, copy=False
  16. )
  17. whatsapp_web_login = fields.Char(string="Login", readonly=False, copy=False)
  18. whatsapp_web_api_key = fields.Char(string="API Key", readonly=False, copy=False)
  19. def _send_message(self, message_type, message_values, message_id):
  20. """Trap to debug who is sending messages"""
  21. _logger.warning(
  22. "TRAP: _send_message called! Type: %s. Stack: \n%s",
  23. message_type,
  24. "".join(traceback.format_stack()),
  25. )
  26. return super()._send_message(message_type, message_values, message_id)
  27. def get_groups(self):
  28. """
  29. Obtiene los grupos de WhatsApp Web para la cuenta desde la base de datos de la plataforma.
  30. Returns:
  31. list: Lista de diccionarios con la información de los grupos en formato compatible con Odoo
  32. """
  33. self.ensure_one()
  34. if not self.whatsapp_web_url:
  35. _logger.warning(
  36. "No se ha configurado la URL de WhatsApp Web para la cuenta %s",
  37. self.name,
  38. )
  39. return []
  40. if not self.whatsapp_web_login:
  41. _logger.warning(
  42. "No se ha configurado el Login (session_name) para la cuenta %s",
  43. self.name,
  44. )
  45. return []
  46. if not self.whatsapp_web_api_key:
  47. _logger.warning(
  48. "No se ha configurado la API Key para la cuenta %s", self.name
  49. )
  50. return []
  51. try:
  52. # Construir URL del nuevo endpoint
  53. base_url = self.whatsapp_web_url.rstrip("/")
  54. session_name = self.whatsapp_web_login
  55. url = f"{base_url}/api/v1/{session_name}/groups"
  56. headers = {
  57. "Content-Type": "application/json",
  58. "X-API-Key": self.whatsapp_web_api_key,
  59. }
  60. response = requests.get(url, headers=headers, timeout=30)
  61. if response.status_code == 200:
  62. groups = response.json()
  63. _logger.info(
  64. "Grupos obtenidos desde la base de datos: %d grupos", len(groups)
  65. )
  66. return groups
  67. else:
  68. _logger.error(
  69. "Error al obtener groups: %s - %s",
  70. response.status_code,
  71. response.text,
  72. )
  73. return []
  74. except Exception as e:
  75. _logger.error("Error en la petición de groups: %s", str(e))
  76. return []
  77. def _find_or_create_partner_from_payload(self, contacts_data):
  78. """
  79. Identify or create a partner based on webhook contacts list.
  80. Priority:
  81. 1. Mobile (last 10 digits match) -> Update WA ID if needed.
  82. 2. WA ID (exact match) -> Update Mobile/Name if needed.
  83. 3. Create new partner.
  84. Returns: res.partner record
  85. """
  86. if not contacts_data:
  87. return self.env["res.partner"]
  88. contact_info = contacts_data[0]
  89. wa_id = contact_info.get("wa_id")
  90. mobile = contact_info.get("phone_number") # Normalized phone from webhook
  91. # Try to get profile name, fallback to wa_id
  92. profile_name = contact_info.get("profile", {}).get("name") or wa_id
  93. # Lid handling: If the main ID is an LID (Lyophilized ID used for privacy),
  94. # we still prefer to link it to a real phone number if available.
  95. # The payload might have "wa_id" as the LID and "phone_number" as the real number,
  96. # or vice-versa depending on context. We trust 'phone_number' field for mobile search.
  97. partner = self.env["res.partner"]
  98. # Strategy 1: Search by Mobile (last 10 digits)
  99. if mobile and len(mobile) >= 10:
  100. last_10 = mobile[-10:]
  101. partner = (
  102. self.env["res.partner"]
  103. .sudo()
  104. .search([("mobile", "like", f"%{last_10}")], limit=1)
  105. )
  106. if partner:
  107. _logger.info(f"Partner found by mobile {last_10}: {partner.name}")
  108. # Update WA ID if missing or different (and valid)
  109. if wa_id and partner.whatsapp_web_id != wa_id:
  110. partner.write({"whatsapp_web_id": wa_id})
  111. return partner
  112. # Strategy 2: Search by WhatsApp Web ID
  113. if wa_id:
  114. partner = (
  115. self.env["res.partner"]
  116. .sudo()
  117. .search([("whatsapp_web_id", "=", wa_id)], limit=1)
  118. )
  119. if partner:
  120. _logger.info(f"Partner found by WA ID {wa_id}: {partner.name}")
  121. # Update mobile if missing
  122. if mobile and not partner.mobile:
  123. partner.write({"mobile": mobile})
  124. return partner
  125. # Strategy 3: Create New Partner
  126. vals = {
  127. "name": profile_name,
  128. "whatsapp_web_id": wa_id,
  129. "mobile": mobile,
  130. }
  131. _logger.info(f"Creating new partner from webhook: {vals}")
  132. partner = self.env["res.partner"].sudo().create(vals)
  133. return partner
  134. def _process_messages(self, value):
  135. """
  136. Sobrescritura completa para manejar mensajes enviados desde la misma cuenta (self-messages)
  137. y rutearlos al chat correcto.
  138. Refactorizado para soportar grupos vía metadata y creación Lazy.
  139. """
  140. if "messages" not in value and value.get("whatsapp_business_api_data", {}).get(
  141. "messages"
  142. ):
  143. value = value["whatsapp_business_api_data"]
  144. wa_api = WhatsAppApi(self)
  145. # 1. Identificar Remitente (Sender)
  146. contacts_data = value.get("contacts", [])
  147. sender_partner = self._find_or_create_partner_from_payload(contacts_data)
  148. original_sender_partner = (
  149. sender_partner # Preserve original sender (Me) for attribution
  150. )
  151. # Fallback Name if partner creation failed (rare)
  152. sender_name = sender_partner.name if sender_partner else "Unknown"
  153. # Determinar el ID del teléfono actual (para detectar auto-mensajes)
  154. my_phone_id = value.get("metadata", {}).get("phone_number_id")
  155. for messages in value.get("messages", []):
  156. parent_msg_id = False
  157. parent_id = False
  158. channel = False
  159. sender_mobile = messages["from"]
  160. message_type = messages["type"]
  161. # Lógica para detectar self-messages
  162. is_self_message = False
  163. # Check explicit flag first (if provided by bridge)
  164. if messages.get("fromMe") or messages.get("from_me"):
  165. is_self_message = True
  166. # Check phone ID match as fallback
  167. # Check phone ID match as fallback ( Metadata OR stored phone_uid)
  168. if not is_self_message:
  169. sender_clean = sender_mobile.replace("@c.us", "")
  170. # Compare vs Metadata ID
  171. if my_phone_id:
  172. my_clean = my_phone_id.replace("@c.us", "")
  173. if sender_clean == my_clean:
  174. is_self_message = True
  175. # Compare vs Account Phone UID (if metadata failed/missing)
  176. if not is_self_message and self.phone_uid:
  177. account_clean = self.phone_uid.replace("@c.us", "")
  178. if sender_clean == account_clean:
  179. is_self_message = True
  180. if is_self_message:
  181. sender_name = False
  182. # Intentar obtener el destinatario real
  183. if "to" in messages:
  184. sender_mobile = messages["to"]
  185. elif "id" in messages and "true_" in messages["id"]:
  186. # Fallback: intentar parsear del ID (formato true_NUMBER@c.us_ID o true_NUMBER_ID)
  187. # Relaxed check: Removed mandatory @c.us to support raw numbers
  188. try:
  189. # Extraer parte entre true_ y @ o primer _
  190. parts = messages["id"].split("_")
  191. if len(parts) > 1:
  192. jid = parts[1] # 5215581845273@c.us or 5215581845273
  193. sender_mobile = jid
  194. _logger.info(
  195. "Recuperado destinatario real desde ID: %s",
  196. sender_mobile,
  197. )
  198. except Exception as e:
  199. _logger.warning("Error parseando ID de self-message: %s", e)
  200. _logger.info(
  201. "Detectado self-message. Redirigiendo a chat de: %s (Original From: %s)",
  202. sender_mobile,
  203. messages["from"],
  204. )
  205. # Fix: Update sender_partner to be the RECIPIENT partner for correct channel naming
  206. if len(sender_mobile) >= 10:
  207. sender_partner = (
  208. self.env["res.partner"]
  209. .sudo()
  210. .search(
  211. [("mobile", "like", f"%{sender_mobile[-10:]}")], limit=1
  212. )
  213. )
  214. if sender_partner:
  215. _logger.info(
  216. "Partner destinatario encontrado para self-message: %s",
  217. sender_partner.name,
  218. )
  219. # --- RECONCILIATION LOGIC ---
  220. # Si viene un job_id en metadata, reconciliar el ID antes de chequear duplicados.
  221. # Esto maneja el caso donde el "Echo" del mensaje trae el ID real y confirma el envío del worker.
  222. job_id = value.get("metadata", {}).get("job_id")
  223. if job_id:
  224. pending_msg = (
  225. self.env["whatsapp.message"]
  226. .sudo()
  227. .search([("job_id", "=", job_id)], limit=1)
  228. )
  229. if pending_msg and pending_msg.msg_uid != messages["id"]:
  230. _logger.info(
  231. "Reconciliando Message ID desde payload de mensajes: JobID %s -> Real ID %s",
  232. job_id,
  233. messages["id"],
  234. )
  235. pending_msg.msg_uid = messages["id"]
  236. # Opcional: Asegurar estado si es necesario, aunque si es un echo,
  237. # el estado 'sent' ya debería estar set por el envío inicial.
  238. # ----------------------------
  239. # --- DEDUPLICATION LOGIC ---
  240. # Check if this message was already processed or sent by Odoo
  241. # This prevents the "Echo" effect when Odoo sends a message and Webhook confirms it
  242. existing_wa_msg = (
  243. self.env["whatsapp.message"]
  244. .sudo()
  245. .search([("msg_uid", "=", messages["id"])], limit=1)
  246. )
  247. if existing_wa_msg and message_type != "reaction":
  248. _logger.info(
  249. "Skipping duplicate message %s (already exists)", messages["id"]
  250. )
  251. # Optionally update status here if needed, but avoiding duplicate mail.message is key
  252. continue
  253. # ---------------------------
  254. # Context / Reply Handling
  255. target_record = False
  256. if "context" in messages and messages["context"].get("id"):
  257. parent_whatsapp_message = (
  258. self.env["whatsapp.message"]
  259. .sudo()
  260. .search([("msg_uid", "=", messages["context"]["id"])])
  261. )
  262. if parent_whatsapp_message:
  263. parent_msg_id = parent_whatsapp_message.id
  264. parent_id = parent_whatsapp_message.mail_message_id
  265. if parent_id:
  266. # Check where the parent message belongs
  267. if (
  268. parent_id.model
  269. and parent_id.model != "discuss.channel"
  270. and parent_id.res_id
  271. ):
  272. # It's a reply to a document (Ticket, Order, etc.)
  273. try:
  274. target_record = self.env[parent_id.model].browse(
  275. parent_id.res_id
  276. )
  277. _logger.info(
  278. f"Reply routed to Document: {parent_id.model} #{parent_id.res_id}"
  279. )
  280. except Exception as e:
  281. _logger.warning(
  282. f"Could not load target record {parent_id.model} #{parent_id.res_id}: {e}"
  283. )
  284. target_record = False
  285. else:
  286. # It's a reply in a channel
  287. channel = (
  288. self.env["discuss.channel"]
  289. .sudo()
  290. .search([("message_ids", "in", parent_id.id)], limit=1)
  291. )
  292. # 2. Lógica de Grupos (Metadata - Decoupled & Lazy)
  293. group_metadata = value.get("metadata", {}).get("group")
  294. # Support legacy group_id only if group dict missing
  295. if not group_metadata and value.get("metadata", {}).get("group_id"):
  296. group_metadata = {"id": value.get("metadata", {}).get("group_id")}
  297. if group_metadata and not target_record:
  298. # Check if group module is installed (Use 'in' operator for models with dots)
  299. if "ww.group" in self.env:
  300. # Process Group (Lazy Create + Organic Member Add)
  301. group = self.env["ww.group"].process_webhook_group(
  302. self, group_metadata, sender_partner
  303. )
  304. if group and group.channel_id and not channel:
  305. channel = group.channel_id
  306. _logger.info(
  307. "Mensaje de grupo ruteado a canal: %s", channel.name
  308. )
  309. else:
  310. _logger.warning(
  311. "Recibido mensaje de grupo pero ww.group no está instalado."
  312. )
  313. # 3. Canal Directo (Si no es grupo y no tenemos target ni channel aun)
  314. if not target_record and not channel:
  315. channel = self._find_active_channel(
  316. sender_mobile, sender_name=sender_name, create_if_not_found=True
  317. )
  318. # --- RENAME LOGIC FOR 1:1 CHATS ---
  319. # Solo si estamos usando un canal (no si vamos a un documento)
  320. if channel and channel.channel_type == "whatsapp" and sender_partner:
  321. is_group_channel = False
  322. if channel.whatsapp_number and channel.whatsapp_number.endswith(
  323. "@g.us"
  324. ):
  325. is_group_channel = True
  326. if not is_group_channel and channel.name != sender_partner.name:
  327. _logger.info(
  328. f"Renaming channel {channel.id} from '{channel.name}' to '{sender_partner.name}'"
  329. )
  330. channel.sudo().write({"name": sender_partner.name})
  331. # -----------------------------------
  332. # Define Target Record if not set (fallback to channel)
  333. if not target_record:
  334. target_record = channel
  335. if not target_record:
  336. _logger.error("Could not determine target record for message")
  337. continue
  338. # Determinar autor (Author ID)
  339. # Preferimos usar el partner identificado del payload
  340. # Si es self-message, usar original_sender_partner (Me)
  341. if is_self_message:
  342. author_id = (
  343. original_sender_partner.id
  344. if original_sender_partner
  345. else self.env.ref("base.partner_root").id
  346. )
  347. else:
  348. author_id = sender_partner.id if sender_partner else False
  349. # If no sender partner, try channel partner if target is channel
  350. if (
  351. not author_id
  352. and getattr(target_record, "_name", "") == "discuss.channel"
  353. ):
  354. author_id = target_record.whatsapp_partner_id.id
  355. kwargs = {
  356. "message_type": "whatsapp_message",
  357. "author_id": author_id,
  358. "subtype_xmlid": "mail.mt_comment",
  359. "parent_id": parent_id.id if parent_id else None,
  360. "whatsapp_inbound_msg_uid": messages["id"],
  361. }
  362. if message_type == "text":
  363. kwargs["body"] = plaintext2html(messages["text"]["body"])
  364. elif message_type == "button":
  365. kwargs["body"] = messages["button"]["text"]
  366. elif message_type in ("document", "image", "audio", "video", "sticker"):
  367. filename = messages[message_type].get("filename")
  368. is_voice = messages[message_type].get("voice")
  369. mime_type = messages[message_type].get("mime_type")
  370. caption = messages[message_type].get("caption")
  371. # Hybrid Handling: Check for local base64 content
  372. data_base64 = messages[message_type].get("data_base64")
  373. if data_base64:
  374. _logger.info("Usando contenido base64 local para %s", message_type)
  375. try:
  376. datas = base64.b64decode(data_base64)
  377. except Exception as e:
  378. _logger.error("Error al decodificar data_base64: %s", e)
  379. datas = b""
  380. else:
  381. # Fallback to standard flow (download from Meta)
  382. datas = wa_api._get_whatsapp_document(messages[message_type]["id"])
  383. if not filename:
  384. extension = mimetypes.guess_extension(mime_type) or ""
  385. filename = message_type + extension
  386. kwargs["attachments"] = [(filename, datas)]
  387. if caption:
  388. kwargs["body"] = plaintext2html(caption)
  389. elif message_type == "location":
  390. url = Markup(
  391. "https://maps.google.com/maps?q={latitude},{longitude}"
  392. ).format(
  393. latitude=messages["location"]["latitude"],
  394. longitude=messages["location"]["longitude"],
  395. )
  396. body = Markup(
  397. '<a target="_blank" href="{url}"> <i class="fa fa-map-marker"/> {location_string} </a>'
  398. ).format(url=url, location_string=_("Location"))
  399. if messages["location"].get("name"):
  400. body += Markup("<br/>{location_name}").format(
  401. location_name=messages["location"]["name"]
  402. )
  403. if messages["location"].get("address"):
  404. body += Markup("<br/>{location_address}").format(
  405. location_address=messages["location"]["address"]
  406. )
  407. kwargs["body"] = body
  408. elif message_type == "contacts":
  409. body = ""
  410. for contact in messages["contacts"]:
  411. body += Markup(
  412. "<i class='fa fa-address-book'/> {contact_name} <br/>"
  413. ).format(
  414. contact_name=contact.get("name", {}).get("formatted_name", "")
  415. )
  416. for phone in contact.get("phones", []):
  417. body += Markup("{phone_type}: {phone_number}<br/>").format(
  418. phone_type=phone.get("type"),
  419. phone_number=phone.get("phone"),
  420. )
  421. kwargs["body"] = body
  422. elif message_type == "reaction":
  423. msg_uid = messages["reaction"].get("message_id")
  424. whatsapp_message = (
  425. self.env["whatsapp.message"]
  426. .sudo()
  427. .search([("msg_uid", "=", msg_uid)])
  428. )
  429. if whatsapp_message:
  430. # Use sender_partner for reaction if available
  431. partner_id = sender_partner
  432. # FALLBACK: If no sender_partner found (common in Groups where contacts is empty),
  433. # try to find partner by the 'from' field (mobile number)
  434. if not partner_id and messages.get("from"):
  435. mobile = messages["from"]
  436. if len(mobile) >= 10:
  437. partner_id = (
  438. self.env["res.partner"]
  439. .sudo()
  440. .search(
  441. [("mobile", "like", f"%{mobile[-10:]}")], limit=1
  442. )
  443. )
  444. if (
  445. not partner_id
  446. and getattr(target_record, "_name", "") == "discuss.channel"
  447. ):
  448. partner_id = target_record.whatsapp_partner_id
  449. emoji = messages["reaction"].get("emoji")
  450. whatsapp_message.mail_message_id._post_whatsapp_reaction(
  451. reaction_content=emoji, partner_id=partner_id
  452. )
  453. continue
  454. else:
  455. _logger.warning("Unsupported whatsapp message type: %s", messages)
  456. continue
  457. # Standard channels (like groups) do not support this param and will crash
  458. if getattr(target_record, "_name", "") == "discuss.channel":
  459. if (
  460. hasattr(target_record, "channel_type")
  461. and target_record.channel_type == "whatsapp"
  462. ):
  463. kwargs["whatsapp_inbound_msg_uid"] = messages["id"]
  464. target_record.message_post(**kwargs)