whatsapp_account.py 21 KB

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