Skip to content

Commit 348813a

Browse files
committed
[IMP] website_livechat_external: init module
1 parent b4eec5b commit 348813a

11 files changed

Lines changed: 549 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import controllers
2+
from . import models
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
##############################################################################
2+
#
3+
# Copyright (C) 2025 ADHOC SA (http://www.adhoc.com.ar)
4+
# All Rights Reserved.
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
##############################################################################
12+
{
13+
"name": "Website LiveChat - Proveedor Externo",
14+
"version": "18.0.1.0.0",
15+
"category": "Website",
16+
"summary": "Integra un livechat de una instancia Odoo externa (ej: Odoo 19) en el website de Odoo 18",
17+
"description": """
18+
Permite incrustar el widget de livechat de una instancia Odoo de versión
19+
diferente (proveedor externo) en el website de esta instancia Odoo 18,
20+
evitando los conflictos de namespace JavaScript entre versiones.
21+
22+
Utiliza un iframe aislado para que los globals de Odoo 19 (owl, odoo, etc.)
23+
no colisionen con los de Odoo 18.
24+
25+
Configuración:
26+
- Website > Configuración > External LiveChat
27+
- Ingresar la URL del proveedor y el ID del canal
28+
""",
29+
"author": "ADHOC SA",
30+
"website": "www.adhoc.com.ar",
31+
"license": "AGPL-3",
32+
"depends": [
33+
"website",
34+
"base_setup",
35+
],
36+
"data": [
37+
"views/res_config_settings_views.xml",
38+
"views/website_templates.xml",
39+
],
40+
"assets": {
41+
"web.assets_backend": [
42+
"website_livechat_external/static/src/js/external_livechat_service.js",
43+
"website_livechat_external/static/src/js/external_livechat_systray.js",
44+
"website_livechat_external/static/src/xml/external_livechat_systray.xml",
45+
],
46+
},
47+
"installable": True,
48+
"auto_install": False,
49+
"application": False,
50+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import re
2+
3+
from odoo import http
4+
from odoo.http import request
5+
6+
# Valida que la URL sea http/https con host válido, sin path (solo origen)
7+
_VALID_ORIGIN_RE = re.compile(r"^https?://[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(:\d+)?$")
8+
9+
10+
class ExternalLivechatController(http.Controller):
11+
@http.route(
12+
"/website_livechat_external/config",
13+
type="json",
14+
auth="user",
15+
)
16+
def livechat_config(self):
17+
"""Devuelve la configuración del livechat externo para el backend."""
18+
ICP = request.env["ir.config_parameter"].sudo()
19+
enabled = ICP.get_param("website_livechat_external.enabled", "False")
20+
return {
21+
"enabled": enabled in ("True", "1", "true"),
22+
}
23+
24+
@http.route(
25+
"/website_livechat_external/frame",
26+
type="http",
27+
auth="public",
28+
website=True,
29+
sitemap=False,
30+
)
31+
def livechat_frame(self):
32+
"""
33+
Devuelve una página HTML mínima y aislada que carga los scripts del
34+
livechat del proveedor externo (Odoo 19). Al servirse desde el mismo
35+
origen de Odoo 18, el iframe que la contiene puede manipular su DOM
36+
para habilitar pointer-events sólo sobre el widget del chat.
37+
"""
38+
ICP = request.env["ir.config_parameter"].sudo()
39+
40+
enabled = ICP.get_param("website_livechat_external.enabled", "False")
41+
if enabled not in ("True", "1", "true"):
42+
return request.not_found()
43+
44+
provider_url = ICP.get_param("website_livechat_external.provider_url", "").rstrip("/")
45+
channel_id_raw = ICP.get_param("website_livechat_external.channel_id", "0")
46+
47+
# Validaciones de seguridad básicas
48+
if not provider_url or not _VALID_ORIGIN_RE.match(provider_url):
49+
return request.make_response("URL del proveedor inválida.", status=400)
50+
51+
try:
52+
channel_id = int(channel_id_raw)
53+
if channel_id <= 0:
54+
raise ValueError
55+
except (ValueError, TypeError):
56+
return request.make_response("ID de canal inválido.", status=400)
57+
58+
loader_src = f"{provider_url}/im_livechat/loader/{channel_id}"
59+
embed_src = f"{provider_url}/im_livechat/assets_embed.js"
60+
61+
html = f"""<!DOCTYPE html>
62+
<html>
63+
<head>
64+
<meta charset="utf-8"/>
65+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
66+
<style>
67+
html, body {{
68+
margin: 0;
69+
padding: 0;
70+
background: transparent;
71+
overflow: hidden;
72+
}}
73+
</style>
74+
</head>
75+
<body>
76+
<!--
77+
Esta página corre en su propio contexto JS (sin los globals de Odoo 18),
78+
por lo que los scripts de Odoo 19 no colisionan con los de Odoo 18.
79+
-->
80+
<script defer="defer" type="text/javascript" src="{loader_src}"></script>
81+
<script defer="defer" type="text/javascript" src="{embed_src}"></script>
82+
<script type="text/javascript">
83+
// Escucha mensajes del parent para abrir/cerrar el livechat.
84+
// El botón vive dentro de un shadow DOM (.o-livechat-root) y no es
85+
// accesible con querySelector desde el parent frame.
86+
window.addEventListener("message", function (ev) {{
87+
if (!ev.data || ev.data.type !== "toggle_livechat") return;
88+
var root = document.querySelector(".o-livechat-root");
89+
if (!root || !root.shadowRoot) return;
90+
var btn = root.shadowRoot.querySelector(".o-livechat-LivechatButton");
91+
if (btn) btn.click();
92+
}});
93+
</script>
94+
</body>
95+
</html>"""
96+
97+
return request.make_response(
98+
html,
99+
headers=[
100+
("Content-Type", "text/html; charset=utf-8"),
101+
# Solo permite embeber este frame desde el mismo origen
102+
("X-Frame-Options", "SAMEORIGIN"),
103+
],
104+
)
105+
106+
@http.route(
107+
"/website_livechat_external/backend_frame",
108+
type="http",
109+
auth="user",
110+
sitemap=False,
111+
)
112+
def livechat_backend_frame(self):
113+
"""
114+
Igual que livechat_frame pero sin website=True, para uso en el
115+
backend web client.
116+
"""
117+
return self.livechat_frame()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import res_config_settings
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from odoo import fields, models
2+
3+
4+
class ResConfigSettings(models.TransientModel):
5+
_inherit = "res.config.settings"
6+
7+
external_livechat_enabled = fields.Boolean(
8+
string="Habilitar LiveChat Externo",
9+
config_parameter="website_livechat_external.enabled",
10+
)
11+
external_livechat_provider_url = fields.Char(
12+
string="URL del Proveedor",
13+
config_parameter="website_livechat_external.provider_url",
14+
help=(
15+
"URL base de la instancia Odoo externa que provee el livechat. "
16+
"Ejemplo: https://train-adhoc-25-03-1.adhoc.inc"
17+
),
18+
)
19+
external_livechat_channel_id = fields.Integer(
20+
string="ID del Canal",
21+
config_parameter="website_livechat_external.channel_id",
22+
help="ID numérico del canal de livechat en la instancia proveedora.",
23+
)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/** @odoo-module */
2+
3+
import { rpc } from "@web/core/network/rpc";
4+
import { registry } from "@web/core/registry";
5+
6+
const externalLivechatService = {
7+
dependencies: [],
8+
9+
async start() {
10+
const state = { available: false };
11+
12+
let config;
13+
try {
14+
config = await rpc("/website_livechat_external/config", {});
15+
} catch {
16+
return state;
17+
}
18+
if (!config || !config.enabled) {
19+
return state;
20+
}
21+
22+
state.available = true;
23+
24+
const frame = document.createElement("iframe");
25+
frame.id = "external_livechat_frame_backend";
26+
frame.src = "/website_livechat_external/backend_frame";
27+
frame.style.cssText =
28+
"position:fixed;top:0;left:0;width:100%;height:100%;" +
29+
"border:none;z-index:2147483646;pointer-events:none;" +
30+
"background:transparent;";
31+
frame.setAttribute("allowtransparency", "true");
32+
frame.setAttribute("allow", "microphone; camera");
33+
frame.setAttribute("title", "LiveChat");
34+
frame.setAttribute("aria-hidden", "true");
35+
document.body.appendChild(frame);
36+
37+
let iframeDoc = null;
38+
39+
/**
40+
* Detecta si las coordenadas del cursor caen sobre un elemento
41+
* del widget de livechat usando elementFromPoint() sobre el
42+
* documento del iframe.
43+
*/
44+
function isOverLivechat(cx, cy) {
45+
if (!iframeDoc) {
46+
return false;
47+
}
48+
try {
49+
const el = iframeDoc.elementFromPoint(cx, cy);
50+
if (
51+
!el ||
52+
el === iframeDoc.documentElement ||
53+
el === iframeDoc.body
54+
) {
55+
return false;
56+
}
57+
let node = el;
58+
while (node && node.nodeType === 1 && node !== iframeDoc.body) {
59+
const cls =
60+
typeof node.className === "string"
61+
? node.className
62+
: "";
63+
const id = node.id || "";
64+
if (
65+
cls.toLowerCase().includes("livechat") ||
66+
id.toLowerCase().includes("livechat")
67+
) {
68+
return true;
69+
}
70+
node = node.parentElement;
71+
}
72+
} catch {
73+
/* cross-origin safety – ignore */
74+
}
75+
return false;
76+
}
77+
78+
document.addEventListener("mousemove", (e) => {
79+
if (!iframeDoc) {
80+
return;
81+
}
82+
frame.style.pointerEvents = isOverLivechat(e.clientX, e.clientY)
83+
? "auto"
84+
: "none";
85+
});
86+
87+
frame.addEventListener("load", () => {
88+
try {
89+
iframeDoc =
90+
frame.contentDocument ||
91+
(frame.contentWindow && frame.contentWindow.document);
92+
if (iframeDoc) {
93+
iframeDoc.addEventListener("mousemove", (e) => {
94+
if (!isOverLivechat(e.clientX, e.clientY)) {
95+
frame.style.pointerEvents = "none";
96+
}
97+
});
98+
}
99+
} catch (e) {
100+
console.warn(
101+
"[ExternalLivechat] No se pudo acceder al DOM del iframe:",
102+
e
103+
);
104+
}
105+
});
106+
107+
/**
108+
* Envía un mensaje al iframe para que abra/cierre el livechat.
109+
* Usa postMessage porque el botón vive dentro de un shadow DOM
110+
* y no es accesible con querySelector desde el parent.
111+
*/
112+
function openChat() {
113+
try {
114+
frame.contentWindow.postMessage(
115+
{ type: "toggle_livechat" },
116+
window.location.origin
117+
);
118+
frame.style.pointerEvents = "auto";
119+
} catch (e) {
120+
console.warn("[ExternalLivechat] No se pudo abrir el chat:", e);
121+
}
122+
}
123+
124+
state.openChat = openChat;
125+
return state;
126+
},
127+
};
128+
129+
registry
130+
.category("services")
131+
.add("external_livechat_backend", externalLivechatService);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/** @odoo-module */
2+
3+
import { Component, useState } from "@odoo/owl";
4+
import { registry } from "@web/core/registry";
5+
import { useService } from "@web/core/utils/hooks";
6+
7+
export class ExternalLivechatSystray extends Component {
8+
static template = "website_livechat_external.SystrayItem";
9+
static props = {};
10+
11+
setup() {
12+
this.livechat = useState(useService("external_livechat_backend"));
13+
}
14+
15+
onClick() {
16+
if (this.livechat.openChat) {
17+
this.livechat.openChat();
18+
}
19+
}
20+
}
21+
22+
export const systrayItem = {
23+
Component: ExternalLivechatSystray,
24+
};
25+
26+
registry
27+
.category("systray")
28+
.add("website_livechat_external.SystrayItem", systrayItem, {
29+
sequence: 100,
30+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<templates xml:space="preserve">
3+
<t t-name="website_livechat_external.SystrayItem">
4+
<div t-if="livechat.available">
5+
<button class="o_nav_entry" title="Soporte" aria-label="Soporte" t-on-click="onClick">
6+
<i class="fa fa-lg fa-headphones" role="img"/>
7+
</button>
8+
</div>
9+
</t>
10+
</templates>

0 commit comments

Comments
 (0)