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 @@ + + +
+ + +Это фейковый сайт для проверки web-чата. Виджет подключён одним тегом снизу — launcher в правом нижнем углу.
+<script src="/chat-widget.js" data-channel="edevs" async></script>
+ Напишите нам в другом канале — ответим как можно скорее.
+{config.greeting}
+