-
Notifications
You must be signed in to change notification settings - Fork 33
[IMP] website_livechat_external: init module #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 18.0
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| from . import controllers | ||
| from . import models |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import main |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"""<!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"/> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||
| <style> | ||
| html, body {{ | ||
| margin: 0; | ||
| padding: 0; | ||
| background: transparent; | ||
| overflow: hidden; | ||
| }} | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <!-- | ||
| Esta página corre en su propio contexto JS (sin los globals de Odoo 18), | ||
| por lo que los scripts de Odoo 19 no colisionan con los de Odoo 18. | ||
| --> | ||
| <script defer="defer" type="text/javascript" src="{loader_src}"></script> | ||
| <script defer="defer" type="text/javascript" src="{embed_src}"></script> | ||
| <script type="text/javascript"> | ||
|
Comment on lines
+58
to
+82
|
||
| // Escucha mensajes del parent para abrir/cerrar el livechat. | ||
| // El botón vive dentro de un shadow DOM (.o-livechat-root) y no es | ||
| // accesible con querySelector desde el parent frame. | ||
| window.addEventListener("message", function (ev) {{ | ||
| if (!ev.data || ev.data.type !== "toggle_livechat") return; | ||
| var root = document.querySelector(".o-livechat-root"); | ||
| if (!root || !root.shadowRoot) return; | ||
| var btn = root.shadowRoot.querySelector(".o-livechat-LivechatButton"); | ||
| if (btn) btn.click(); | ||
| }}); | ||
|
Comment on lines
+83
to
+92
|
||
| </script> | ||
| </body> | ||
| </html>""" | ||
|
|
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import res_config_settings |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
| }); | ||
|
Comment on lines
+78
to
+85
|
||
|
|
||
| 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); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")); | ||
| } | ||
|
Comment on lines
+11
to
+13
|
||
|
|
||
| onClick() { | ||
| if (this.livechat.openChat) { | ||
| this.livechat.openChat(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const systrayItem = { | ||
| Component: ExternalLivechatSystray, | ||
| }; | ||
|
|
||
| registry | ||
| .category("systray") | ||
| .add("website_livechat_external.SystrayItem", systrayItem, { | ||
| sequence: 100, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <templates xml:space="preserve"> | ||
| <t t-name="website_livechat_external.SystrayItem"> | ||
| <div t-if="livechat.available"> | ||
| <button class="o_nav_entry" title="Soporte" aria-label="Soporte" t-on-click="onClick"> | ||
| <i class="fa fa-lg fa-headphones" role="img"/> | ||
| </button> | ||
| </div> | ||
| </t> | ||
| </templates> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Este módulo introduce rutas nuevas con validaciones (enabled/provider_url/channel_id) y distintos status codes (404/400/200). En el repo ya hay tests Odoo (
base_bg/tests/test_bg_job.py), pero acá no se agregan. Sería bueno sumar al menos tests de controlador para: (1)/framedevuelve 404 si está deshabilitado, (2) 400 si la URL o el channel_id son inválidos, (3) 200 y contiene los<script src=...>esperados cuando la config es válida.