✨ feat(webchat): применять оформление кнопки из конфигурации

This commit is contained in:
Andrey committed 2026-10-01 00:27:29 +03:00
1 parent 223a962cb9
commit b160335bf6
17 files changed
+829 -400

No files matched your search

@@ -0,0 +1,35 @@
"""Публичная конфигурация для iframe и лоадера на сайте организации."""
from django.utils.cache import patch_vary_headers
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.webchat import services
from chatballs.webchat.access import _Public, _resolved_web_widget
from chatballs.webchat.api_inputs import host_origin
from chatballs.webchat.throttling import WebchatConfigThrottle
class WebchatConfigView(_Public):
throttle_classes = [WebchatConfigThrottle]
def get(self, request: Request) -> Response:
widget_key = request.GET.get("widgetKey", "")
channel_code = request.GET.get("channel", "")
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
response = Response({"available": False})
else:
response = Response(
services.public_config(
context=context, widget=widget, origin=host_origin(request),
)
)
# Лоадер работает на другом домене. CORS разрешается только для
# конфигурации доступного этому сайту виджета, без cookies и сессии.
origin = request.headers.get("Origin")
if origin and response.data.get("available"):
response["Access-Control-Allow-Origin"] = origin
patch_vary_headers(response, ["Origin"])
response["Cache-Control"] = "no-cache"
return response
+12 -360
View File
@@ -1,362 +1,14 @@
# Публичный JS-лоадер виджета (SPEC-CHATBALLS-0003 §3). Подключается одним тегом:
# <script src="https://<ваш-домен>/chat-widget.js"
# data-widget-key="wgt_public_key" async></script>
# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/).
#
# TODO (security): для production настроить CSP `frame-ancestors` для /chat/
# (раздаётся vite/nginx, не Django — настраивается в infra/deploy), разрешив
# домены сайтов, где стоит виджет. Домены — у владельца.
"""Публичный автономный JS-лоадер: оформление, панель и анимация джина.
LOADER_JS = r"""
(function () {
var script = document.currentScript;
if (!script) return;
var widgetKey = script.getAttribute("data-widget-key") || "";
var legacyChannel = script.getAttribute("data-channel") || "";
if (!widgetKey && !legacyChannel) return;
var origin = new URL(script.src, location.href).origin;
var instanceId = "chat_" + Math.random().toString(36).slice(2);
var entryQuery = widgetKey
? "widgetKey=" + encodeURIComponent(widgetKey)
: "channel=" + encodeURIComponent(legacyChannel);
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
var contentTimer = 0;
var api = window.ChatballsChat = window.ChatballsChat || {};
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
var style = document.createElement("style");
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
(document.head || document.documentElement).appendChild(style);
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
// остаётся на месте и показывает шеврон — им же панель и закрывают.
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
var btn = document.createElement("button");
btn.className = "chatballs-chat-launcher";
btn.type = "button";
btn.setAttribute("aria-label", "Открыть чат");
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
btn.innerHTML = BOT_ICON;
var dot = document.createElement("span");
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
btn.appendChild(dot);
var notification = new Audio(origin + "/chat/audio/notification.mp3");
var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
notification.preload = "auto";
ringtone.preload = "auto";
ringtone.loop = true;
ringtone.volume = 1;
function play(audio) {
var result = audio.play();
if (result && result.catch) result.catch(function () {});
}
function updateDot() {
dot.style.display = !open && (unread || callActive) ? "block" : "none";
}
function bumpLauncher() {
btn.classList.remove("chatballs-chat-message-bump");
void btn.offsetWidth;
btn.classList.add("chatballs-chat-message-bump");
window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
}
function setCallActive(active) {
callActive = active;
btn.classList.toggle("chatballs-chat-call-shake", active);
if (active) play(ringtone);
else {
ringtone.pause();
ringtone.currentTime = 0;
}
updateDot();
}
function ensureFrame() {
if (frame) return;
shell = document.createElement("div");
shell.style.cssText = "position:fixed;right:24px;bottom:92px;width:" + PANEL_WIDTH.normal + ";height:" + PANEL_HEIGHT.normal + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
frame = document.createElement("iframe");
frame.src = panelUrl;
frame.title = "Чат";
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
shell.appendChild(frame);
layoutShell();
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () {
notifyLayout();
if (open) notifyOpened();
});
document.body.appendChild(shell);
window.addEventListener("message", function (e) {
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
var d = e.data || {};
if (d.instanceId && d.instanceId !== instanceId) return;
if (d.type === "chatballs-chat-close") setOpen(false);
if (d.type === "chatballs-chat-layout-request") notifyLayout();
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
if (d.type === "chatballs-chat-unread") {
unread = Boolean(d.unread);
updateDot();
}
if (d.type === "chatballs-chat-activity" && d.kind === "message") {
unread = !open;
updateDot();
if (!open && !callActive) bumpLauncher();
notification.currentTime = 0;
play(notification);
}
if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
});
}
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
// самим iframe (transition в его стилях).
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
// На телефоне плавающее окно не помещается: панель занимает весь экран,
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
var mobile = window.matchMedia("(max-width: 480px)");
function panelRadius() { return mobile.matches ? 0 : 24; }
function layoutShell() {
if (!shell) return;
var full = mobile.matches;
shell.style.top = full ? "0" : "";
shell.style.left = full ? "0" : "";
shell.style.right = full ? "0" : "24px";
shell.style.bottom = full ? "0" : "92px";
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = full ? "100%" : expanded ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
frame.style.borderRadius = panelRadius() + "px";
showLauncher();
notifyLayout();
}
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
function notifyLayout() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
}
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
function showLauncher() {
var hidden = open && mobile.matches;
btn.style.opacity = hidden ? "0" : "1";
btn.style.pointerEvents = hidden ? "none" : "";
}
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
else if (mobile.addListener) mobile.addListener(layoutShell);
function setExpanded(next) {
expanded = next;
layoutShell();
}
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
// нельзя, внутри переворачивалась бы вёрстка.
var GENIE_OPEN_MS = 300;
var GENIE_CLOSE_MS = 240;
var SVG_NS = "http://www.w3.org/2000/svg";
var genieSvg = null, geniePath = null, genieRaf = 0;
function ensureGenie() {
if (genieSvg) return;
genieSvg = document.createElementNS(SVG_NS, "svg");
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
geniePath = document.createElementNS(SVG_NS, "path");
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
genieSvg.appendChild(geniePath);
document.body.appendChild(genieSvg);
}
function smooth(x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
return x * x * (3 - 2 * x);
}
function mix(a, b, k) { return a + (b - a) * k; }
function shapeAt(p, panel, icon) {
// Низ втягивается раньше верха — из-за этого и появляется горловина.
var pb = smooth(Math.min(1, p * 1.7));
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
var topY = mix(panel.top, icon.top, pt);
var topHalf = mix(panel.half, icon.half, pt);
var topCx = mix(panel.cx, icon.cx, pt);
var topR = Math.min(mix(panel.radius, icon.half, pt), topHalf);
var bottomY = mix(panel.bottom, icon.bottom, pb);
var bottomHalf = mix(panel.half, icon.half, pb);
var bottomCx = mix(panel.cx, icon.cx, pb);
var bottomR = Math.min(mix(panel.radius, icon.half, pb), bottomHalf);
return {
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
k: Math.max(0, (bottomY - topY) * 0.45),
fill: pt
};
}
function pathOf(g, dx, dy) {
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
return "M" + (tl + g.topR) + "," + ty +
"H" + (tr - g.topR) +
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
"H" + (bl + g.bottomR) +
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
}
function paintGenie(p, panel, icon) {
var g = shapeAt(p, panel, icon);
geniePath.setAttribute("d", pathOf(g, 0, 0));
geniePath.setAttribute(
"fill",
"rgb(" + Math.round(mix(255, 22, g.fill)) + "," +
Math.round(mix(255, 119, g.fill)) + "," +
Math.round(mix(255, 255, g.fill)) + ")"
);
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
// координаты контура честно переводятся в его систему простым сдвигом.
var box = shell.getBoundingClientRect();
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
var width = Math.max(1, box.width);
var height = Math.max(1, box.height);
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
Math.max(1, g.bottomY - g.topY);
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.half, g.fill)) + "px";
}
function restPanel() {
shell.style.clipPath = "none";
frame.style.transform = "none";
frame.style.borderRadius = panelRadius() + "px";
}
function playGenie(from, to, duration, panel, icon, done) {
ensureGenie();
genieSvg.style.display = "block";
var started = 0;
window.cancelAnimationFrame(genieRaf);
function step(now) {
if (!started) started = now;
var linear = Math.min(1, (now - started) / duration);
// Мягкий вход и выход: резкий старт читается как рывок.
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
paintGenie(mix(from, to, eased), panel, icon);
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
else done();
}
genieRaf = window.requestAnimationFrame(step);
}
function panelRect() {
var box = shell.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2,
radius: panelRadius()
};
}
function iconRect() {
var box = btn.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2
};
}
function notifyOpened() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
}
function setOpen(next) {
ensureFrame();
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
// запуска анимации, чтобы панель не мелькнула целиком.
ensureGenie();
open = next;
window.clearTimeout(contentTimer);
btn.style.opacity = "0";
btn.style.pointerEvents = "none";
if (open) {
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
// границам панели, а они известны только разложенной.
shell.style.display = "block";
paintGenie(1, panelRect(), iconRect());
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
if (!open) return;
genieSvg.style.display = "none";
restPanel();
showLauncher();
});
} else {
var box = panelRect();
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
if (open) return;
genieSvg.style.display = "none";
shell.style.display = "none";
restPanel();
showLauncher();
});
}
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
btn.appendChild(dot);
if (open) {
unread = false;
updateDot();
if (callActive) play(ringtone);
notifyOpened();
} else updateDot();
}
btn.addEventListener("click", function () { setOpen(!open); });
function mount() {
document.body.appendChild(btn);
ensureFrame();
}
if (document.body) mount();
else window.addEventListener("DOMContentLoaded", mount);
})();
Модули собираются в одну IIFE; дополнительных запросов за JS на сайте нет.
"""
from pathlib import Path
_ASSETS = Path(__file__).with_name("loader_assets")
LOADER_JS = (_ASSETS / "runtime.js").read_text(encoding="utf-8")
for _module in ("appearance", "launcher", "genie"):
LOADER_JS = LOADER_JS.replace(
f"/*__{_module.upper()}__*/",
(_ASSETS / f"{_module}.js").read_text(encoding="utf-8"),
)
@@ -0,0 +1,89 @@
// Публичная конфигурация не должна мешать загрузке страницы хоста.
var appearance = { accent: "#1677ff", rgb: [22, 119, 255], position: "right", size: 56, shape: "circle" };
var launcherIcon = null;
function choice(value, allowed, fallback) {
if (value === undefined) return fallback;
if (allowed.indexOf(value) < 0) throw new Error("Invalid launcher appearance");
return value;
}
function readAppearance(config) {
if (!config || config.available !== true) return null;
var raw = config.appearance === undefined ? {} : config.appearance;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid appearance");
var accent = raw.accent || config.accent || "#1677ff";
if (typeof accent !== "string" || !/^#[0-9a-f]{6}$/i.test(accent)) throw new Error("Invalid accent");
var icon = raw.launcherIcon || "";
if (typeof icon !== "string") throw new Error("Invalid launcher icon");
if (icon) {
if (icon.length > 2048 || /[\x00-\x20\x7f\\]/.test(icon) ||
!(/^(https?:\/\/|\/(?!\/))/i.test(icon))) throw new Error("Invalid launcher icon URL");
icon = new URL(icon, origin).href;
}
return {
accent: accent,
rgb: [1, 3, 5].map(function (offset) { return parseInt(accent.slice(offset, offset + 2), 16); }),
position: choice(raw.launcherPosition, ["left", "right"], "right"),
size: choice(raw.launcherSize, [48, 56, 64], 56),
shape: choice(raw.launcherShape, ["circle", "rounded", "square"], "circle"),
icon: icon
};
}
function launcherRadius() {
return appearance.shape === "square" ? 10 : appearance.size * (appearance.shape === "rounded" ? 0.3 : 0.5);
}
function renderLauncher() {
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
if (!open && launcherIcon) {
btn.textContent = "";
btn.appendChild(launcherIcon);
}
btn.appendChild(dot);
}
function applyAppearance(next) {
if (animating) {
pendingAppearance = next;
return;
}
appearance = next;
btn.style.setProperty("--chatballs-accent", next.accent);
btn.style.setProperty("--chatballs-accent-rgb", next.rgb.join(","));
btn.style.left = next.position === "left" ? "24px" : "";
btn.style.right = next.position === "right" ? "24px" : "";
btn.style.width = btn.style.height = next.size + "px";
btn.style.borderRadius = next.shape === "square" ? "10px" : next.shape === "rounded" ? "30%" : "50%";
layoutShell();
if (!next.icon) return;
// Загружаем как изображение, не вставляем SVG/URL в HTML страницы хоста.
var image = document.createElement("img");
image.alt = "";
image.style.cssText = "width:30px;height:30px;object-fit:contain;flex:none;";
image.onload = function () {
launcherIcon = image;
renderLauncher();
};
image.onerror = function () {
launcherIcon = null;
renderLauncher();
};
image.src = next.icon;
}
function loadAppearance() {
if (!window.fetch) return;
var configUrl = origin + "/api/v1/webchat/config/?" + entryQuery + "&hostOrigin=" + encodeURIComponent(location.origin);
window.fetch(configUrl, { cache: "no-cache", credentials: "omit" })
.then(function (response) {
if (!response.ok) throw new Error("Widget configuration unavailable");
return response.json();
})
.then(function (config) {
var next = readAppearance(config);
if (next) applyAppearance(next);
})
.catch(function () { /* Стандартная кнопка уже смонтирована. */ });
}
@@ -0,0 +1,139 @@
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
// нельзя, внутри переворачивалась бы вёрстка.
var GENIE_OPEN_MS = 300;
var GENIE_CLOSE_MS = 240;
var SVG_NS = "http://www.w3.org/2000/svg";
var genieSvg = null, geniePath = null, genieRaf = 0;
function ensureGenie() {
if (genieSvg) return;
genieSvg = document.createElementNS(SVG_NS, "svg");
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
geniePath = document.createElementNS(SVG_NS, "path");
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
genieSvg.appendChild(geniePath);
document.body.appendChild(genieSvg);
}
function smooth(x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
return x * x * (3 - 2 * x);
}
function mix(a, b, k) { return a + (b - a) * k; }
function shapeAt(p, panel, icon) {
// Низ втягивается раньше верха — из-за этого и появляется горловина.
var pb = smooth(Math.min(1, p * 1.7));
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
var topY = mix(panel.top, icon.top, pt);
var topHalf = mix(panel.half, icon.half, pt);
var topCx = mix(panel.cx, icon.cx, pt);
var topR = Math.min(mix(panel.radius, icon.radius, pt), topHalf);
var bottomY = mix(panel.bottom, icon.bottom, pb);
var bottomHalf = mix(panel.half, icon.half, pb);
var bottomCx = mix(panel.cx, icon.cx, pb);
var bottomR = Math.min(mix(panel.radius, icon.radius, pb), bottomHalf);
return {
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
k: Math.max(0, (bottomY - topY) * 0.45),
fill: pt
};
}
function pathOf(g, dx, dy) {
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
return "M" + (tl + g.topR) + "," + ty +
"H" + (tr - g.topR) +
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
"H" + (bl + g.bottomR) +
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
}
function paintGenie(p, panel, icon) {
var g = shapeAt(p, panel, icon);
geniePath.setAttribute("d", pathOf(g, 0, 0));
geniePath.setAttribute(
"fill",
"rgb(" + Math.round(mix(255, appearance.rgb[0], g.fill)) + "," +
Math.round(mix(255, appearance.rgb[1], g.fill)) + "," +
Math.round(mix(255, appearance.rgb[2], g.fill)) + ")"
);
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
// координаты контура честно переводятся в его систему простым сдвигом.
var box = shell.getBoundingClientRect();
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
var width = Math.max(1, box.width);
var height = Math.max(1, box.height);
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
Math.max(1, g.bottomY - g.topY);
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.radius, g.fill)) + "px";
}
function restPanel() {
shell.style.clipPath = "none";
frame.style.transform = "none";
frame.style.borderRadius = panelRadius() + "px";
}
function playGenie(from, to, duration, panel, icon, done) {
ensureGenie();
genieSvg.style.display = "block";
var started = 0;
window.cancelAnimationFrame(genieRaf);
function step(now) {
if (!started) started = now;
var linear = Math.min(1, (now - started) / duration);
// Мягкий вход и выход: резкий старт читается как рывок.
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
paintGenie(mix(from, to, eased), panel, icon);
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
else done();
}
genieRaf = window.requestAnimationFrame(step);
}
function panelRect() {
var box = shell.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2,
radius: panelRadius()
};
}
function iconRect() {
var box = btn.getBoundingClientRect();
return {
top: box.top,
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2,
radius: launcherRadius()
};
}
@@ -0,0 +1,54 @@
var style = document.createElement("style");
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher{box-shadow:0 8px 24px rgba(var(--chatballs-accent-rgb),0.35)}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(var(--chatballs-accent-rgb),0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(var(--chatballs-accent-rgb),0.45);outline-offset:2px}";
(document.head || document.documentElement).appendChild(style);
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
// остаётся на месте и показывает шеврон — им же панель и закрывают.
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
var btn = document.createElement("button");
btn.className = "chatballs-chat-launcher";
btn.type = "button";
btn.setAttribute("aria-label", "Открыть чат");
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;--chatballs-accent-rgb:22,119,255;background:var(--chatballs-accent);--chatballs-accent:#1677ff;cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
btn.innerHTML = BOT_ICON;
var dot = document.createElement("span");
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
btn.appendChild(dot);
var notification = new Audio(origin + "/chat/audio/notification.mp3");
var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
notification.preload = "auto";
ringtone.preload = "auto";
ringtone.loop = true;
ringtone.volume = 1;
function play(audio) {
var result = audio.play();
if (result && result.catch) result.catch(function () {});
}
function updateDot() {
dot.style.display = !open && (unread || callActive) ? "block" : "none";
}
function bumpLauncher() {
btn.classList.remove("chatballs-chat-message-bump");
void btn.offsetWidth;
btn.classList.add("chatballs-chat-message-bump");
window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
}
function setCallActive(active) {
callActive = active;
btn.classList.toggle("chatballs-chat-call-shake", active);
if (active) play(ringtone);
else {
ringtone.pause();
ringtone.currentTime = 0;
}
updateDot();
}
@@ -0,0 +1,173 @@
(function () {
var script = document.currentScript;
if (!script) return;
var widgetKey = script.getAttribute("data-widget-key") || "";
var legacyChannel = script.getAttribute("data-channel") || "";
if (!widgetKey && !legacyChannel) return;
var origin = new URL(script.src, location.href).origin;
var instanceId = "chat_" + Math.random().toString(36).slice(2);
var entryQuery = widgetKey
? "widgetKey=" + encodeURIComponent(widgetKey)
: "channel=" + encodeURIComponent(legacyChannel);
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
var animating = false, pendingAppearance = null;
var api = window.ChatballsChat = window.ChatballsChat || {};
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
/*__APPEARANCE__*/
/*__LAUNCHER__*/
function ensureFrame() {
if (frame) return;
shell = document.createElement("div");
shell.style.cssText = "position:fixed;width:" + PANEL_WIDTH.normal + ";height:" + panelHeight() + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
frame = document.createElement("iframe");
frame.src = panelUrl;
frame.title = "Чат";
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
shell.appendChild(frame);
layoutShell();
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () {
notifyLayout();
if (open) notifyOpened();
});
document.body.appendChild(shell);
window.addEventListener("message", function (e) {
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
var d = e.data || {};
if (d.instanceId && d.instanceId !== instanceId) return;
if (d.type === "chatballs-chat-close") setOpen(false);
if (d.type === "chatballs-chat-layout-request") notifyLayout();
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
if (d.type === "chatballs-chat-unread") {
unread = Boolean(d.unread);
updateDot();
}
if (d.type === "chatballs-chat-activity" && d.kind === "message") {
unread = !open;
updateDot();
if (!open && !callActive) bumpLauncher();
notification.currentTime = 0;
play(notification);
}
if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
});
}
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
// самим iframe (transition в его стилях).
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
function panelHeight() {
return "min(" + (expanded ? 820 : 720) + "px,calc(100vh - " + (appearance.size + 60) + "px))";
}
// На телефоне плавающее окно не помещается: панель занимает весь экран,
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
var mobile = window.matchMedia("(max-width: 480px)");
function panelRadius() { return mobile.matches ? 0 : 24; }
function layoutShell() {
if (!shell) return;
var full = mobile.matches;
shell.style.top = full ? "0" : "";
shell.style.left = full ? "0" : appearance.position === "left" ? "24px" : "";
shell.style.right = full ? "0" : appearance.position === "right" ? "24px" : "";
shell.style.bottom = full ? "0" : (appearance.size + 36) + "px";
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = full ? "100%" : panelHeight();
frame.style.borderRadius = panelRadius() + "px";
showLauncher();
notifyLayout();
}
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
function notifyLayout() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
}
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
function showLauncher() {
var hidden = open && mobile.matches;
btn.style.opacity = hidden ? "0" : "1";
btn.style.pointerEvents = hidden ? "none" : "";
}
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
else if (mobile.addListener) mobile.addListener(layoutShell);
function setExpanded(next) {
expanded = next;
layoutShell();
}
/*__GENIE__*/
function notifyOpened() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
}
function finishAnimation() {
animating = false;
genieSvg.style.display = "none";
restPanel();
showLauncher();
if (pendingAppearance) {
var next = pendingAppearance;
pendingAppearance = null;
applyAppearance(next);
}
}
function setOpen(next) {
ensureFrame();
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
// запуска анимации, чтобы панель не мелькнула целиком.
ensureGenie();
open = next;
animating = true;
btn.style.opacity = "0";
btn.style.pointerEvents = "none";
if (open) {
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
// границам панели, а они известны только разложенной.
shell.style.display = "block";
paintGenie(1, panelRect(), iconRect());
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
if (!open) return;
finishAnimation();
});
} else {
var box = panelRect();
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
if (open) return;
shell.style.display = "none";
finishAnimation();
});
}
renderLauncher();
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
if (open) {
unread = false;
updateDot();
if (callActive) play(ringtone);
notifyOpened();
} else updateDot();
}
btn.addEventListener("click", function () { setOpen(!open); });
function mount() {
document.body.appendChild(btn);
ensureFrame();
loadAppearance();
}
if (document.body) mount();
else window.addEventListener("DOMContentLoaded", mount);
})();
@@ -0,0 +1,16 @@
"""Раздача автономного лоадера с кэшем не дольше пяти минут."""
from django.http import HttpResponse
from django.views import View
from chatballs.webchat.loader import LOADER_JS
LOADER_MAX_AGE_SECONDS = 300
class WidgetLoaderView(View):
def get(self, request) -> HttpResponse:
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
return response
@@ -195,3 +195,34 @@ class WebAppearanceApiTests(TestCase):
def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
response = self.client.get("/chat-widget.js")
self.assertEqual(response["Cache-Control"], "public, max-age=300")
def test_allowed_site_can_read_public_config_without_credentials(self) -> None:
response = self.client.get(
"/api/v1/webchat/config/",
{"widgetKey": self.integration.web_chat_widget.public_key},
HTTP_ORIGIN="https://example.com",
)
self.assertTrue(response.json()["available"])
self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
self.assertFalse(response.has_header("Access-Control-Allow-Credentials"))
self.assertIn("Origin", response["Vary"])
def test_other_site_cannot_override_origin_or_read_config(self) -> None:
response = self.client.get(
"/api/v1/webchat/config/",
{
"widgetKey": self.integration.web_chat_widget.public_key,
"hostOrigin": "https://example.com",
},
HTTP_ORIGIN="https://other.example",
)
self.assertFalse(response.json()["available"])
self.assertFalse(response.has_header("Access-Control-Allow-Origin"))
def test_loader_host_origin_and_legacy_channel_are_supported(self) -> None:
response = self.client.get(
"/api/v1/webchat/config/",
{"channel": self.channel.code, "hostOrigin": "https://example.com"},
)
self.assertTrue(response.json()["available"])
self.assertEqual(response.json()["widgetKey"], self.integration.web_chat_widget.public_key)
+2 -1
View File
@@ -2,10 +2,11 @@ from django.urls import path
from chatballs.webchat import views
from chatballs.webchat.asset_views import WidgetAssetView
from chatballs.webchat.config_views import WebchatConfigView
urlpatterns = [
path("assets/<uuid:public_id>/", WidgetAssetView.as_view(), name="webchat-asset"),
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
path("config/", WebchatConfigView.as_view(), name="webchat-config"),
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
path("fields/", views.WebchatFieldsView.as_view(), name="webchat-fields"),
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
+1 -37
View File
@@ -1,5 +1,4 @@
from django.http import FileResponse, HttpResponse
from django.views import View
from django.http import FileResponse
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
@@ -20,37 +19,10 @@ from chatballs.webchat.access import (
_resolved_web_widget,
)
from chatballs.webchat.api_inputs import host_origin
from chatballs.webchat.loader import LOADER_JS
from chatballs.webchat.throttling import (
WebchatConfigThrottle,
WebchatSessionIssueThrottle,
)
LOADER_MAX_AGE_SECONDS = 300
class WebchatConfigView(_Public):
throttle_classes = [WebchatConfigThrottle]
def get(self, request: Request) -> Response:
widget_key = request.GET.get("widgetKey", "")
channel_code = request.GET.get("channel", "")
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
response = Response({"available": False})
else:
response = Response(
services.public_config(
context=context,
widget=widget,
origin=host_origin(request),
)
)
# Оформление меняют в админке, и сайт должен увидеть его на следующей
# загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
response["Cache-Control"] = "no-cache"
return response
class WebchatSessionView(_Public):
throttle_classes = [WebchatSessionIssueThrottle]
@@ -246,11 +218,3 @@ class WebchatCallDeclineView(_PublicSession):
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
return Response({"ok": True})
class WidgetLoaderView(View):
def get(self, request) -> HttpResponse:
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
return response
+1 -1
View File
@@ -2,7 +2,7 @@ from django.urls import include, path
from rest_framework.schemas import get_schema_view
from chatballs.identity.demo_views import DemoMediaView
from chatballs.webchat.views import WidgetLoaderView
from chatballs.webchat.loader_views import WidgetLoaderView
urlpatterns = [
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
+10 -1
View File
@@ -30,6 +30,15 @@
Встраивание: <code>&lt;script src="/chat-widget.js" data-channel="demo" async&gt;&lt;/script&gt;</code>
</div>
<script src="/chat-widget.js" data-channel="demo" async></script>
<script>
// Можно проверить существующее подключение, не меняя встроенный код.
var params = new URLSearchParams(location.search);
var widgetKey = params.get("widgetKey");
var loader = document.createElement("script");
loader.src = new URL("/chat-widget.js", params.get("widgetOrigin") || location.origin).href;
loader.async = true;
loader.setAttribute(widgetKey ? "data-widget-key" : "data-channel", widgetKey || "demo");
document.body.appendChild(loader);
</script>
</body>
</html>