diff --git a/.skaro/tasks/T-016-nastroyka-formy-pered-chatom-na-servere.md b/.skaro/tasks/T-016-nastroyka-formy-pered-chatom-na-servere.md new file mode 100644 index 0000000..fc09e29 --- /dev/null +++ b/.skaro/tasks/T-016-nastroyka-formy-pered-chatom-na-servere.md @@ -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. diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index e72ee32..013cdb4 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -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", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index defab7a..6bfcf6c 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -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 символов", diff --git a/apps/backend/chatballs/integrations/serializers.py b/apps/backend/chatballs/integrations/serializers.py index eafacb7..b5375b2 100644 --- a/apps/backend/chatballs/integrations/serializers.py +++ b/apps/backend/chatballs/integrations/serializers.py @@ -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). diff --git a/apps/backend/chatballs/integrations/services.py b/apps/backend/chatballs/integrations/services.py index e1c91df..32967b6 100644 --- a/apps/backend/chatballs/integrations/services.py +++ b/apps/backend/chatballs/integrations/services.py @@ -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( diff --git a/apps/backend/chatballs/integrations/test_pre_chat.py b/apps/backend/chatballs/integrations/test_pre_chat.py new file mode 100644 index 0000000..8e73d04 --- /dev/null +++ b/apps/backend/chatballs/integrations/test_pre_chat.py @@ -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) diff --git a/apps/backend/chatballs/integrations/web_config.py b/apps/backend/chatballs/integrations/web_config.py new file mode 100644 index 0000000..667722e --- /dev/null +++ b/apps/backend/chatballs/integrations/web_config.py @@ -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), + } diff --git a/apps/backend/chatballs/webchat/access.py b/apps/backend/chatballs/webchat/access.py new file mode 100644 index 0000000..b117118 --- /dev/null +++ b/apps/backend/chatballs/webchat/access.py @@ -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 + + diff --git a/apps/backend/chatballs/webchat/configuration.py b/apps/backend/chatballs/webchat/configuration.py new file mode 100644 index 0000000..3d92d86 --- /dev/null +++ b/apps/backend/chatballs/webchat/configuration.py @@ -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, + } + + diff --git a/apps/backend/chatballs/webchat/pre_chat.py b/apps/backend/chatballs/webchat/pre_chat.py new file mode 100644 index 0000000..6ab5eb7 --- /dev/null +++ b/apps/backend/chatballs/webchat/pre_chat.py @@ -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} diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 66fe07d..31a2daf 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -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, diff --git a/apps/backend/chatballs/webchat/sessions.py b/apps/backend/chatballs/webchat/sessions.py new file mode 100644 index 0000000..efc589a --- /dev/null +++ b/apps/backend/chatballs/webchat/sessions.py @@ -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"]) + + diff --git a/apps/backend/chatballs/webchat/test_pre_chat.py b/apps/backend/chatballs/webchat/test_pre_chat.py new file mode 100644 index 0000000..6e4ed52 --- /dev/null +++ b/apps/backend/chatballs/webchat/test_pre_chat.py @@ -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"}) diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index ebb1dc4..ae6427c 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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) diff --git a/apps/backend/chatballs/webchat/widgets.py b/apps/backend/chatballs/webchat/widgets.py index b2462b9..f73514c 100644 --- a/apps/backend/chatballs/webchat/widgets.py +++ b/apps/backend/chatballs/webchat/widgets.py @@ -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