diff --git a/website_livechat_external/__init__.py b/website_livechat_external/__init__.py new file mode 100644 index 00000000..19240f4e --- /dev/null +++ b/website_livechat_external/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models \ No newline at end of file diff --git a/website_livechat_external/__manifest__.py b/website_livechat_external/__manifest__.py new file mode 100644 index 00000000..877eb317 --- /dev/null +++ b/website_livechat_external/__manifest__.py @@ -0,0 +1,50 @@ +############################################################################## +# +# Copyright (C) 2025 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +############################################################################## +{ + "name": "Website LiveChat - Proveedor Externo", + "version": "18.0.1.0.0", + "category": "Website", + "summary": "Integra un livechat de una instancia Odoo externa (ej: Odoo 19) en el website de Odoo 18", + "description": """ + Permite incrustar el widget de livechat de una instancia Odoo de versión + diferente (proveedor externo) en el website de esta instancia Odoo 18, + evitando los conflictos de namespace JavaScript entre versiones. + + Utiliza un iframe aislado para que los globals de Odoo 19 (owl, odoo, etc.) + no colisionen con los de Odoo 18. + + Configuración: + - Website > Configuración > External LiveChat + - Ingresar la URL del proveedor y el ID del canal + """, + "author": "ADHOC SA", + "website": "www.adhoc.com.ar", + "license": "AGPL-3", + "depends": [ + "website", + "base_setup", + ], + "data": [ + "views/res_config_settings_views.xml", + "views/website_templates.xml", + ], + "assets": { + "web.assets_backend": [ + "website_livechat_external/static/src/js/external_livechat_service.js", + "website_livechat_external/static/src/js/external_livechat_systray.js", + "website_livechat_external/static/src/xml/external_livechat_systray.xml", + ], + }, + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/website_livechat_external/controllers/__init__.py b/website_livechat_external/controllers/__init__.py new file mode 100644 index 00000000..deec4a8b --- /dev/null +++ b/website_livechat_external/controllers/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/website_livechat_external/controllers/main.py b/website_livechat_external/controllers/main.py new file mode 100644 index 00000000..0c0c8c3d --- /dev/null +++ b/website_livechat_external/controllers/main.py @@ -0,0 +1,117 @@ +import re + +from odoo import http +from odoo.http import request + +# Valida que la URL sea http/https con host válido, sin path (solo origen) +_VALID_ORIGIN_RE = re.compile(r"^https?://[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(:\d+)?$") + + +class ExternalLivechatController(http.Controller): + @http.route( + "/website_livechat_external/config", + type="json", + auth="user", + ) + def livechat_config(self): + """Devuelve la configuración del livechat externo para el backend.""" + ICP = request.env["ir.config_parameter"].sudo() + enabled = ICP.get_param("website_livechat_external.enabled", "False") + return { + "enabled": enabled in ("True", "1", "true"), + } + + @http.route( + "/website_livechat_external/frame", + type="http", + auth="public", + website=True, + sitemap=False, + ) + def livechat_frame(self): + """ + Devuelve una página HTML mínima y aislada que carga los scripts del + livechat del proveedor externo (Odoo 19). Al servirse desde el mismo + origen de Odoo 18, el iframe que la contiene puede manipular su DOM + para habilitar pointer-events sólo sobre el widget del chat. + """ + ICP = request.env["ir.config_parameter"].sudo() + + enabled = ICP.get_param("website_livechat_external.enabled", "False") + if enabled not in ("True", "1", "true"): + return request.not_found() + + provider_url = ICP.get_param("website_livechat_external.provider_url", "").rstrip("/") + channel_id_raw = ICP.get_param("website_livechat_external.channel_id", "0") + + # Validaciones de seguridad básicas + if not provider_url or not _VALID_ORIGIN_RE.match(provider_url): + return request.make_response("URL del proveedor inválida.", status=400) + + try: + channel_id = int(channel_id_raw) + if channel_id <= 0: + raise ValueError + except (ValueError, TypeError): + return request.make_response("ID de canal inválido.", status=400) + + loader_src = f"{provider_url}/im_livechat/loader/{channel_id}" + embed_src = f"{provider_url}/im_livechat/assets_embed.js" + + html = f""" + +
+ + + + + + + + + + +""" + + return request.make_response( + html, + headers=[ + ("Content-Type", "text/html; charset=utf-8"), + # Solo permite embeber este frame desde el mismo origen + ("X-Frame-Options", "SAMEORIGIN"), + ], + ) + + @http.route( + "/website_livechat_external/backend_frame", + type="http", + auth="user", + sitemap=False, + ) + def livechat_backend_frame(self): + """ + Igual que livechat_frame pero sin website=True, para uso en el + backend web client. + """ + return self.livechat_frame() diff --git a/website_livechat_external/models/__init__.py b/website_livechat_external/models/__init__.py new file mode 100644 index 00000000..aba22284 --- /dev/null +++ b/website_livechat_external/models/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings \ No newline at end of file diff --git a/website_livechat_external/models/res_config_settings.py b/website_livechat_external/models/res_config_settings.py new file mode 100644 index 00000000..8521672b --- /dev/null +++ b/website_livechat_external/models/res_config_settings.py @@ -0,0 +1,23 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + external_livechat_enabled = fields.Boolean( + string="Habilitar LiveChat Externo", + config_parameter="website_livechat_external.enabled", + ) + external_livechat_provider_url = fields.Char( + string="URL del Proveedor", + config_parameter="website_livechat_external.provider_url", + help=( + "URL base de la instancia Odoo externa que provee el livechat. " + "Ejemplo: https://train-adhoc-25-03-1.adhoc.inc" + ), + ) + external_livechat_channel_id = fields.Integer( + string="ID del Canal", + config_parameter="website_livechat_external.channel_id", + help="ID numérico del canal de livechat en la instancia proveedora.", + ) \ No newline at end of file diff --git a/website_livechat_external/static/src/js/external_livechat_service.js b/website_livechat_external/static/src/js/external_livechat_service.js new file mode 100644 index 00000000..99c21a39 --- /dev/null +++ b/website_livechat_external/static/src/js/external_livechat_service.js @@ -0,0 +1,131 @@ +/** @odoo-module */ + +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; + +const externalLivechatService = { + dependencies: [], + + async start() { + const state = { available: false }; + + let config; + try { + config = await rpc("/website_livechat_external/config", {}); + } catch { + return state; + } + if (!config || !config.enabled) { + return state; + } + + state.available = true; + + const frame = document.createElement("iframe"); + frame.id = "external_livechat_frame_backend"; + frame.src = "/website_livechat_external/backend_frame"; + frame.style.cssText = + "position:fixed;top:0;left:0;width:100%;height:100%;" + + "border:none;z-index:2147483646;pointer-events:none;" + + "background:transparent;"; + frame.setAttribute("allowtransparency", "true"); + frame.setAttribute("allow", "microphone; camera"); + frame.setAttribute("title", "LiveChat"); + frame.setAttribute("aria-hidden", "true"); + document.body.appendChild(frame); + + let iframeDoc = null; + + /** + * Detecta si las coordenadas del cursor caen sobre un elemento + * del widget de livechat usando elementFromPoint() sobre el + * documento del iframe. + */ + function isOverLivechat(cx, cy) { + if (!iframeDoc) { + return false; + } + try { + const el = iframeDoc.elementFromPoint(cx, cy); + if ( + !el || + el === iframeDoc.documentElement || + el === iframeDoc.body + ) { + return false; + } + let node = el; + while (node && node.nodeType === 1 && node !== iframeDoc.body) { + const cls = + typeof node.className === "string" + ? node.className + : ""; + const id = node.id || ""; + if ( + cls.toLowerCase().includes("livechat") || + id.toLowerCase().includes("livechat") + ) { + return true; + } + node = node.parentElement; + } + } catch { + /* cross-origin safety – ignore */ + } + return false; + } + + document.addEventListener("mousemove", (e) => { + if (!iframeDoc) { + return; + } + frame.style.pointerEvents = isOverLivechat(e.clientX, e.clientY) + ? "auto" + : "none"; + }); + + frame.addEventListener("load", () => { + try { + iframeDoc = + frame.contentDocument || + (frame.contentWindow && frame.contentWindow.document); + if (iframeDoc) { + iframeDoc.addEventListener("mousemove", (e) => { + if (!isOverLivechat(e.clientX, e.clientY)) { + frame.style.pointerEvents = "none"; + } + }); + } + } catch (e) { + console.warn( + "[ExternalLivechat] No se pudo acceder al DOM del iframe:", + e + ); + } + }); + + /** + * Envía un mensaje al iframe para que abra/cierre el livechat. + * Usa postMessage porque el botón vive dentro de un shadow DOM + * y no es accesible con querySelector desde el parent. + */ + function openChat() { + try { + frame.contentWindow.postMessage( + { type: "toggle_livechat" }, + window.location.origin + ); + frame.style.pointerEvents = "auto"; + } catch (e) { + console.warn("[ExternalLivechat] No se pudo abrir el chat:", e); + } + } + + state.openChat = openChat; + return state; + }, +}; + +registry + .category("services") + .add("external_livechat_backend", externalLivechatService); diff --git a/website_livechat_external/static/src/js/external_livechat_systray.js b/website_livechat_external/static/src/js/external_livechat_systray.js new file mode 100644 index 00000000..4b5a03cb --- /dev/null +++ b/website_livechat_external/static/src/js/external_livechat_systray.js @@ -0,0 +1,30 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export class ExternalLivechatSystray extends Component { + static template = "website_livechat_external.SystrayItem"; + static props = {}; + + setup() { + this.livechat = useState(useService("external_livechat_backend")); + } + + onClick() { + if (this.livechat.openChat) { + this.livechat.openChat(); + } + } +} + +export const systrayItem = { + Component: ExternalLivechatSystray, +}; + +registry + .category("systray") + .add("website_livechat_external.SystrayItem", systrayItem, { + sequence: 100, + }); diff --git a/website_livechat_external/static/src/xml/external_livechat_systray.xml b/website_livechat_external/static/src/xml/external_livechat_systray.xml new file mode 100644 index 00000000..b9c8fb66 --- /dev/null +++ b/website_livechat_external/static/src/xml/external_livechat_systray.xml @@ -0,0 +1,10 @@ + +