diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/hub_backend/settings.py index 3b731fe..b070502 100644 --- a/apps/backend/hub_backend/settings.py +++ b/apps/backend/hub_backend/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ "hub_platform.channels", "hub_platform.conversations", "hub_platform.notifications", + "hub_platform.webchat", "hub_platform.health", "hub_platform.events", ] diff --git a/apps/backend/hub_backend/urls.py b/apps/backend/hub_backend/urls.py index e72137c..aef3a03 100644 --- a/apps/backend/hub_backend/urls.py +++ b/apps/backend/hub_backend/urls.py @@ -2,8 +2,11 @@ from django.contrib import admin from django.urls import include, path from rest_framework.schemas import get_schema_view +from hub_platform.webchat.views import WidgetLoaderView + urlpatterns = [ path("admin/", admin.site.urls), + path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"), path("api/v1/schema/", get_schema_view(title="Edevs Hub API", version="0.1.0"), name="openapi-schema"), path("api/v1/auth/", include("hub_platform.identity.auth_urls")), path("api/v1/company/", include("hub_platform.identity.company_urls")), @@ -14,5 +17,6 @@ urlpatterns = [ path("api/v1/channels/", include("hub_platform.channels.urls")), path("api/v1/conversations/", include("hub_platform.conversations.urls")), path("api/v1/notifications/", include("hub_platform.notifications.urls")), + path("api/v1/webchat/", include("hub_platform.webchat.urls")), path("api/v1/health/", include("hub_platform.health.urls")), ] diff --git a/apps/backend/hub_platform/conversations/transports/__init__.py b/apps/backend/hub_platform/conversations/transports/__init__.py index 9d37a4a..02986ae 100644 --- a/apps/backend/hub_platform/conversations/transports/__init__.py +++ b/apps/backend/hub_platform/conversations/transports/__init__.py @@ -6,9 +6,15 @@ _POLL = { IntegrationProvider.MAX: _max.poll_updates, IntegrationProvider.TELEGRAM: _telegram.poll_updates, } +def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool: + # Web Chat: ответ уже сохранён в БД, браузер забирает его поллингом — внешней отправки нет. + return True + + _SEND = { IntegrationProvider.MAX: _max.send_text, IntegrationProvider.TELEGRAM: _telegram.send_text, + IntegrationProvider.WEB: _web_noop, } # Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки. diff --git a/apps/backend/hub_platform/webchat/__init__.py b/apps/backend/hub_platform/webchat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/hub_platform/webchat/apps.py b/apps/backend/hub_platform/webchat/apps.py new file mode 100644 index 0000000..0fa3080 --- /dev/null +++ b/apps/backend/hub_platform/webchat/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class WebchatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "webchat" + name = "hub_platform.webchat" + verbose_name = "Web chat widget" diff --git a/apps/backend/hub_platform/webchat/loader.py b/apps/backend/hub_platform/webchat/loader.py new file mode 100644 index 0000000..2f561f6 --- /dev/null +++ b/apps/backend/hub_platform/webchat/loader.py @@ -0,0 +1,54 @@ +# Публичный JS-лоадер виджета (SPEC-HUB-0003 §3). Подключается одним тегом: +# +# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/). + +LOADER_JS = r""" +(function () { + var script = document.currentScript; + if (!script) return; + var channel = script.getAttribute("data-channel") || "edevs"; + var origin = new URL(script.src, location.href).origin; + var panelUrl = origin + "/chat/?channel=" + encodeURIComponent(channel); + + var open = false, frame = null; + + var btn = document.createElement("button"); + btn.setAttribute("aria-label", "Открыть чат"); + btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:60px;height:60px;border-radius:50%;border:none;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.4);cursor:pointer;z-index:2147483000;display:flex;align-items:center;justify-content:center;"; + btn.innerHTML = ''; + + var dot = document.createElement("span"); + dot.style.cssText = "position:absolute;top:10px;right:10px;width:12px;height:12px;border-radius:50%;background:#ff4d4f;border:2px solid #1677ff;display:none;"; + btn.appendChild(dot); + + function ensureFrame() { + if (frame) return; + frame = document.createElement("iframe"); + frame.src = panelUrl; + frame.title = "Чат"; + frame.style.cssText = "position:fixed;right:24px;bottom:24px;width:min(384px,calc(100vw - 32px));height:min(600px,calc(100vh - 48px));border:none;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,0.18);z-index:2147483000;display:none;background:transparent;"; + document.body.appendChild(frame); + window.addEventListener("message", function (e) { + if (e.origin !== origin) return; + var d = e.data || {}; + if (d.type === "edevs-chat-close") setOpen(false); + if (d.type === "edevs-chat-unread") dot.style.display = d.unread && !open ? "block" : "none"; + }); + } + + function setOpen(next) { + ensureFrame(); + open = next; + frame.style.display = open ? "block" : "none"; + btn.style.display = open ? "none" : "flex"; + if (open) { + dot.style.display = "none"; + try { frame.contentWindow.postMessage({ type: "edevs-chat-opened" }, origin); } catch (_) {} + } + } + + btn.addEventListener("click", function () { setOpen(true); }); + if (document.body) document.body.appendChild(btn); + else window.addEventListener("DOMContentLoaded", function () { document.body.appendChild(btn); }); +})(); +""" diff --git a/apps/backend/hub_platform/webchat/migrations/0001_initial.py b/apps/backend/hub_platform/webchat/migrations/0001_initial.py new file mode 100644 index 0000000..208d8b1 --- /dev/null +++ b/apps/backend/hub_platform/webchat/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-06-28 15:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('conversations', '0001_initial'), + ('integrations', '0002_integration_channel_integration_poll_marker'), + ] + + operations = [ + migrations.CreateModel( + name='WebSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(db_index=True, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_seen_at', models.DateTimeField(auto_now=True)), + ('connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_sessions', to='integrations.integration')), + ('identity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='web_sessions', to='conversations.connectionidentity')), + ], + ), + ] diff --git a/apps/backend/hub_platform/webchat/migrations/__init__.py b/apps/backend/hub_platform/webchat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/hub_platform/webchat/models.py b/apps/backend/hub_platform/webchat/models.py new file mode 100644 index 0000000..82d334a --- /dev/null +++ b/apps/backend/hub_platform/webchat/models.py @@ -0,0 +1,15 @@ +from django.db import models + +# Анонимная браузерная сессия Web Chat (SPEC-HUB-0003 §7). Храним только hash +# токена; токен живёт в браузере и идентифицирует ConnectionIdentity канала. + + +class WebSession(models.Model): + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + connection = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="web_sessions") + identity = models.ForeignKey("conversations.ConnectionIdentity", on_delete=models.CASCADE, related_name="web_sessions") + created_at = models.DateTimeField(auto_now_add=True) + last_seen_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"websession:{self.identity_id}" diff --git a/apps/backend/hub_platform/webchat/services.py b/apps/backend/hub_platform/webchat/services.py new file mode 100644 index 0000000..7ab685f --- /dev/null +++ b/apps/backend/hub_platform/webchat/services.py @@ -0,0 +1,132 @@ +import hashlib +import secrets +import uuid + +from django.db import transaction + +from hub_platform.conversations.ingest import ingest_inbound +from hub_platform.conversations.models import ( + ConnectionIdentity, + Contact, + Conversation, + ControlMode, + LifecycleState, +) +from hub_platform.conversations.transports.base import InboundMessage +from hub_platform.integrations.models import Integration, IntegrationProvider +from hub_platform.webchat.models import WebSession + +DEFAULT_GREETING = "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?" +DEFAULT_CONSENT = "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение." +DEFAULT_ACCENT = "#1677ff" + +_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"} +_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"} + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def web_connection_for_channel(channel_code: str) -> Integration | None: + return ( + Integration.objects.select_related("channel") + .filter(provider=IntegrationProvider.WEB, channel__code=channel_code) + .first() + ) + + +def _host_allowed(integration: Integration, origin: str) -> bool: + allowed = integration.config.get("allowed_domains") or [] + if not allowed: + return True # dev: пустой allowlist = разрешено + if not origin: + return False + host = origin.split("//")[-1].split("/")[0].split(":")[0] + return any(host == d or host.endswith("." + d) for d in allowed) + + +def public_config(channel_code: str, origin: str) -> dict: + integration = web_connection_for_channel(channel_code) + if integration is None or integration.channel_id is None: + return {"available": False} + if not _host_allowed(integration, origin): + return {"available": False, "reason": "domain"} + cfg = integration.config + channel = integration.channel + fallback = [] + for sib in Integration.objects.filter(channel=channel).exclude(id=integration.id): + username = sib.config.get("bot_username") + if sib.provider == IntegrationProvider.TELEGRAM and username: + fallback.append({"label": "Написать в Telegram", "url": f"https://t.me/{username}"}) + elif sib.provider == IntegrationProvider.MAX and username: + fallback.append({"label": "Написать в MAX", "url": ""}) + return { + "available": True, + "channel": channel.code, + "title": cfg.get("title") or channel.name, + "accent": cfg.get("accent") or DEFAULT_ACCENT, + "greeting": cfg.get("greeting") or DEFAULT_GREETING, + "consent": {"text": cfg.get("consent_text") or DEFAULT_CONSENT, "version": cfg.get("consent_version") or "v1"}, + "quickReplies": cfg.get("quick_replies") or [], + "fallback": fallback, + } + + +@transaction.atomic +def issue_session(channel_code: str) -> dict | None: + integration = web_connection_for_channel(channel_code) + if integration is None or integration.channel_id is None: + return None + session_id = uuid.uuid4().hex + contact = Contact.objects.create(organization=integration.channel.organization, name="Веб-гость") + identity = ConnectionIdentity.objects.create( + contact=contact, connection=integration, external_user_id=session_id, display_name="Веб-гость" + ) + token = secrets.token_urlsafe(32) + WebSession.objects.create(token_hash=_hash(token), connection=integration, identity=identity) + return {"token": token, "sessionId": session_id} + + +def resolve_session(token: str) -> WebSession | None: + if not token: + return None + return ( + WebSession.objects.select_related( + "connection", "connection__channel", "identity", "identity__contact" + ) + .filter(token_hash=_hash(token)) + .first() + ) + + +def post_message(session: WebSession, text: str) -> None: + inbound = InboundMessage( + external_id=uuid.uuid4().hex, + user_id=session.identity.external_user_id, + chat_id="", + text=text, + display_name=session.identity.display_name, + ) + ingest_inbound(session.connection, inbound) + + +def messages_payload(session: WebSession, since: int) -> dict: + conversation = ( + Conversation.objects.filter( + channel=session.connection.channel, contact=session.identity.contact, lifecycle=LifecycleState.OPEN + ) + .order_by("-last_activity_at") + .first() + ) + if conversation is None: + return {"state": "ai", "lifecycle": LifecycleState.OPEN, "messages": []} + items = conversation.messages.filter(id__gt=since).order_by("created_at") + return { + "state": _STATE.get(conversation.control_mode, "ai"), + "lifecycle": conversation.lifecycle, + "messages": [ + {"id": m.id, "author": _ROLE.get(m.author_type, "ai"), "text": m.text, "createdAt": m.created_at.isoformat()} + for m in items + ], + } diff --git a/apps/backend/hub_platform/webchat/urls.py b/apps/backend/hub_platform/webchat/urls.py new file mode 100644 index 0000000..a91b554 --- /dev/null +++ b/apps/backend/hub_platform/webchat/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from hub_platform.webchat import views + +urlpatterns = [ + path("config/", views.WebchatConfigView.as_view(), name="webchat-config"), + path("session/", views.WebchatSessionView.as_view(), name="webchat-session"), + path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"), +] diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/hub_platform/webchat/views.py new file mode 100644 index 0000000..9f6dc09 --- /dev/null +++ b/apps/backend/hub_platform/webchat/views.py @@ -0,0 +1,69 @@ +from django.http import HttpResponse +from django.views import View +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.webchat import services +from hub_platform.webchat.loader import LOADER_JS + + +def _origin(request: Request) -> str: + return request.headers.get("Origin") or request.headers.get("Referer") or "" + + +def _token(request: Request) -> str: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:] + if request.method == "POST": + return str(request.data.get("token", "")) + return request.GET.get("token", "") + + +class _Public(APIView): + authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django + permission_classes = [AllowAny] + + +class WebchatConfigView(_Public): + def get(self, request: Request) -> Response: + return Response(services.public_config(request.GET.get("channel", ""), _origin(request))) + + +class WebchatSessionView(_Public): + def post(self, request: Request) -> Response: + result = services.issue_session(str(request.data.get("channel", ""))) + if result is None: + return Response({"detail": "Канал недоступен"}, status=404) + return Response(result, status=201) + + +class WebchatMessagesView(_Public): + def post(self, request: Request) -> Response: + session = services.resolve_session(_token(request)) + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + text = str(request.data.get("text", "")).strip() + if not text: + return Response({"detail": "Пустое сообщение"}, status=400) + services.post_message(session, text[:4000]) + return Response({"ok": True}, status=201) + + def get(self, request: Request) -> Response: + session = services.resolve_session(_token(request)) + if session is None: + return Response({"detail": "Сессия не найдена"}, status=401) + try: + since = int(request.GET.get("since", "0") or 0) + except ValueError: + since = 0 + return Response(services.messages_payload(session, since)) + + +class WidgetLoaderView(View): + def get(self, request) -> HttpResponse: + response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8") + response["Cache-Control"] = "public, max-age=300" + return response diff --git a/apps/web-chat/public/demo.html b/apps/web-chat/public/demo.html new file mode 100644 index 0000000..86ce7e4 --- /dev/null +++ b/apps/web-chat/public/demo.html @@ -0,0 +1,35 @@ + + + + + + Demo · Edevs Web Chat + + + + +
+

Демо-страница для виджета Edevs

+

Это фейковый сайт для проверки web-чата. Виджет подключён одним тегом снизу — launcher в правом нижнем углу.

+
+
+ Встраивание: <script src="/chat-widget.js" data-channel="edevs" async></script> +
+ + + + diff --git a/apps/web-chat/src/App.tsx b/apps/web-chat/src/App.tsx index e1ba812..a810ceb 100644 --- a/apps/web-chat/src/App.tsx +++ b/apps/web-chat/src/App.tsx @@ -1,18 +1,224 @@ -import { ConfigProvider, FloatButton, Typography } from "antd"; -import { edevsHubTheme } from "@edevs/ui"; +import { useEffect, useRef, useState } from "react"; + +import { getConfig, poll, sendMessage, startSession, type WebConfig, type WebMessage } from "./api"; + +const CHANNEL = new URLSearchParams(location.search).get("channel") || "edevs"; +const TOKEN_KEY = `edevs-chat-token:${CHANNEL}`; + +function closePanel() { + window.parent.postMessage({ type: "edevs-chat-close" }, "*"); +} export function App() { + const [config, setConfig] = useState(null); + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); + const [accepted, setAccepted] = useState(() => Boolean(localStorage.getItem(TOKEN_KEY))); + const [messages, setMessages] = useState([]); + const [pending, setPending] = useState([]); + const [state, setState] = useState<"ai" | "operator" | "waiting">("ai"); + const [awaiting, setAwaiting] = useState(false); + const [input, setInput] = useState(""); + const [starting, setStarting] = useState(false); + const lastId = useRef(0); + const bodyRef = useRef(null); + + useEffect(() => { + getConfig(CHANNEL).then(setConfig).catch(() => setConfig({ available: false })); + }, []); + + function ingestPoll(data: { state: "ai" | "operator" | "waiting"; messages: WebMessage[] }) { + setState(data.state); + if (data.messages.length) { + lastId.current = Math.max(lastId.current, ...data.messages.map((m) => m.id)); + setMessages((prev) => [...prev, ...data.messages.filter((m) => !prev.some((p) => p.id === m.id))]); + if (data.messages.some((m) => m.author !== "client")) setAwaiting(false); + setPending([]); + } + } + + // Поллинг ответов AI/оператора. + useEffect(() => { + if (!accepted || !token) return; + let alive = true; + const tick = async () => { + try { + const data = await poll(token, lastId.current); + if (alive) ingestPoll(data); + } catch { + /* keep trying */ + } + }; + void tick(); + const timer = setInterval(tick, 2500); + return () => { alive = false; clearInterval(timer); }; + }, [accepted, token]); + + useEffect(() => { + const node = bodyRef.current; + if (node) node.scrollTop = node.scrollHeight; + }, [messages, pending, awaiting]); + + const accent = config?.accent || "#1677ff"; + const title = config?.title || "Чат"; + const letter = title.trim()[0]?.toUpperCase() || "E"; + + async function accept() { + setStarting(true); + const t = await startSession(CHANNEL); + setStarting(false); + if (!t) return; + localStorage.setItem(TOKEN_KEY, t); + setToken(t); + setAccepted(true); + } + + async function send() { + const text = input.trim(); + if (!text || !token || awaiting) return; + setInput(""); + setPending((p) => [...p, text]); + setAwaiting(true); + await sendMessage(token, text).catch(() => undefined); + try { + ingestPoll(await poll(token, lastId.current)); + } catch { + /* polling loop will retry */ + } + setPending([]); + setAwaiting(false); + } + + const status = state === "operator" + ? { label: "Отвечает специалист", dot: "#52c41a" } + : state === "waiting" + ? { label: "Передаём оператору", dot: "#faad14" } + : { label: "Виртуальный помощник", dot: "#52c41a" }; + const unavailable = config !== null && !config.available; + return ( - -
-
- Web Chat - - Каркас публичного виджета готов. Детальная реализация UI ждёт прохождения DG-04. - -
- -
-
+
+
+
{letter}
+
+
{title}
+
+ + {unavailable ? "Временно недоступен" : status.label} +
+
+ +
+ +
+ {config === null &&
Загрузка…
} + + {unavailable && ( +
+
+ +
+

Чат временно недоступен

+

Напишите нам в другом канале — ответим как можно скорее.

+
+ )} + + {config?.available && !accepted && ( + <> +
+
{letter}
+

{title}

+

{config.greeting}

+
+
+
{config.consent?.text} · ред. {config.consent?.version}
+
+ + )} + + {config?.available && accepted && ( + <> +
Сегодня
+ {config.greeting && } + {messages.map((m) => (m.author === "system" + ?
{m.text}
+ : ))} + {pending.map((t, i) => )} + {awaiting && } + + )} +
+ + {config?.available && !accepted && ( +
+ +
+ )} + {config?.available && accepted && ( +
+ {state === "ai" && (config.quickReplies?.length ?? 0) > 0 && pending.length === 0 && messages.length === 0 && ( +
+ {config.quickReplies!.map((q) => ( + + ))} +
+ )} +
+