Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions website_livechat_external/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
50 changes: 50 additions & 0 deletions website_livechat_external/__manifest__.py
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,
}
1 change: 1 addition & 0 deletions website_livechat_external/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
117 changes: 117 additions & 0 deletions website_livechat_external/controllers/main.py
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)
Comment on lines +24 to +56
Copy link

Copilot AI Apr 8, 2026

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) /frame devuelve 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.

Copilot generated this review using guidance from repository custom instructions.

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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/frame sirve un HTML de mismo origen que ejecuta JS remoto ({provider_url}/im_livechat/...). Aunque el script venga de otro dominio, se ejecuta con el origen de esta instancia y por lo tanto tiene acceso total a cookies/sesión y endpoints (especialmente grave en /backend_frame con sesión del usuario). Si el proveedor no es 100% confiable, esto es una escalada de confianza importante. Alternativas: aislar en otro origen/subdominio (y comunicarte por postMessage), o al menos restringir/whitelistear dominios permitidos (y documentar explícitamente el riesgo) y evitar habilitarlo en backend si no es necesario.

Copilot uses AI. Check for mistakes.
// 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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

En el listener window.addEventListener("message", ...) del HTML embebido no se valida ev.origin ni ev.source. Aunque X-Frame-Options: SAMEORIGIN reduce el riesgo de embedding cross-origin, sigue siendo recomendable validar que el mensaje proviene del window.parent y del origen esperado antes de ejecutar btn.click() (defensa en profundidad).

Copilot uses AI. Check for mistakes.
</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()
1 change: 1 addition & 0 deletions website_livechat_external/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import res_config_settings
23 changes: 23 additions & 0 deletions website_livechat_external/models/res_config_settings.py
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.",
)
131 changes: 131 additions & 0 deletions website_livechat_external/static/src/js/external_livechat_service.js
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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idem al website: document.addEventListener("mousemove", ...) + elementFromPoint() en cada evento puede impactar performance del backend (web client) y correr todo el tiempo aunque el chat no se use. Sugiero throttlear el handler (p. ej. requestAnimationFrame) y, si es posible, activarlo solo cuando el iframe ya cargó y/o cuando el chat esté disponible/abierto.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

En el resto del repo los componentes suelen consumir servicios con useService(...) directamente (p. ej. mail_ux/static/src/core/common/composer.js:13-14) sin envolverlos en useState. Aquí useState(useService(...)) puede ser innecesario y dificulta entender si la reactividad depende del servicio o del wrapper. Considerá usar this.livechat = useService("external_livechat_backend") y, si hace falta reactividad, exponer un estado reactivo desde el propio servicio.

Copilot uses AI. Check for mistakes.

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>
Loading
Loading