mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(webchat): настроить форму перед чатом на сервере
This commit is contained in:
1 parent
6e36bdc725
commit
416e37e441
15 files changed
+708
-320
No files matched your search
@@ -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),
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user