✨ feat(webchat): сохранять данные сайта в контакте

This commit is contained in:
Andrey committed 2026-09-30 09:34:11 +03:00
1 parent dc6e38b689
commit c3ea8395d0
15 files changed
+474 -9

No files matched your search

@@ -0,0 +1,40 @@
---
id: T-010
title: Хранение значений полей и приём данных с сайта на сервере
milestone: M02
status: done
depends_on:
- T-009
order: 2
spec: "0019"
created: 2026-09-29
branch: skaro/T-010-hranenie-znacheniy-poley-i-pri
---
## Цель
Появляется таблица значений полей и сервис, который валидирует данные с сайта и пишет их в контакт и в значения; данные принимают старт сессии и новый endpoint (R-9, R-10, R-11, R-15, ADR-0030).
## Критерии приёмки
- [x] Модель contact_field_values (организация, контакт, подключение, ключ, значение jsonb, updated_at) с уникальностью по контакт+подключение+ключ, под RLS; тест изоляции под runtime-ролью проходит
- [x] POST /api/v1/webchat/fields/ с токеном сессии и POST /webchat/session с fields сохраняют валидные значения; null очищает значение
- [x] Неизвестные ключи и неверные типы отбрасываются без ошибки для сайта, в журнал пишется предупреждение без значения; enum принимает только value из options, строки обрезаются/отклоняются свыше 500 символов
- [x] name/email/phone пишутся в контакт только если там пусто или прошлое значение пришло с этого же подключения — ручная правка оператора не затирается (тест)
- [x] Endpoint под теми же throttle, что остальные публичные по сессии
## Заметки
webchat/services.py issue_session, webchat/views.py, urls.py. Источник встроенного поля нужно где-то помнить (например, отметка в значениях с тем же ключом) — решить при реализации, не ломая модель Contact без нужды.
## Итог
Задача T-010 выполнена в ветке, коммит `2df2c17c`. Skaro отметил все пять критериев приёмки и показал карточку слияния.
1. Таблица значений и RLS: миграции применились; тест под runtime-ролью подтвердил изоляцию организаций.
2. Старт сессии и `POST /api/v1/webchat/fields/`: тесты подтвердили сохранение значений и очистку через `null`.
3. Валидация: неизвестные ключи, неверные типы, значения enum вне списка и слишком длинные строки отбрасываются; предупреждения не содержат значений.
4. Ручные правки: тесты подтвердили, что сайт не затирает изменённые оператором имя, email и телефон.
5. Ограничения запросов: тест подтвердил требование токена и ответ `429` при исчерпании лимита записи.
Адресные тесты, проверка миграций, Django check и Ruff прошли.
@@ -17,6 +17,7 @@ from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.conversations.models import (
ContactFieldValue,
ControlMode,
Conversation,
ConversationLabel,
@@ -124,6 +125,7 @@ class ConversationContactView(ConversationViewBase):
changed.append(field)
if changed:
contact.save(update_fields=changed)
ContactFieldValue.objects.filter(contact=contact, key__in=changed).delete()
self._audit(request, "contact_updated", conversation)
return Response(
{
@@ -164,7 +164,7 @@ def client_row(contact: Contact) -> dict:
"isGuest": not contact.name,
"phone": contact.phone,
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
"email": next(
"email": contact.email or next(
(
identity.external_user_id
for identity in contact.identities.all()
@@ -308,7 +308,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
"description": contact.description,
"company": contact.company,
"city": contact.city,
"email": next(
"email": contact.email or next(
(
identity.external_user_id
for identity in identity_qs
@@ -12,13 +12,18 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ContactMerge,
Conversation,
)
from chatballs.i18n import t
from chatballs.identity.audit import record_audit_event
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
# они пустые. Что именно заполнили — запоминаем, чтобы очистить при разъединении.
CARD_FIELDS = ("name", "phone", "avatar_url", "description", "company", "city")
CARD_FIELDS = ("name", "email", "phone", "avatar_url", "description", "company", "city")
MIN_REASON_LENGTH = 5
@@ -0,0 +1,33 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("conversations", "0026_message_ai_turn_state"),
("integrations", "0010_integration_runtime_revision"),
]
operations = [
migrations.AddField(
model_name="contact",
name="email",
field=models.EmailField(blank=True, default="", max_length=254),
),
migrations.CreateModel(
name="ContactFieldValue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key", models.CharField(max_length=40)),
("value", models.JSONField()),
("updated_at", models.DateTimeField(auto_now=True)),
("contact", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="site_field_values", to="conversations.contact")),
("integration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="contact_field_values", to="integrations.integration")),
("organization", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="identity.organization")),
],
options={
"db_table": "contact_field_values",
"constraints": [models.UniqueConstraint(fields=("contact", "integration", "key"), name="uniq_contact_integration_field_key")],
},
),
]
@@ -22,6 +22,7 @@ def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
class Contact(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
name = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, default="")
# Телефон приходит только через явный шаринг контакта (кнопка в TG/MAX,
# форма в веб-чате) — автоматически мессенджеры его не отдают.
phone = models.CharField(max_length=32, blank=True)
@@ -65,6 +66,27 @@ class Contact(models.Model):
return self.name or f"contact:{self.id}"
class ContactFieldValue(TenantRelationModel):
"""Последнее значение с сайта для контакта и WEB-подключения."""
tenant_relation_fields = ("contact", "integration")
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="site_field_values")
integration = models.ForeignKey(
"integrations.Integration", on_delete=models.CASCADE, related_name="contact_field_values"
)
key = models.CharField(max_length=40)
value = models.JSONField()
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "contact_field_values"
constraints = [
models.UniqueConstraint(
fields=["contact", "integration", "key"], name="uniq_contact_integration_field_key"
),
]
class ContactMerge(models.Model):
"""Журнал объединения контактов (ADR-CHATBALLS-0006).
@@ -6,7 +6,7 @@ from rest_framework.response import Response
from chatballs.api.pagination import page_payload, paginate
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
from chatballs.conversations.models import Contact
from chatballs.conversations.models import Contact, ContactFieldValue
from chatballs.conversations.stats import sales_overview_stats
from chatballs.conversations.view_base import ConversationViewBase
from chatballs.i18n import t
@@ -87,6 +87,7 @@ class ClientDetailView(ConversationViewBase):
changed.append(field)
if changed:
contact.save(update_fields=changed)
ContactFieldValue.objects.filter(contact=contact, key__in=changed).delete()
record_audit_event(
action="conversation.contact_updated",
actor=request.user,
@@ -182,6 +182,8 @@ def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
def _contact_email(conversation: Conversation) -> str:
if conversation.contact.email:
return conversation.contact.email
if (
conversation.connection_id
and conversation.connection.provider == IntegrationProvider.EMAIL
@@ -0,0 +1,33 @@
from chatballs.conversations.models import ContactFieldValue
from chatballs.conversations.test_chat_extras import ChatExtrasTestCase
from chatballs.webchat.testing import create_web_widget
class SiteFieldManualEditTests(ChatExtrasTestCase):
def test_dialog_edit_clears_site_provenance_even_if_value_is_same(self) -> None:
widget = create_web_widget(self.channel)
contact = self.conversation.contact
ContactFieldValue.objects.create(
organization=self.organization, contact=contact, integration=widget.integration,
key="name", value=contact.name,
)
response = self.client.post(
f"/api/v1/conversations/{self.conversation.id}/contact/",
{"name": contact.name}, format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="name").exists())
def test_client_edit_clears_site_provenance(self) -> None:
widget = create_web_widget(self.channel)
contact = self.conversation.contact
ContactFieldValue.objects.create(
organization=self.organization, contact=contact, integration=widget.integration,
key="phone", value="+79991234567",
)
response = self.admin_client.patch(
f"/api/v1/conversations/clients/{contact.id}/",
{"phone": "+79991234567"}, format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="phone").exists())
@@ -0,0 +1,51 @@
from django.db import migrations
TABLE = "contact_field_values"
FORWARD_SQL = f"""
ALTER TABLE {TABLE} OWNER TO chatballs_schema;
ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {TABLE} FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {TABLE} TO chatballs_runtime_app;
GRANT ALL ON TABLE {TABLE} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {TABLE}_id_seq TO chatballs_runtime_app, chatballs_schema;
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
CREATE POLICY chatballs_tenant_isolation ON {TABLE}
FOR ALL TO chatballs_runtime_app
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
CREATE POLICY chatballs_schema_access ON {TABLE}
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
CREATE CONSTRAINT TRIGGER contact_field_value_contact
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_fk('conversations_contact', 'contact_id');
CREATE CONSTRAINT TRIGGER contact_field_value_integration
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_fk('integrations_integration', 'integration_id');
"""
REVERSE_SQL = f"""
DROP TRIGGER IF EXISTS contact_field_value_integration ON {TABLE};
DROP TRIGGER IF EXISTS contact_field_value_contact ON {TABLE};
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
ALTER TABLE {TABLE} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} DISABLE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {TABLE} FROM chatballs_runtime_app;
REVOKE USAGE, SELECT ON SEQUENCE {TABLE}_id_seq FROM chatballs_runtime_app;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0038_widget_asset_guards"),
("conversations", "0027_contact_email_contactfieldvalue"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
+6 -2
View File
@@ -130,7 +130,7 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
@transaction.atomic
def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | None:
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
@@ -149,13 +149,17 @@ def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | No
display_name=guest_name,
)
token = secrets.token_urlsafe(32)
WebSession.objects.create(
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}
@@ -0,0 +1,120 @@
"""Проверка и сохранение недоверенных значений, присланных сайтом."""
import logging
import math
from datetime import datetime
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator, validate_email
from django.db import transaction
from chatballs.conversations.models import Contact, ContactFieldValue
from chatballs.webchat.field_schema import RESERVED_KEYS
from chatballs.webchat.services import normalize_phone
logger = logging.getLogger(__name__)
MAX_STRING_LENGTH = 500
BUILTIN_TYPES = {"name": "string", "email": "email", "phone": "phone"}
def _valid_value(value: object, field_type: str) -> object:
if value is None:
return None
if field_type == "boolean":
if type(value) is bool:
return value
raise ValueError("type")
if field_type == "number":
if type(value) is int and value.bit_length() <= 1024:
return value
if type(value) is float and math.isfinite(value):
return value
raise ValueError("type")
if not isinstance(value, str):
raise ValueError("type")
if len(value) > MAX_STRING_LENGTH:
raise ValueError("length")
if field_type == "datetime":
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("format") from exc
elif field_type == "email":
try:
validate_email(value)
except ValidationError as exc:
raise ValueError("format") from exc
elif field_type == "phone":
phone = normalize_phone(value)
if not phone:
raise ValueError("format")
return phone
elif field_type == "url":
try:
URLValidator(schemes=["http", "https"])(value)
except ValidationError as exc:
raise ValueError("format") from exc
return value
def _schema(integration) -> dict[str, dict]:
raw = integration.config.get("fields", [])
fields = {item["key"]: item for item in raw if isinstance(item, dict) and item.get("key")}
return {**{key: {"type": kind} for key, kind in BUILTIN_TYPES.items()}, **fields}
def _apply_builtin(contact: Contact, key: str, value: object, previous: ContactFieldValue | None, session) -> bool:
current = getattr(contact, key)
old_site_value = previous.value if previous else None
guest_name = key == "name" and current == session.identity.display_name and session.identity.external_user_id[:6] in current
if current and not guest_name and (previous is None or current != old_site_value):
return False
if value is not None and len(value) > Contact._meta.get_field(key).max_length:
raise ValueError("length")
setattr(contact, key, value or "")
contact.save(update_fields=[key])
return True
@transaction.atomic
def save_site_fields(session, fields: object) -> None:
"""Частичное обновление; плохие ключи/типы не прерывают приём."""
if not isinstance(fields, dict):
logger.warning("webchat fields ignored: invalid payload")
return
contact = Contact.objects.select_for_update().get(
id=session.identity.contact_id, organization_id=session.organization_id
)
schema = _schema(session.connection)
for key, raw_value in fields.items():
definition = schema.get(key) if isinstance(key, str) else None
if definition is None:
logger.warning("webchat field ignored: unknown key")
continue
try:
value = _valid_value(raw_value, definition["type"])
if definition["type"] == "enum" and value is not None:
if value not in {option["value"] for option in definition.get("options", [])}:
raise ValueError("option")
previous = ContactFieldValue.objects.filter(
contact=contact, integration=session.connection, key=key
).first()
if key in RESERVED_KEYS and not _apply_builtin(contact, key, value, previous, session):
continue
except ValueError as exc:
logger.warning("webchat field ignored: %s", exc)
continue
if value is None:
if previous:
previous.delete()
elif previous:
previous.value = value
previous.save(update_fields=["value", "updated_at"])
else:
ContactFieldValue.objects.create(
organization_id=session.organization_id,
contact=contact,
integration=session.connection,
key=key,
value=value,
)
@@ -0,0 +1,137 @@
from django.core.cache import cache
from django.db import connection, transaction
from django.test import TestCase, TransactionTestCase
from rest_framework.test import APIClient
from rest_framework.throttling import SimpleRateThrottle
from chatballs.channels.models import Channel
from chatballs.conversations.models import Contact, ContactFieldValue
from chatballs.identity.models import Organization
from chatballs.tenancy.database import tenant_atomic
from chatballs.webchat.models import WebSession
from chatballs.webchat.testing import create_web_widget
FIELDS = [
{"key": "order_status", "type": "enum", "options": [{"value": "cooking", "label": "Готовится"}]},
{"key": "has_order", "type": "boolean"},
{"key": "user_id", "type": "string"},
{"key": "amount", "type": "number"},
]
class SiteFieldApiTests(TestCase):
def setUp(self):
self.organization = Organization.objects.create(name="Site fields", slug="site-fields")
channel = Channel.objects.create(organization=self.organization, code="site-fields", name="Site")
self.widget = create_web_widget(channel)
self.widget.integration.config = {"fields": FIELDS}
self.widget.integration.save(update_fields=["config"])
self.client = APIClient()
def _session(self, fields=None):
payload = {"widgetKey": self.widget.public_key}
if fields is not None:
payload["fields"] = fields
response = self.client.post("/api/v1/webchat/session/", payload, format="json")
self.assertEqual(response.status_code, 201, response.content)
return response.json()["token"], WebSession.objects.latest("id")
def _fields(self, token, fields, **kwargs):
return self.client.post(
"/api/v1/webchat/fields/", {"fields": fields}, format="json",
headers={"Authorization": f"Bearer {token}"}, **kwargs,
)
def test_initial_and_incremental_values_with_null_clear(self):
token, session = self._session({"name": "Иван", "email": "ivan@example.test", "order_status": "cooking"})
contact = session.identity.contact
contact.refresh_from_db()
self.assertEqual((contact.name, contact.email), ("Иван", "ivan@example.test"))
self.assertEqual(ContactFieldValue.objects.get(contact=contact, key="order_status").value, "cooking")
self.assertEqual(self._fields(token, {"has_order": True, "order_status": None}).status_code, 200)
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="order_status").exists())
self.assertIs(ContactFieldValue.objects.get(contact=contact, key="has_order").value, True)
self.assertEqual(self._fields(token, {"name": None, "email": None}).status_code, 200)
contact.refresh_from_db()
self.assertEqual((contact.name, contact.email), ("", ""))
def test_invalid_values_are_ignored_and_not_logged(self):
token, session = self._session()
with self.assertLogs("chatballs.webchat.site_fields", level="WARNING") as logs:
response = self._fields(token, {
"unknown": "SECRET", "has_order": "SECRET", "order_status": "SECRET",
"user_id": "SECRET" * 101,
"amount": 10 ** 400,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(ContactFieldValue.objects.filter(contact=session.identity.contact).exists())
self.assertNotIn("SECRET", " ".join(logs.output))
def test_operator_edits_are_preserved(self):
token, session = self._session({"name": "Иван", "email": "ivan@example.test", "phone": "+79991234567"})
contact = session.identity.contact
Contact.objects.filter(pk=contact.pk).update(
name="Оператор", email="manual@example.test", phone="+79990000000"
)
self.assertEqual(self._fields(token, {"name": "Пётр", "email": "new@example.test", "phone": "+79991111111"}).status_code, 200)
contact.refresh_from_db()
self.assertEqual((contact.name, contact.email, contact.phone), ("Оператор", "manual@example.test", "+79990000000"))
def test_site_fields_do_not_identify_or_merge_visitors(self):
fields = {"email": "same@example.test", "phone": "+79991234567", "user_id": "same"}
_, first = self._session(fields)
_, second = self._session(fields)
self.assertNotEqual(first.identity.contact_id, second.identity.contact_id)
self.assertEqual(Contact.objects.filter(organization=self.organization).count(), 2)
def test_fields_endpoint_requires_session_and_uses_write_throttle(self):
self.assertEqual(self.client.post("/api/v1/webchat/fields/", {"fields": {}}, format="json").status_code, 401)
token, _ = self._session()
original = SimpleRateThrottle.THROTTLE_RATES
SimpleRateThrottle.THROTTLE_RATES = {**original, "webchat_session_write": "1/min"}
cache.clear()
try:
self.assertEqual(self._fields(token, {}).status_code, 200)
self.assertEqual(self._fields(token, {}, REMOTE_ADDR="203.0.113.2").status_code, 429)
finally:
SimpleRateThrottle.THROTTLE_RATES = original
cache.clear()
class SiteFieldRuntimeRoleTests(TransactionTestCase):
def test_values_are_tenant_scoped_under_app_role(self):
first = Organization.objects.create(name="First", slug="site-fields-first")
second = Organization.objects.create(name="Second", slug="site-fields-second")
first_contact = None
first_integration = None
for organization in (first, second):
channel = Channel.objects.create(organization=organization, code="site", name="Site")
widget = create_web_widget(channel)
contact = Contact.objects.create(organization=organization, name="Visitor")
ContactFieldValue.objects.create(
organization=organization, contact=contact, integration=widget.integration,
key="user_id", value="private",
)
if organization == first:
first_contact = contact
first_integration = widget.integration
with connection.cursor() as cursor:
cursor.execute("SET ROLE chatballs_runtime_app")
try:
with tenant_atomic(first.id):
self.assertEqual(ContactFieldValue.objects.count(), 1)
self.assertEqual(ContactFieldValue.objects.get().organization_id, first.id)
self.assertEqual(ContactFieldValue.objects.filter(organization=second).update(value="leak"), 0)
ContactFieldValue.objects.create(
organization=first, contact=first_contact, integration=first_integration,
key="has_order", value=True,
)
self.assertEqual(ContactFieldValue.objects.count(), 2)
with tenant_atomic(second.id):
self.assertEqual(ContactFieldValue.objects.count(), 1)
self.assertEqual(ContactFieldValue.objects.get().organization_id, second.id)
with transaction.atomic():
self.assertEqual(ContactFieldValue.objects.count(), 0)
finally:
with connection.cursor() as cursor:
cursor.execute("RESET ROLE")
+1
View File
@@ -7,6 +7,7 @@ urlpatterns = [
path("assets/<uuid:public_id>/", WidgetAssetView.as_view(), name="webchat-asset"),
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
path("fields/", views.WebchatFieldsView.as_view(), name="webchat-fields"),
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
path("messages/<int:message_id>/audio/", views.WebchatMessageAudioView.as_view(), name="webchat-message-audio"),
path("messages/<int:message_id>/attachment/", views.WebchatMessageAttachmentView.as_view(), name="webchat-message-attachment"),
+16 -2
View File
@@ -148,12 +148,23 @@ 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)
result = services.issue_session(context=context, widget=widget, fields=request.data.get("fields"))
if result is None:
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
return Response(result, status=201)
class WebchatFieldsView(_PublicSession):
def post(self, request: Request) -> Response:
with _resolved_web_session(request) as (_context, session):
if session is None:
return Response({"detail": t("webchat.session_not_found")}, status=401)
from chatballs.webchat.site_fields import save_site_fields
save_site_fields(session, request.data.get("fields"))
return Response({"ok": True})
class WebchatMessagesView(_PublicSession):
# JSON — текст, multipart — голосовое из записи в виджете.
parser_classes = [JSONParser, MultiPartParser, FormParser]
@@ -275,7 +286,10 @@ class WebchatContactView(_PublicSession):
class WebchatCallOpenView(_PublicSession):
def post(self, request: Request) -> Response:
from chatballs.calls.errors import CallTokenError
from chatballs.calls.serializers import ice_servers_payload, public_invite_payload
from chatballs.calls.serializers import (
ice_servers_payload,
public_invite_payload,
)
from chatballs.calls.services import open_call_for_identity
with _resolved_web_session(request) as (_context, session):