✨ feat(webchat): настроить форму перед чатом на сервере

This commit is contained in:
Andrey committed 2026-09-30 17:29:45 +03:00
1 parent 6e36bdc725
commit 416e37e441
15 files changed
+708 -320

No files matched your search

@@ -0,0 +1,28 @@
---
id: T-016
title: Настройка формы перед чатом на сервере
milestone: M03
status: done
depends_on:
- T-010
order: 1
spec: "0020"
created: 2026-09-29
branch: skaro/T-016-nastroyka-formy-pered-chatom-n
---
## Цель
Сервер хранит и валидирует config.preChat, сам повышает версию согласия и принимает значения формы при старте сессии (R-1, R-3, R-4, R-5, R-10).
## Критерии приёмки
- [x] PATCH подключения валидирует preChat: enabled, title, fields [{key, required}], key — name/email/phone или существующее своё поле; по умолчанию выключено
- [x] Изменение consentText повышает consentVersion на сервере
- [x] Удаление своего поля из схемы убирает его из preChat
- [x] Публичная конфигурация отдаёт preChat; старт сессии принимает значения формы, они проходят ту же валидацию и запись, что setFields, и имеют приоритет над ними
- [x] Тесты проходят (только затронутые)
## Итог
Реализованы хранение и валидация config.preChat, серверное повышение consentVersion при изменении текста и удаление ссылок на удалённые свои поля. Публичный API отдаёт форму и схему; старт сессии принимает preChatFields с приоритетом над fields до общего валидатора и записи в контакт/значения полей. Коммит 7e84a254; адресные проверки прошли на исходниках T-016 в штатном backend-admin с отдельной тестовой БД test_chatballs_t016.
@@ -515,6 +515,8 @@ MESSAGES: dict[str, object] = {
"settings.ports_numbers": "Ports must be numbers",
"settings.quick_replies_list": "quickReplies must be a list of strings",
"settings.unknown_provider": "Unknown provider",
"settings.pre_chat_invalid": "Pre-chat form settings are invalid — reload the page and try again",
"settings.pre_chat_unknown_field": "A pre-chat form field was not found. Select an existing field",
"settings.web_fields_list": "Custom fields were sent in a wrong format — reload the page and try again",
"settings.web_fields_limit": "A connection can have at most {limit} custom fields — delete the extra ones",
"settings.web_field_key_invalid": "Key “{key}” is not valid: lowercase Latin letters, digits and “_”, starting with a letter, up to 40 characters",
@@ -519,6 +519,8 @@ MESSAGES: dict[str, object] = {
"settings.ports_numbers": "Порты — числа",
"settings.quick_replies_list": "quickReplies — список строк",
"settings.unknown_provider": "Неизвестный провайдер",
"settings.pre_chat_invalid": "Настройки формы перед чатом переданы неверно — обновите страницу и повторите",
"settings.pre_chat_unknown_field": "Поле формы перед чатом не найдено. Выберите существующее поле",
"settings.web_fields_list": "Свои поля переданы неверно — обновите страницу и повторите",
"settings.web_fields_limit": "Своих полей может быть не больше {limit} — удалите лишние",
"settings.web_field_key_invalid": "Ключ «{key}» не подходит: латинские строчные буквы, цифры и «_», начинается с буквы, до 40 символов",
@@ -3,6 +3,7 @@ from urllib.parse import urlsplit, urlunsplit
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.webchat.appearance import stored_appearance
from chatballs.webchat.field_schema import fields_payload
from chatballs.webchat.pre_chat import pre_chat_payload
# Пароль прокси наружу не отдаётся: в списке подключений его видел бы каждый,
# у кого есть право смотреть интеграции, а сам адрес попадал бы в логи и
@@ -83,6 +84,7 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"accent": integration.config.get("accent", ""),
"greeting": integration.config.get("greeting", ""),
"quickReplies": integration.config.get("quick_replies", []),
"preChat": pre_chat_payload(integration.config),
"consentText": integration.config.get("consent_text", ""),
"consentVersion": integration.config.get("consent_version", ""),
# Свои поля веб-подключения (SPEC-0019).
@@ -21,9 +21,8 @@ from chatballs.integrations.outbound import (
from chatballs.integrations.runtime import (
advance_revision_after_configuration_change,
)
from chatballs.integrations.web_config import normalized_web_config
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.appearance import normalize_appearance
from chatballs.webchat.field_schema import normalize_fields
@dataclass(frozen=True)
@@ -81,42 +80,13 @@ def _email_config(config: dict) -> dict:
}
def _web_fields(config: dict, previous_config: dict) -> list[dict]:
"""Свои поля (SPEC-0019). Форма, которая о них не знает, присылает config
без «fields» — это не «удалить все поля», а «не трогать»."""
previous = previous_config.get("fields", [])
if "fields" not in config:
return previous if isinstance(previous, list) else []
return normalize_fields(config["fields"], previous)
def _normalized_config(provider: str, config: dict, previous_config: dict | None = None) -> dict:
if not isinstance(config, dict):
raise ValidationError({"config": t("api.object_required")})
if provider == IntegrationProvider.EMAIL:
return _email_config(config)
if provider == IntegrationProvider.WEB:
appearance = normalize_appearance(config, previous_config)
allowed = config.get("allowedOrigins", config.get("allowed_domains", []))
if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed):
raise ValidationError({"config": t("settings.allowed_origins_list")})
quick_replies = config.get("quickReplies", config.get("quick_replies", []))
if not isinstance(quick_replies, list) or not all(
isinstance(item, str) for item in quick_replies
):
raise ValidationError({"config": t("settings.quick_replies_list")})
return {
"allowed_domains": [item.strip() for item in allowed if item.strip()],
"title": str(config.get("title", "")).strip(),
# Прежнее место цвета: его читают виджеты, сохранённые до appearance.
"accent": appearance["accent"],
"appearance": appearance,
"greeting": str(config.get("greeting", "")).strip(),
"quick_replies": quick_replies,
"consent_text": str(config.get("consentText", config.get("consent_text", ""))).strip(),
"consent_version": str(config.get("consentVersion", config.get("consent_version", ""))).strip(),
"fields": _web_fields(config, previous_config or {}),
}
return normalized_web_config(config, previous_config)
base_url = str(config.get("baseUrl", config.get("base_url", ""))).strip()
result: dict[str, str] = {}
# Схему проверяем на входе: без неё в base_url принимался, например,
@@ -218,6 +188,11 @@ def update_integration(
) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": t("settings.integration_other_organization")})
if integration.provider == IntegrationProvider.WEB:
# Версию согласия считаем по последней сохранённой конфигурации.
integration = Integration.objects.select_for_update().get(
pk=integration.pk, organization=context.organization
)
previous_config = integration.config
previous_secret = integration.secret
normalized_config = _normalized_config(
@@ -0,0 +1,142 @@
"""Настройки формы: PATCH, версия согласия, удаление схемы и публичная выдача."""
from django.test import TestCase
from rest_framework.test import APIClient
from chatballs.channels.models import Channel
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.serializers import integration_payload
from chatballs.integrations.services import IntegrationInput, create_integration, update_integration
from chatballs.testing import TenantAPIClient, system_tenant_context
from chatballs.webchat.testing import create_web_widget
CUSTOM = {"key": "order_id", "label": "Номер заказа", "type": "string"}
FORM = {
"enabled": True,
"title": "Представьтесь",
"fields": [{"key": "name", "required": True}, {"key": "order_id", "required": False}],
}
class PreChatConfigTests(TestCase):
def setUp(self):
self.organization = Organization.objects.create(name="Form", slug="pre-chat")
self.channel = Channel.objects.create(organization=self.organization, code="form", name="Form")
self.context = system_tenant_context(self.organization)
self.integration = create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.WEB, name="Site", channel_id=self.channel.id,
config={"allowedOrigins": ["form.example.test"]},
),
)
user = HumanUser.objects.create_user(email="form@example.test")
OrganizationMembership.objects.create(user=user, organization=self.organization, role="ADMIN")
self.client = TenantAPIClient()
self.client.force_authenticate(user=user)
def _patch(self, **config):
return self.client.patch(
f"/api/v1/integrations/{self.integration.id}/",
{"config": {"allowedOrigins": ["form.example.test"], **config}}, format="json"
)
def _config(self, **config):
response = self._patch(**config)
self.assertEqual(response.status_code, 200, response.content)
return response.json()["integration"]["config"]
def _public(self):
return APIClient().get(
"/api/v1/webchat/config/", {"widgetKey": self.integration.web_chat_widget.public_key},
HTTP_ORIGIN="https://form.example.test",
).json()
def test_default_is_disabled_for_new_and_legacy_widgets(self):
default = {"enabled": False, "title": "", "fields": []}
self.assertEqual(integration_payload(self.integration)["config"]["preChat"], default)
self.assertEqual(self._public()["preChat"], default)
legacy = create_web_widget(self.channel, name="Legacy")
self.assertEqual(integration_payload(legacy.integration)["config"]["preChat"], default)
public = APIClient().get("/api/v1/webchat/config/", {"widgetKey": legacy.public_key}).json()
self.assertEqual(public["preChat"], default)
def test_valid_form_is_saved_and_published_with_field_schema(self):
form = {**FORM, "fields": [*FORM["fields"], {"key": "email", "required": True}, {"key": "phone", "required": False}]}
config = self._config(fields=[CUSTOM], preChat=form)
self.assertEqual(config["preChat"], form)
self.integration.refresh_from_db()
self.assertEqual(self.integration.config["preChat"], form)
public = self._public()
self.assertEqual(public["preChat"], form)
self.assertEqual(public["fields"][0]["key"], "order_id")
self.assertNotIn("aiVisible", public["fields"][0])
self.assertEqual(self.integration.web_chat_widget.presentation_config["preChat"], form)
self.assertEqual(self._config(title="New title")["preChat"], form)
def test_invalid_form_is_rejected_without_saving(self):
self._config(fields=[CUSTOM], preChat=FORM)
invalid = [
None, [], {"enabled": "true"}, {"enabled": 1}, {"title": 42},
{"fields": {}}, {"fields": [None]}, {"fields": [{"key": []}]},
{"fields": [{"key": "missing", "required": False}]},
{"fields": [{"key": "name", "required": "false"}]},
{"fields": [{"key": "name"}, {"key": "name"}]},
]
for form in invalid:
with self.subTest(form=form):
self.assertEqual(self._patch(preChat=form).status_code, 400)
self.integration.refresh_from_db()
self.assertEqual(self.integration.config["preChat"], FORM)
def test_custom_field_from_another_connection_is_rejected(self):
create_integration(
context=self.context,
data=IntegrationInput(provider=IntegrationProvider.WEB, name="Other", channel_id=self.channel.id, config={"fields": [CUSTOM]}),
)
self.assertEqual(self._patch(preChat=FORM).status_code, 400)
def test_schema_deletion_removes_custom_fields_from_form(self):
expected = {**FORM, "fields": [{"key": "name", "required": True}]}
for full_config in (False, True):
with self.subTest(full_config=full_config):
self._config(fields=[CUSTOM], preChat=FORM)
config = self._config(fields=[], **({"preChat": FORM} if full_config else {}))
self.assertEqual(config["preChat"], expected)
self.assertEqual(self._public()["preChat"], expected)
self.assertEqual(self._public()["fields"], [])
# Явная ссылка на уже удалённое поле отклоняется.
self.assertEqual(self._patch(preChat=FORM).status_code, 400)
def test_consent_version_is_owned_by_server_and_changes_only_with_text(self):
initial = integration_payload(self.integration)["config"]["consentVersion"]
self.assertEqual(initial, "v1")
changed = self._config(consentText="Согласие", consentVersion="v900")
self.assertEqual(changed["consentVersion"], "v2")
unchanged = self._config(consentText=" Согласие ", consentVersion="v1")
self.assertEqual(unchanged["consentVersion"], "v2")
omitted = self._config(preChat={"enabled": False})
self.assertEqual((omitted["consentText"], omitted["consentVersion"]), ("Согласие", "v2"))
changed = self._config(consentText="Новое согласие")
self.assertEqual(changed["consentVersion"], "v3")
self.assertEqual(self._public()["consent"], {"text": "Новое согласие", "version": "v3"})
self.assertEqual(self._config(consentText="")["consentVersion"], "v4")
def test_legacy_consent_version_is_preserved_until_text_changes(self):
self.integration.config = {}
self.integration.save(update_fields=["config"])
self.assertEqual(self._config(consentText="First legacy text")["consentVersion"], "v2")
self.integration.config = {"consent_text": "Legacy", "consent_version": "release"}
self.integration.save(update_fields=["config"])
self.assertEqual(self._config(consentText="Legacy", consentVersion="v1")["consentVersion"], "release")
self.assertEqual(self._config(consentText="Changed")["consentVersion"], "release.1")
self.assertEqual(self._config(consentText="Again")["consentVersion"], "release.2")
def test_stale_service_object_does_not_reuse_consent_version(self):
for text, version in (("First", "v2"), ("Second", "v3")):
saved = update_integration(
context=self.context, integration=self.integration,
data=IntegrationInput(provider="WEB", name="Site", channel_id=self.channel.id, config={"consentText": text}),
)
self.assertEqual(saved.config["consent_version"], version)
@@ -0,0 +1,36 @@
"""Нормализация конфигурации WEB-подключения."""
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.webchat.appearance import normalize_appearance
from chatballs.webchat.field_schema import normalize_fields
from chatballs.webchat.pre_chat import normalize_consent, normalize_pre_chat
def normalized_web_config(config: dict, previous: dict | None = None) -> dict:
consent = normalize_consent(config, previous)
previous = previous or {}
appearance = normalize_appearance(config, previous)
allowed = config.get("allowedOrigins", config.get("allowed_domains", []))
if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed):
raise ValidationError({"config": t("settings.allowed_origins_list")})
quick_replies = config.get("quickReplies", config.get("quick_replies", []))
if not isinstance(quick_replies, list) or not all(isinstance(item, str) for item in quick_replies):
raise ValidationError({"config": t("settings.quick_replies_list")})
# Старые формы, не знающие о схеме, не должны её удалять.
previous_fields = previous.get("fields", [])
if not isinstance(previous_fields, list):
previous_fields = []
fields = normalize_fields(config["fields"], previous_fields) if "fields" in config else previous_fields
return {
"allowed_domains": [item.strip() for item in allowed if item.strip()],
"title": str(config.get("title", "")).strip(),
"accent": appearance["accent"],
"appearance": appearance,
"greeting": str(config.get("greeting", "")).strip(),
"quick_replies": quick_replies,
**consent,
"fields": fields,
"preChat": normalize_pre_chat(config, previous, fields),
}
+91
View File
@@ -0,0 +1,91 @@
"""Публичные API-базы и разрешение tenant-контекста виджета/сессии."""
from contextlib import contextmanager
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.integrations.models import IntegrationStatus
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import web_channel_route, web_session_route, web_widget_route
from chatballs.tenancy.lookup import load_organization
from chatballs.webchat import services
from chatballs.webchat.api_inputs import session_token
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
from chatballs.webchat.throttling import WebchatSessionTrafficThrottle, WebchatTrafficThrottle
class _Public(APIView):
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
permission_classes = [AllowAny]
class _PublicSession(_Public):
"""Публичный endpoint, работающий по токену анонимной сессии.
Два контура лимитов: по адресу клиента и по самой сессии — см.
``webchat/throttling.py``.
"""
throttle_classes = [WebchatTrafficThrottle, WebchatSessionTrafficThrottle]
@contextmanager
def _resolved_web_widget(widget_key: str, channel_code: str = ""):
route = web_widget_route(widget_key) if widget_key else web_channel_route(channel_code)
if route is None:
yield None, None
return
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
filters = (
{"id": route.resource_id, "public_key": widget_key}
if widget_key
else {"integration_id": route.resource_id}
)
widget = WebChatWidget.objects.select_related(
"integration",
"integration__channel",
).filter(
**filters,
organization=organization,
status=WebChatWidgetStatus.PUBLISHED,
integration__organization=organization,
integration__provider="WEB",
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__organization=organization,
integration__channel__is_active=True,
).first()
if widget is not None and channel_code and widget.integration.channel.code != channel_code:
widget = None
yield context, widget
@contextmanager
def _resolved_web_session(request: Request):
token = session_token(request)
route = web_session_route(services.hash_session_token(token)) if token else None
if route is None:
yield None, None
return
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
session = services.resolve_session(
context=context,
token=token,
session_id=int(route.resource_id),
)
yield context, session
@@ -0,0 +1,63 @@
"""Публичная конфигурация веб-виджета."""
from chatballs.i18n import t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.appearance import public_appearance
from chatballs.webchat.models import WebChatWidget
from chatballs.webchat.pre_chat import pre_chat_payload
from chatballs.webchat.sessions import origin_allowed
def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str) -> dict:
integration = widget.integration
# Язык отдаётся и тогда, когда виджет открыть нельзя: «Чат временно
# недоступен» — это организация говорит со своим клиентом, и говорить она
# должна на своём языке, а не на языке браузера посетителя.
language = resolve_language(
organization_language=context.organization.language,
instance_language=default_language(),
)
if integration.channel_id is None:
return {"available": False, "language": language}
if not origin_allowed(widget, origin):
return {"available": False, "reason": "domain", "language": language}
cfg = widget.presentation_config
consent = widget.consent_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": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
appearance = public_appearance(cfg)
return {
"available": True,
"widgetKey": widget.public_key,
# Язык обвязки виджета — язык организации: на нём отвечают и агент, и
# оператор, и английская кнопка «Send» вокруг русских ответов выглядела
# бы ошибкой.
"language": language,
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
"accent": appearance["accent"],
"appearance": appearance,
"greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language),
"consent": {
"text": consent.get("consent_text") or t("webchat.default_consent", language=language),
"version": consent.get("consent_version") or "v1",
},
"quickReplies": cfg.get("quick_replies") or [],
# Схема своих полей — уже без «Видит AI» (widgets.ensure_widget).
"fields": cfg.get("fields") or [],
"preChat": pre_chat_payload(integration.config),
"fallback": fallback,
}
@@ -0,0 +1,66 @@
"""Настройка формы перед чатом и серверная версия согласия (SPEC-0020)."""
import re
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.webchat.field_schema import RESERVED_KEYS
def pre_chat_payload(config: dict) -> dict:
stored = config.get("preChat", {})
return {
"enabled": stored.get("enabled", False),
"title": stored.get("title", ""),
"fields": [dict(item) for item in stored.get("fields", [])],
}
def normalize_pre_chat(config: dict, previous: dict, fields: list[dict]) -> dict:
allowed = RESERVED_KEYS | {item["key"] for item in fields}
removed = {item["key"] for item in previous.get("fields", [])} - allowed
if "preChat" not in config:
result = pre_chat_payload(previous)
result["fields"] = [item for item in result["fields"] if item["key"] in allowed]
return result
raw = config["preChat"]
if not isinstance(raw, dict):
raise ValidationError({"config": t("settings.pre_chat_invalid")})
enabled = raw.get("enabled", False)
title = raw.get("title", "")
selected = raw.get("fields", [])
if type(enabled) is not bool or not isinstance(title, str) or not isinstance(selected, list):
raise ValidationError({"config": t("settings.pre_chat_invalid")})
result = []
seen = set()
for item in selected:
if not isinstance(item, dict):
raise ValidationError({"config": t("settings.pre_chat_invalid")})
key = item.get("key")
required = item.get("required", False)
if not isinstance(key, str):
raise ValidationError({"config": t("settings.pre_chat_unknown_field")})
if type(required) is not bool or key in seen:
raise ValidationError({"config": t("settings.pre_chat_invalid")})
seen.add(key)
# Полная форма настроек может прислать прежний preChat вместе с
# удалением схемы: удалённое поле убираем и из этого списка.
if "fields" in config and key in removed:
continue
if key not in allowed:
raise ValidationError({"config": t("settings.pre_chat_unknown_field")})
result.append({"key": key, "required": required})
return {"enabled": enabled, "title": title.strip(), "fields": result}
def normalize_consent(config: dict, previous: dict | None) -> dict:
stored = previous or {}
text = str(config.get("consentText", config.get("consent_text", stored.get("consent_text", "")))).strip()
version = str(stored.get("consent_version") or "v1")
if previous is not None and text != stored.get("consent_text", ""):
# Поддерживаем прежние версии вида «v1», «1», «rev-2»; для произвольной
# старой версии начинаем числовой суффикс. Клиент версию не назначает.
match = re.fullmatch(r"(.*?)(\d+)", version)
version = f"{match[1]}{int(match[2]) + 1}" if match else f"{version}.1"
return {"consent_text": text, "consent_version": version}
+17 -198
View File
@@ -1,18 +1,8 @@
import hashlib
import secrets
import uuid
from datetime import timedelta
from urllib.parse import urlsplit
from django.conf import settings
from django.db import models, transaction
from django.utils import timezone
from chatballs.conversations.ai_turn import conversation_is_thinking
from chatballs.conversations.ingest import ingest_inbound
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
LifecycleState,
@@ -20,199 +10,28 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.conversations.transports.base import InboundMessage
from chatballs.i18n import customer_language, t
from chatballs.i18n.languages import resolve_language
from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.appearance import public_appearance
from chatballs.webchat.models import WebChatWidget, WebSession
from chatballs.webchat.configuration import public_config as public_config
from chatballs.webchat.models import WebSession
from chatballs.webchat.sessions import (
hash_session_token as hash_session_token,
)
from chatballs.webchat.sessions import (
issue_session as issue_session,
)
from chatballs.webchat.sessions import (
origin_allowed as origin_allowed,
)
from chatballs.webchat.sessions import (
resolve_session as resolve_session,
)
from chatballs.webchat.sessions import (
web_connection_for_channel as web_connection_for_channel,
)
_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"}
_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"}
def hash_session_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def web_connection_for_channel(
context: TenantContext,
channel_code: str,
) -> Integration | None:
matches = list(
Integration.objects.select_related("channel")
.filter(
provider=IntegrationProvider.WEB,
organization=context.organization,
channel__code=channel_code,
channel__organization=context.organization,
channel__is_active=True,
)
.order_by("id")[:2]
)
return matches[0] if len(matches) == 1 else None
def origin_allowed(widget: WebChatWidget, origin: str) -> bool:
allowed = widget.allowed_origins or []
if not allowed:
return bool(settings.DEBUG or settings.TESTING)
if not origin:
return False
parsed = urlsplit(origin)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
normalized_origin = f"{parsed.scheme}://{parsed.netloc.lower()}"
host = parsed.hostname.lower().rstrip(".")
for raw_rule in allowed:
rule = str(raw_rule).strip().lower().rstrip("/")
if not rule:
continue
if "://" in rule and normalized_origin == rule:
return True
if rule.startswith("*."):
suffix = rule[2:].rstrip(".")
if host != suffix and host.endswith(f".{suffix}"):
return True
elif "://" not in rule and host == rule.rstrip("."):
return True
return False
def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str) -> dict:
integration = widget.integration
# Язык отдаётся и тогда, когда виджет открыть нельзя: «Чат временно
# недоступен» — это организация говорит со своим клиентом, и говорить она
# должна на своём языке, а не на языке браузера посетителя.
language = resolve_language(
organization_language=context.organization.language,
instance_language=default_language(),
)
if integration.channel_id is None:
return {"available": False, "language": language}
if not origin_allowed(widget, origin):
return {"available": False, "reason": "domain", "language": language}
cfg = widget.presentation_config
consent = widget.consent_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": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
appearance = public_appearance(cfg)
return {
"available": True,
"widgetKey": widget.public_key,
# Язык обвязки виджета — язык организации: на нём отвечают и агент, и
# оператор, и английская кнопка «Send» вокруг русских ответов выглядела
# бы ошибкой.
"language": language,
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
"accent": appearance["accent"],
"appearance": appearance,
"greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language),
"consent": {
"text": consent.get("consent_text") or t("webchat.default_consent", language=language),
"version": consent.get("consent_version") or "v1",
},
"quickReplies": cfg.get("quick_replies") or [],
# Схема своих полей — уже без «Видит AI» (widgets.ensure_widget).
"fields": cfg.get("fields") or [],
"fallback": fallback,
}
@transaction.atomic
def issue_session(*, context: TenantContext, widget: WebChatWidget, fields: object = None) -> dict | None:
integration = widget.integration
if integration is None or integration.channel_id is None:
return None
session_id = uuid.uuid4().hex
guest_name = t(
"webchat.guest_name",
code=session_id[:6],
language=customer_language(integration.channel.organization),
)
contact = Contact.objects.create(organization=integration.channel.organization, name=guest_name)
identity = ConnectionIdentity.objects.create(
organization=context.organization,
contact=contact,
connection=integration,
external_user_id=session_id,
display_name=guest_name,
)
token = secrets.token_urlsafe(32)
session = WebSession.objects.create(
organization=context.organization,
token_hash=hash_session_token(token),
connection=integration,
widget=widget,
identity=identity,
)
if fields is not None:
from chatballs.webchat.site_fields import save_site_fields
save_site_fields(session, fields)
return {"token": token, "sessionId": session_id}
def resolve_session(
*,
context: TenantContext,
token: str,
session_id: int,
) -> WebSession | None:
if not token:
return None
session = (
WebSession.objects.select_related(
"connection",
"connection__channel",
"connection__organization",
"widget",
"identity",
"identity__contact",
)
.filter(
token_hash=hash_session_token(token),
id=session_id,
organization=context.organization,
widget__organization=context.organization,
widget__integration_id=models.F("connection_id"),
connection__organization_id=models.F("identity__contact__organization_id"),
connection__channel__organization_id=models.F("connection__organization_id"),
last_seen_at__gt=timezone.now() - _session_idle_ttl(),
)
.first()
)
if session is not None:
_touch_session(session)
return session
def _session_idle_ttl() -> timedelta:
return timedelta(seconds=settings.CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS)
# Отметку активности обновляем редко: виджет опрашивает ленту раз в 2.5 с, и
# запись на каждый опрос — это UPDATE строки сессии четыре раза в минуту на
# каждую открытую вкладку. Час загрубления на сроке в недели ничего не решает.
SESSION_TOUCH_THROTTLE = timedelta(hours=1)
def _touch_session(session: WebSession) -> None:
if timezone.now() - session.last_seen_at >= SESSION_TOUCH_THROTTLE:
# last_seen_at — auto_now, значение проставит сам Django.
session.save(update_fields=["last_seen_at"])
def post_message(session: WebSession, text: str) -> None:
inbound = InboundMessage(
external_id=uuid.uuid4().hex,
+162
View File
@@ -0,0 +1,162 @@
"""Выдача, разрешение и срок жизни анонимных сессий веб-чата."""
import hashlib
import secrets
import uuid
from datetime import timedelta
from urllib.parse import urlsplit
from django.conf import settings
from django.db import models, transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, Contact
from chatballs.i18n import customer_language, t
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.models import WebChatWidget, WebSession
def hash_session_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def web_connection_for_channel(
context: TenantContext,
channel_code: str,
) -> Integration | None:
matches = list(
Integration.objects.select_related("channel")
.filter(
provider=IntegrationProvider.WEB,
organization=context.organization,
channel__code=channel_code,
channel__organization=context.organization,
channel__is_active=True,
)
.order_by("id")[:2]
)
return matches[0] if len(matches) == 1 else None
def origin_allowed(widget: WebChatWidget, origin: str) -> bool:
allowed = widget.allowed_origins or []
if not allowed:
return bool(settings.DEBUG or settings.TESTING)
if not origin:
return False
parsed = urlsplit(origin)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return False
normalized_origin = f"{parsed.scheme}://{parsed.netloc.lower()}"
host = parsed.hostname.lower().rstrip(".")
for raw_rule in allowed:
rule = str(raw_rule).strip().lower().rstrip("/")
if not rule:
continue
if "://" in rule and normalized_origin == rule:
return True
if rule.startswith("*."):
suffix = rule[2:].rstrip(".")
if host != suffix and host.endswith(f".{suffix}"):
return True
elif "://" not in rule and host == rule.rstrip("."):
return True
return False
@transaction.atomic
def issue_session(
*, context: TenantContext, widget: WebChatWidget, fields: object = None, pre_chat_fields: object = None
) -> dict | None:
integration = widget.integration
if integration is None or integration.channel_id is None:
return None
session_id = uuid.uuid4().hex
guest_name = t(
"webchat.guest_name",
code=session_id[:6],
language=customer_language(integration.channel.organization),
)
contact = Contact.objects.create(organization=integration.channel.organization, name=guest_name)
identity = ConnectionIdentity.objects.create(
organization=context.organization,
contact=contact,
connection=integration,
external_user_id=session_id,
display_name=guest_name,
)
token = secrets.token_urlsafe(32)
session = WebSession.objects.create(
organization=context.organization,
token_hash=hash_session_token(token),
connection=integration,
widget=widget,
identity=identity,
)
if fields is not None or pre_chat_fields is not None:
from chatballs.webchat.site_fields import save_site_fields
# Форма перекрывает данные сайта до проверки: невалидное значение
# формы не должно незаметно восстанавливать прежнее значение с сайта.
values = dict(fields) if isinstance(fields, dict) else {}
if fields is not None and not isinstance(fields, dict):
save_site_fields(session, fields)
if isinstance(pre_chat_fields, dict):
values.update(pre_chat_fields)
elif pre_chat_fields is not None:
save_site_fields(session, pre_chat_fields)
save_site_fields(session, values)
return {"token": token, "sessionId": session_id}
def resolve_session(
*,
context: TenantContext,
token: str,
session_id: int,
) -> WebSession | None:
if not token:
return None
session = (
WebSession.objects.select_related(
"connection",
"connection__channel",
"connection__organization",
"widget",
"identity",
"identity__contact",
)
.filter(
token_hash=hash_session_token(token),
id=session_id,
organization=context.organization,
widget__organization=context.organization,
widget__integration_id=models.F("connection_id"),
connection__organization_id=models.F("identity__contact__organization_id"),
connection__channel__organization_id=models.F("connection__organization_id"),
last_seen_at__gt=timezone.now() - _session_idle_ttl(),
)
.first()
)
if session is not None:
_touch_session(session)
return session
def _session_idle_ttl() -> timedelta:
return timedelta(seconds=settings.CHATBALLS_WEBCHAT_SESSION_IDLE_SECONDS)
# Отметку активности обновляем редко: виджет опрашивает ленту раз в 2.5 с, и
# запись на каждый опрос — это UPDATE строки сессии четыре раза в минуту на
# каждую открытую вкладку. Час загрубления на сроке в недели ничего не решает.
SESSION_TOUCH_THROTTLE = timedelta(hours=1)
def _touch_session(session: WebSession) -> None:
if timezone.now() - session.last_seen_at >= SESSION_TOUCH_THROTTLE:
# last_seen_at — auto_now, значение проставит сам Django.
session.save(update_fields=["last_seen_at"])
@@ -0,0 +1,77 @@
"""Значения формы при старте сессии используют запись и проверки setFields."""
from django.test import TestCase
from rest_framework.test import APIClient
from chatballs.channels.models import Channel
from chatballs.conversations.models import ContactFieldValue, Conversation
from chatballs.identity.models import Organization
from chatballs.webchat.models import WebSession
from chatballs.webchat.testing import create_web_widget
class PreChatSessionTests(TestCase):
def setUp(self):
organization = Organization.objects.create(name="Form", slug="session-form")
channel = Channel.objects.create(organization=organization, code="form", name="Form")
self.widget = create_web_widget(channel)
self.widget.integration.config = {
"fields": [
{"key": "order_id", "type": "string"}, {"key": "amount", "type": "number"},
{"key": "confirmed", "type": "boolean"}, {"key": "when", "type": "datetime"},
{"key": "status", "type": "enum", "options": [{"value": "new"}]},
],
"preChat": {"enabled": True, "title": "", "fields": []},
}
self.widget.integration.save(update_fields=["config"])
self.client = APIClient()
def _session(self, **payload):
response = self.client.post(
"/api/v1/webchat/session/", {"widgetKey": self.widget.public_key, **payload}, format="json"
)
self.assertEqual(response.status_code, 201, response.content)
return WebSession.objects.latest("id")
def _values(self, session):
return dict(ContactFieldValue.objects.filter(contact=session.identity.contact).values_list("key", "value"))
def test_form_values_override_site_values_and_write_contact_and_custom_fields(self):
session = self._session(
fields={"name": "Site", "email": "site@example.test", "order_id": "site", "amount": 12},
preChatFields={"name": "Client", "email": "client@example.test", "phone": "+7 (999) 123-45-67", "order_id": "form", "confirmed": False, "when": "2026-09-30T12:30", "status": "new"},
)
contact = session.identity.contact
contact.refresh_from_db()
self.assertEqual((contact.name, contact.email, contact.phone), ("Client", "client@example.test", "+79991234567"))
self.assertEqual(self._values(session), {
"name": "Client", "email": "client@example.test", "phone": "+79991234567",
"order_id": "form", "amount": 12, "confirmed": False, "when": "2026-09-30T12:30", "status": "new",
})
self.assertFalse(Conversation.objects.filter(contact=contact).exists())
def test_null_from_form_overrides_site_values(self):
session = self._session(fields={"name": "Site", "order_id": "site"}, preChatFields={"name": None, "order_id": None})
contact = session.identity.contact
contact.refresh_from_db()
self.assertEqual(contact.name, "")
self.assertEqual(self._values(session), {})
def test_invalid_form_values_use_same_validation_and_never_fall_back_to_site(self):
valid = {"email": "site@example.test", "phone": "+79991234567", "order_id": "site", "amount": 10, "confirmed": True, "status": "new", "when": "2026-09-30T12:30"}
invalid = {"email": "SECRET", "phone": "SECRET", "order_id": "SECRET" * 101, "amount": "SECRET", "confirmed": "SECRET", "status": "SECRET", "when": "SECRET", "unknown": "SECRET"}
with self.assertLogs("chatballs.webchat.site_fields", level="WARNING") as logs:
session = self._session(fields=valid, preChatFields=invalid)
self.assertEqual(self._values(session), {})
contact = session.identity.contact
contact.refresh_from_db()
self.assertEqual((contact.email, contact.phone), ("", ""))
self.assertNotIn("SECRET", " ".join(logs.output))
def test_invalid_payload_is_ignored_without_losing_valid_site_fields(self):
with self.assertLogs("chatballs.webchat.site_fields", level="WARNING"):
session = self._session(fields={"name": "Site"}, preChatFields=["SECRET"])
self.assertEqual(self._values(session), {"name": "Site"})
with self.assertLogs("chatballs.webchat.site_fields", level="WARNING"):
session = self._session(fields=["SECRET"], preChatFields={"name": "Form"})
self.assertEqual(self._values(session), {"name": "Form"})
+11 -90
View File
@@ -1,12 +1,8 @@
from contextlib import contextmanager
from django.http import FileResponse, HttpResponse
from django.views import View
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
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 chatballs.conversations.attachment_views import (
attachment_response,
@@ -16,101 +12,23 @@ from chatballs.conversations.models import Message, MessageKind
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
from chatballs.i18n import t
from chatballs.integrations.features import voice_messages_allowed
from chatballs.integrations.models import IntegrationStatus
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import (
web_channel_route,
web_session_route,
web_widget_route,
)
from chatballs.tenancy.lookup import load_organization
from chatballs.webchat import services
from chatballs.webchat.api_inputs import host_origin, session_token
from chatballs.webchat.access import (
_Public,
_PublicSession,
_resolved_web_session,
_resolved_web_widget,
)
from chatballs.webchat.api_inputs import host_origin
from chatballs.webchat.loader import LOADER_JS
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
from chatballs.webchat.throttling import (
WebchatConfigThrottle,
WebchatSessionIssueThrottle,
WebchatSessionTrafficThrottle,
WebchatTrafficThrottle,
)
LOADER_MAX_AGE_SECONDS = 300
class _Public(APIView):
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
permission_classes = [AllowAny]
class _PublicSession(_Public):
"""Публичный endpoint, работающий по токену анонимной сессии.
Два контура лимитов: по адресу клиента и по самой сессии — см.
``webchat/throttling.py``.
"""
throttle_classes = [WebchatTrafficThrottle, WebchatSessionTrafficThrottle]
@contextmanager
def _resolved_web_widget(widget_key: str, channel_code: str = ""):
route = web_widget_route(widget_key) if widget_key else web_channel_route(channel_code)
if route is None:
yield None, None
return
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
filters = (
{"id": route.resource_id, "public_key": widget_key}
if widget_key
else {"integration_id": route.resource_id}
)
widget = WebChatWidget.objects.select_related(
"integration",
"integration__channel",
).filter(
**filters,
organization=organization,
status=WebChatWidgetStatus.PUBLISHED,
integration__organization=organization,
integration__provider="WEB",
integration__status=IntegrationStatus.OK,
integration__is_active=True,
integration__channel__organization=organization,
integration__channel__is_active=True,
).first()
if widget is not None and channel_code and widget.integration.channel.code != channel_code:
widget = None
yield context, widget
@contextmanager
def _resolved_web_session(request: Request):
token = session_token(request)
route = web_session_route(services.hash_session_token(token)) if token else None
if route is None:
yield None, None
return
organization = load_organization(route.organization_id)
if organization is None:
yield None, None
return
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
session = services.resolve_session(
context=context,
token=token,
session_id=int(route.resource_id),
)
yield context, session
class WebchatConfigView(_Public):
throttle_classes = [WebchatConfigThrottle]
@@ -148,7 +66,10 @@ class WebchatSessionView(_Public):
or not services.origin_allowed(widget, host_origin(request))
):
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
result = services.issue_session(context=context, widget=widget, fields=request.data.get("fields"))
result = services.issue_session(
context=context, widget=widget, fields=request.data.get("fields"),
pre_chat_fields=request.data.get("preChatFields"),
)
if result is None:
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
return Response(result, status=201)
@@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.webchat.field_schema import public_fields
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
from chatballs.webchat.pre_chat import pre_chat_payload
def widget_for_integration(integration: Integration) -> WebChatWidget | None:
@@ -43,6 +44,7 @@ def ensure_widget(integration: Integration) -> WebChatWidget | None:
for key in ("title", "accent", "greeting", "quick_replies", "appearance")
if config.get(key) not in (None, "", [], {})
}
presentation["preChat"] = pre_chat_payload(config)
fields = public_fields(config.get("fields", []))
if fields:
presentation["fields"] = fields