From b160335bf66618919f5657c00cdc5ab60734cd3a Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 1 Oct 2026 00:27:29 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(webchat):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BE=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...021-loader-primenyaet-oformlenie-knopki.md | 31 ++ .../backend/chatballs/webchat/config_views.py | 35 ++ apps/backend/chatballs/webchat/loader.py | 372 +----------------- .../webchat/loader_assets/appearance.js | 89 +++++ .../chatballs/webchat/loader_assets/genie.js | 139 +++++++ .../webchat/loader_assets/launcher.js | 54 +++ .../webchat/loader_assets/runtime.js | 173 ++++++++ .../backend/chatballs/webchat/loader_views.py | 16 + .../chatballs/webchat/test_appearance.py | 31 ++ apps/backend/chatballs/webchat/urls.py | 3 +- apps/backend/chatballs/webchat/views.py | 38 +- apps/backend/chatballs_backend/urls_app.py | 2 +- apps/web-chat/public/demo.html | 11 +- tests/webchat/README.md | 34 ++ tests/webchat/check-fallback.js | 29 ++ tests/webchat/check-launcher.js | 82 ++++ tests/webchat/loader.test.cjs | 90 +++++ 17 files changed, 829 insertions(+), 400 deletions(-) create mode 100644 .skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md create mode 100644 apps/backend/chatballs/webchat/config_views.py create mode 100644 apps/backend/chatballs/webchat/loader_assets/appearance.js create mode 100644 apps/backend/chatballs/webchat/loader_assets/genie.js create mode 100644 apps/backend/chatballs/webchat/loader_assets/launcher.js create mode 100644 apps/backend/chatballs/webchat/loader_assets/runtime.js create mode 100644 apps/backend/chatballs/webchat/loader_views.py create mode 100644 tests/webchat/README.md create mode 100644 tests/webchat/check-fallback.js create mode 100644 tests/webchat/check-launcher.js create mode 100644 tests/webchat/loader.test.cjs diff --git a/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md b/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md new file mode 100644 index 0000000..ef7befe --- /dev/null +++ b/.skaro/tasks/T-021-loader-primenyaet-oformlenie-knopki.md @@ -0,0 +1,31 @@ +--- +id: T-021 +title: Лоадер применяет оформление кнопки +milestone: M04 +status: done +depends_on: + - T-020 +order: 3 +spec: "0021" +created: 2026-09-29 +branch: skaro/T-021-loader-primenyaet-oformlenie-k +--- + +## Цель + +Кнопка на сайте получает цвет, иконку, положение, размер и форму из конфигурации (R-5). + +## Критерии приёмки + +- [x] Лоадер запрашивает публичную конфигурацию и применяет accent (включая тень и фокус), иконку, left/right, 48/56/64, circle/rounded/square +- [x] Панель открывается над кнопкой с той же стороны; анимация «джин» и полноэкранный режим на телефоне работают с обеих сторон +- [x] Ошибка конфигурации не ломает страницу: кнопка остаётся в стандартном виде +- [x] Проверено на тестовой странице для обеих сторон и трёх размеров + +## Заметки + +webchat/loader.py — цвет сейчас зашит #1677ff, в genie-анимации тоже (paintGenie). + +## Итог + +Ветка T-021 перенесена через git rebase main на 223a962c, конфликт views.py разрешён. Общие публичные обработчики используются из access.py свежей main, дубликат public_views.py удалён; передача preChatFields сохранена, обработчики конфигурации с CORS и лоадера сохранены. Новый коммит a0eb9f31: связанные проверки прошли, исходные настройки dev-подключения восстановлены. diff --git a/apps/backend/chatballs/webchat/config_views.py b/apps/backend/chatballs/webchat/config_views.py new file mode 100644 index 0000000..14900c5 --- /dev/null +++ b/apps/backend/chatballs/webchat/config_views.py @@ -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 diff --git a/apps/backend/chatballs/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py index 35c76db..8a441c0 100644 --- a/apps/backend/chatballs/webchat/loader.py +++ b/apps/backend/chatballs/webchat/loader.py @@ -1,362 +1,14 @@ -# Публичный JS-лоадер виджета (SPEC-CHATBALLS-0003 §3). Подключается одним тегом: -# -# Лоадер рисует 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 = ''; - var CHEVRON_ICON = ''; - - 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"), + ) diff --git a/apps/backend/chatballs/webchat/loader_assets/appearance.js b/apps/backend/chatballs/webchat/loader_assets/appearance.js new file mode 100644 index 0000000..2a8f153 --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_assets/appearance.js @@ -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 () { /* Стандартная кнопка уже смонтирована. */ }); + } diff --git a/apps/backend/chatballs/webchat/loader_assets/genie.js b/apps/backend/chatballs/webchat/loader_assets/genie.js new file mode 100644 index 0000000..eea391d --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_assets/genie.js @@ -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() + }; + } diff --git a/apps/backend/chatballs/webchat/loader_assets/launcher.js b/apps/backend/chatballs/webchat/loader_assets/launcher.js new file mode 100644 index 0000000..34a3ac6 --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_assets/launcher.js @@ -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 = ''; + var CHEVRON_ICON = ''; + + 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(); + } diff --git a/apps/backend/chatballs/webchat/loader_assets/runtime.js b/apps/backend/chatballs/webchat/loader_assets/runtime.js new file mode 100644 index 0000000..f9e7485 --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_assets/runtime.js @@ -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); +})(); diff --git a/apps/backend/chatballs/webchat/loader_views.py b/apps/backend/chatballs/webchat/loader_views.py new file mode 100644 index 0000000..4e09dfc --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_views.py @@ -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 diff --git a/apps/backend/chatballs/webchat/test_appearance.py b/apps/backend/chatballs/webchat/test_appearance.py index 78a96ec..f65ff6b 100644 --- a/apps/backend/chatballs/webchat/test_appearance.py +++ b/apps/backend/chatballs/webchat/test_appearance.py @@ -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) diff --git a/apps/backend/chatballs/webchat/urls.py b/apps/backend/chatballs/webchat/urls.py index a83ba80..321215a 100644 --- a/apps/backend/chatballs/webchat/urls.py +++ b/apps/backend/chatballs/webchat/urls.py @@ -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//", 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"), diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index ae6427c..2ae3567 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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 diff --git a/apps/backend/chatballs_backend/urls_app.py b/apps/backend/chatballs_backend/urls_app.py index 2df652a..49c0c6f 100644 --- a/apps/backend/chatballs_backend/urls_app.py +++ b/apps/backend/chatballs_backend/urls_app.py @@ -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"), diff --git a/apps/web-chat/public/demo.html b/apps/web-chat/public/demo.html index b4f7d54..48dabcc 100644 --- a/apps/web-chat/public/demo.html +++ b/apps/web-chat/public/demo.html @@ -30,6 +30,15 @@ Встраивание: <script src="/chat-widget.js" data-channel="demo" async></script> - + diff --git a/tests/webchat/README.md b/tests/webchat/README.md new file mode 100644 index 0000000..9ce8408 --- /dev/null +++ b/tests/webchat/README.md @@ -0,0 +1,34 @@ +# Проверка лоадера + +Модульные тесты разбора оформления и геометрии анимации: + +```sh +node --test tests/webchat/loader.test.cjs +``` + +Связанные серверные тесты запускаются в `backend-admin` штатного dev-контура, +с `--reuse-db`: + +```sh +pytest -q chatballs/webchat/test_appearance.py::WebAppearanceApiTests \ + chatballs/webchat/tests.py::PublicWebChatWidgetTests --reuse-db +``` + +Для браузерной проверки открыть `/chat/demo.html?widgetKey=<существующий ключ>`. +Параметр `widgetOrigin` позволяет проверить вставку с другого origin: +`/chat/demo.html?widgetKey=<ключ>&widgetOrigin=<адрес установки>`. +Оба адреса страницы должны быть разрешены в настройках существующего подключения. + +`check-launcher.js` выполняется через task-local Playwright MCP +(`browser_run_code_unsafe`, `filename`). Сценарий читает реальную конфигурацию, +проверяет размер, форму, сторону, цвет, тень, фокус, иконку, открытие/закрытие +«джина», разворачивание окна и полноэкранный режим 390×844. +Запустить для размеров 48/56/64 с обеих сторон, меняя настройки существующего +dev-подключения и восстанавливая их после проверки. Сценарий не создаёт данные +и не начинает диалог. + +`check-fallback.js` проверяет стандартную кнопку и работу страницы при сетевом +отказе запроса конфигурации. Он прерывает запрос, не подставляя ответ API. +Отдельно проверить отказ загрузки иконки и получение конфигурации во время +анимации: при ошибке иконки возвращается стандартный знак, поздние настройки +применяются после завершения анимации. diff --git a/tests/webchat/check-fallback.js b/tests/webchat/check-fallback.js new file mode 100644 index 0000000..fa59c8e --- /dev/null +++ b/tests/webchat/check-fallback.js @@ -0,0 +1,29 @@ +// Проверяем сетевой отказ, не подменяя API вымышленным ответом. +async (page) => { + const errors = []; + const onError = error => errors.push(error.message); + const endpoint = "**/api/v1/webchat/config/**"; + await page.setViewportSize({ width: 1280, height: 900 }); + page.on("pageerror", onError); + await page.route(endpoint, route => route.abort("failed")); + try { + const failed = page.waitForEvent("requestfailed", { predicate: request => request.url().includes("/webchat/config/") }); + await page.reload({ waitUntil: "domcontentloaded" }); + await failed; + const button = page.locator(".chatballs-chat-launcher"); + const defaults = await button.evaluate(b => ({ color: getComputedStyle(b).backgroundColor, + width: b.offsetWidth, radius: b.style.borderRadius, side: b.style.right, standardIcon: !!b.querySelector("svg") })); + if (defaults.color !== "rgb(22, 119, 255)" || defaults.width !== 56 || defaults.radius !== "50%" || + defaults.side !== "24px" || !defaults.standardIcon) throw new Error("Default launcher was lost"); + await button.click(); + await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.clipPath === "none"); + await button.click(); + await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none"); + if (errors.length) throw new Error(errors.join("; ")); + if (!await page.getByRole("heading").isVisible()) throw new Error("Host page was affected"); + return { defaults, pageErrors: errors, openCloseWorks: true, passed: true }; + } finally { + await page.unroute(endpoint); + page.off("pageerror", onError); + } +} diff --git a/tests/webchat/check-launcher.js b/tests/webchat/check-launcher.js new file mode 100644 index 0000000..85f1fad --- /dev/null +++ b/tests/webchat/check-launcher.js @@ -0,0 +1,82 @@ +// Сценарий для task-local Playwright MCP на штатной demo.html?widgetKey=… . +// Конфигурация берётся с реального API; ответы и учётные записи не подменяются. +async (page) => { + const check = (condition, message) => { if (!condition) throw new Error(message); }; + await page.setViewportSize({ width: 1280, height: 900 }); + await page.reload({ waitUntil: "domcontentloaded" }); + const config = await page.evaluate(async () => { + const key = new URLSearchParams(location.search).get("widgetKey"); + const response = await fetch("/api/v1/webchat/config/?widgetKey=" + encodeURIComponent(key) + + "&hostOrigin=" + encodeURIComponent(location.origin)); + return response.json(); + }); + check(config.available, "The real widget must be available"); + const { launcherPosition: side, launcherSize: size, launcherShape: shape, accent, launcherIcon } = config.appearance; + const rgb = [1, 3, 5].map(offset => parseInt(accent.slice(offset, offset + 2), 16)).join(", "); + const fill = `rgb(${rgb.replaceAll(" ", "")})`; + await page.waitForFunction(({ side, size }) => { + const b = document.querySelector(".chatballs-chat-launcher"); + return b && b.style[side] === "24px" && b.offsetWidth === size; + }, { side, size }); + const button = page.locator(".chatballs-chat-launcher"); + await page.mouse.move(640, 850); + await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher")) + .boxShadow.includes(`rgba(${rgb}, 0.35)`), rgb); + const read = () => button.evaluate(b => ({ box: b.getBoundingClientRect().toJSON(), + color: getComputedStyle(b).backgroundColor, radius: getComputedStyle(b).borderRadius, + shadow: getComputedStyle(b).boxShadow, outline: getComputedStyle(b).outlineColor, + icon: b.querySelector("img")?.src, iconWidth: b.querySelector("img")?.naturalWidth })); + if (launcherIcon) await page.waitForFunction(() => document.querySelector(".chatballs-chat-launcher img")?.naturalWidth > 0); + const launcher = await read(); + check(launcher.color === `rgb(${rgb})`, "Accent was not applied"); + check(launcher.box.width === size && launcher.box.height === size, "Incorrect launcher size"); + check(launcher.radius === ({ circle: "50%", rounded: "30%", square: "10px" })[shape], "Incorrect launcher shape"); + check(side === "left" ? launcher.box.left === 24 : launcher.box.right === 1256, "Incorrect launcher side"); + await page.keyboard.press("Tab"); + await button.focus(); + const focus = await read(); + check(focus.outline === `rgba(${rgb}, 0.45)`, "Focus must use accent"); + await button.hover(); + await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher")) + .boxShadow.includes(`rgba(${rgb}, 0.45)`), rgb); + const hover = await read(); + await page.mouse.move(640, 850); + const animate = () => page.evaluate(() => new Promise(resolve => { + document.querySelector(".chatballs-chat-launcher").click(); + const svg = document.querySelector("body > svg"); + const path = svg.querySelector("path"); + const samples = []; + const sample = () => { + samples.push({ fill: path.getAttribute("fill"), path: path.getAttribute("d") }); + if (svg.style.display === "none") resolve(samples); + else requestAnimationFrame(sample); + }; + sample(); + })); + const opening = await animate(); + check(opening.length > 2 && opening[0].fill === fill, "Genie must start in the launcher accent"); + check(opening.every(s => !/NaN|Infinity/.test(s.path)), "Genie geometry must stay finite"); + const panel = () => page.locator("iframe").evaluate(f => f.parentElement.getBoundingClientRect().toJSON()); + const normal = await panel(); + check(normal.bottom === 900 - size - 36, "Panel must stay 12px above launcher"); + check(side === "left" ? normal.left === 24 : normal.right === 1256, "Panel must follow launcher side"); + const frame = page.frameLocator("iframe"); + await frame.getByRole("button", { name: "Развернуть окно", exact: true }).click(); + await page.waitForFunction(() => Math.abs(document.querySelector("iframe").parentElement.getBoundingClientRect().width - 820) < .1); + const expanded = await panel(); + check(side === "left" ? expanded.left === 24 : expanded.right === 1256, "Expanded panel must keep its side"); + const closing = await animate(); + check(closing.at(-1).fill === fill, "Genie must close into the launcher accent"); + if (launcherIcon) check((await read()).iconWidth > 0, "Custom icon must return after closing"); + await page.setViewportSize({ width: 390, height: 844 }); + await animate(); + const mobile = await panel(); + check(mobile.x === 0 && mobile.y === 0 && mobile.width === 390 && mobile.height === 844, "Mobile panel must be fullscreen"); + check(await button.evaluate(b => b.style.opacity === "0" && b.style.pointerEvents === "none"), "Launcher must be hidden on mobile"); + check(await frame.getByRole("button", { name: "Развернуть окно", exact: true }).count() === 0, "Fullscreen panel cannot expand"); + await frame.getByRole("button", { name: "Свернуть", exact: true }).click(); + await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none"); + check(await button.evaluate(b => b.style.opacity === "1" && b.style.pointerEvents !== "none"), "Launcher must return after mobile close"); + return { side, size, shape, accent, launcher, focus: focus.outline, hover: hover.shadow, + normal, expanded, mobile, genieFrames: [opening.length, closing.length], passed: true }; +} diff --git a/tests/webchat/loader.test.cjs b/tests/webchat/loader.test.cjs new file mode 100644 index 0000000..dad1537 --- /dev/null +++ b/tests/webchat/loader.test.cjs @@ -0,0 +1,90 @@ +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { test } = require("node:test"); +const vm = require("node:vm"); + +const assets = join(__dirname, "../../apps/backend/chatballs/webchat/loader_assets"); +function context() { + const ctx = vm.createContext({ URL, origin: "https://widget.example" }); + vm.runInContext(readFileSync(join(assets, "appearance.js"), "utf8"), ctx); + vm.runInContext(readFileSync(join(assets, "genie.js"), "utf8"), ctx); + return ctx; +} + +test("launcher configuration covers both sides, all sizes and all shapes", () => { + const ctx = context(); + for (const position of ["left", "right"]) { + for (const size of [48, 56, 64]) { + for (const shape of ["circle", "rounded", "square"]) { + ctx.appearance = ctx.readAppearance({ available: true, appearance: { + accent: "#0d8a7e", launcherPosition: position, launcherSize: size, launcherShape: shape, + launcherIcon: "/api/v1/webchat/assets/icon/", + } }); + assert.equal(ctx.appearance.position, position); + assert.equal(ctx.appearance.size, size); + assert.equal(ctx.appearance.shape, shape); + assert.deepEqual(Array.from(ctx.appearance.rgb), [13, 138, 126]); + assert.equal(ctx.appearance.icon, "https://widget.example/api/v1/webchat/assets/icon/"); + assert.equal(ctx.launcherRadius(), shape === "square" ? 10 : size * (shape === "rounded" ? .3 : .5)); + } + } + } +}); + +test("legacy accent works and absent configuration leaves the default launcher", () => { + const ctx = context(); + const legacy = ctx.readAppearance({ available: true, accent: "#BE123C" }); + assert.deepEqual(Array.from(legacy.rgb), [190, 18, 60]); + assert.equal(legacy.position, "right"); + assert.equal(legacy.size, 56); + assert.equal(legacy.shape, "circle"); + for (const response of [null, {}, { available: false }, { available: false, appearance: {} }]) { + assert.equal(ctx.readAppearance(response), null); + } + assert.equal(ctx.appearance.accent, "#1677ff"); +}); + +test("invalid configuration is rejected before any appearance is applied", () => { + const ctx = context(); + for (const appearance of [null, [], "text", { accent: "red" }, { launcherSize: 50 }, + { launcherSize: true }, { launcherPosition: "top" }, { launcherShape: "oval" }, + { launcherIcon: 42 }, { launcherIcon: "javascript:alert(1)" }, + { launcherIcon: "//evil.example/icon.svg" }, { launcherIcon: "data:image/svg+xml," }, + { launcherIcon: '/icon.svg" onerror="alert(1)' }]) { + assert.throws(() => ctx.readAppearance({ available: true, appearance })); + assert.equal(ctx.appearance.accent, "#1677ff"); + } +}); + +test("genie geometry mirrors across the viewport and ends in the selected launcher shape", () => { + const ctx = context(); + const viewport = 1280; + const panel = { top: 100, bottom: 708, cx: 1036, half: 220, radius: 24 }; + for (const size of [48, 56, 64]) { + for (const radius of [size / 2, size * .3, 10]) { + const icon = { top: 720, bottom: 720 + size, cx: 1224, half: size / 2, radius }; + for (const p of [0, .25, .5, .75, 1]) { + const right = ctx.shapeAt(p, panel, icon); + const left = ctx.shapeAt(p, { ...panel, cx: viewport - panel.cx }, { ...icon, cx: viewport - icon.cx }); + assert.ok(Math.abs(left.topL - (viewport - right.topRt)) < 1e-9); + assert.ok(Math.abs(left.bottomRt - (viewport - right.bottomL)) < 1e-9); + assert.equal(left.topY, right.topY); + assert.equal(left.bottomY, right.bottomY); + assert.ok(!/NaN|Infinity/.test(ctx.pathOf(left, 0, 0))); + if (p === 1) { + assert.equal(left.topR, radius); + assert.equal(left.bottomR, radius); + } + } + } + } +}); + +test("all loader modules assemble into one valid standalone script", () => { + let source = readFileSync(join(assets, "runtime.js"), "utf8"); + for (const module of ["appearance", "launcher", "genie"]) { + source = source.replace(`/*__${module.toUpperCase()}__*/`, () => readFileSync(join(assets, `${module}.js`), "utf8")); + } + assert.doesNotThrow(() => new vm.Script(source)); +});