From a940dcc2e3fac67b1c512211d49510b3411f9bc8 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 30 Sep 2026 18:59:34 +0300 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat(webchat):=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=B0=D0=B9=D1=82=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sobytie-v-lente-obnovlenie-po-websocket.md | 32 ++ .../chatballs/conversations/client_common.py | 24 ++ .../chatballs/conversations/client_details.py | 212 ++++++++++++ .../chatballs/conversations/clients.py | 230 +------------ .../0028_site_fields_updated_event.py | 18 ++ .../backend/chatballs/conversations/models.py | 30 +- .../conversations/serializer_context.py | 80 +++++ .../chatballs/conversations/serializers.py | 98 +----- .../chatballs/conversations/signals.py | 5 +- .../chatballs/conversations/site_fields.py | 46 +++ .../chatballs/conversations/system_events.py | 31 ++ .../chatballs/conversations/test_realtime.py | 43 ++- apps/backend/chatballs/i18n/messages/en.py | 4 + apps/backend/chatballs/i18n/messages/ru.py | 4 + .../chatballs/webchat/message_history.py | 101 ++++++ apps/backend/chatballs/webchat/services.py | 91 +----- .../chatballs/webchat/site_field_events.py | 45 +++ apps/backend/chatballs/webchat/site_fields.py | 9 + .../webchat/test_site_field_events.py | 154 +++++++++ .../src/features/conversations/apiTypes.ts | 104 ++++++ .../features/conversations/conversationApi.ts | 198 ++++++++++++ .../src/features/conversations/model.ts | 302 +----------------- .../useConversationEvents.test.tsx | 38 +++ .../conversations/useConversationEvents.ts | 5 +- packages/contracts/src/index.ts | 10 + 25 files changed, 1184 insertions(+), 730 deletions(-) create mode 100644 .skaro/tasks/T-011-sobytie-v-lente-obnovlenie-po-websocket.md create mode 100644 apps/backend/chatballs/conversations/client_common.py create mode 100644 apps/backend/chatballs/conversations/client_details.py create mode 100644 apps/backend/chatballs/conversations/migrations/0028_site_fields_updated_event.py create mode 100644 apps/backend/chatballs/conversations/serializer_context.py create mode 100644 apps/backend/chatballs/conversations/site_fields.py create mode 100644 apps/backend/chatballs/conversations/system_events.py create mode 100644 apps/backend/chatballs/webchat/message_history.py create mode 100644 apps/backend/chatballs/webchat/site_field_events.py create mode 100644 apps/backend/chatballs/webchat/test_site_field_events.py create mode 100644 apps/internal-ui/src/features/conversations/apiTypes.ts create mode 100644 apps/internal-ui/src/features/conversations/conversationApi.ts create mode 100644 apps/internal-ui/src/features/conversations/useConversationEvents.test.tsx diff --git a/.skaro/tasks/T-011-sobytie-v-lente-obnovlenie-po-websocket.md b/.skaro/tasks/T-011-sobytie-v-lente-obnovlenie-po-websocket.md new file mode 100644 index 0000000..e3ee3d3 --- /dev/null +++ b/.skaro/tasks/T-011-sobytie-v-lente-obnovlenie-po-websocket.md @@ -0,0 +1,32 @@ +--- +id: T-011 +title: Событие в ленте, обновление по WebSocket и siteFields в API +milestone: M02 +status: done +depends_on: + - T-010 +order: 3 +spec: "0019" +created: 2026-09-29 +branch: skaro/T-011-sobytie-v-lente-obnovlenie-po +--- + +## Цель + +Изменение значения видно оператору сразу: системное событие в ленте открытого диалога, событие по WebSocket и поле siteFields в API диалога и контакта (R-12, R-13, R-16). + +## Критерии приёмки + +- [x] Новый код SystemEvent для обновления данных сайта; параметры — подпись, старое и новое отображаемое значение; фразу собирает бэкенд через t() на языке читателя, ключи есть в ru и en +- [x] Событие пишется только для полей enum и boolean и только при фактическом изменении значения при открытом диалоге +- [x] Оператор получает событие по WebSocket, useConversationEvents обновляет карточку и ленту без перезагрузки +- [x] API диалога и контакта отдаёт siteFields [{key,label,type,value,display,color?,updatedAt}] в порядке схемы, без удалённых полей +- [x] Тесты событий, realtime и каталога i18n проходят (только затронутые) + +## Заметки + +conversations/models.py SystemEvent (+миграция choices), serializers.py _system_text, features/conversations/useConversationEvents.ts. + +## Итог + +Ветка T-011 перенесена через git rebase main на 416e37e4; конфликт webchat/services.py разрешён с сохранением sessions/configuration и preChatFields из main, а история сообщений подключена из message_history. Переписанные коммиты 3ba1b606 и d7ea4a97 сохранены, рабочее дерево чистое. Повторные адресные проверки после разрешения конфликта прошли. diff --git a/apps/backend/chatballs/conversations/client_common.py b/apps/backend/chatballs/conversations/client_common.py new file mode 100644 index 0000000..8052763 --- /dev/null +++ b/apps/backend/chatballs/conversations/client_common.py @@ -0,0 +1,24 @@ +from chatballs.conversations.models import ControlMode, Conversation, LifecycleState + +# Короткие коды для UI (совпадают с фронтовыми справочниками). +PROVIDER_CODE = { + "MAX": "MAX", + "TELEGRAM": "TG", + "VK": "VK", + "WEB": "WEB", + "EMAIL": "EMAIL", +} + + +def _actor_name(user) -> str: + return (user.full_name or user.email) if user is not None else "" + + +def _mode(latest: Conversation) -> str: + if latest.lifecycle != LifecycleState.OPEN: + return "closed" + if latest.control_mode == ControlMode.HUMAN: + return "operator" + if latest.control_mode == ControlMode.AI: + return "ai" + return "wait" diff --git a/apps/backend/chatballs/conversations/client_details.py b/apps/backend/chatballs/conversations/client_details.py new file mode 100644 index 0000000..eeda196 --- /dev/null +++ b/apps/backend/chatballs/conversations/client_details.py @@ -0,0 +1,212 @@ +"""Подробная карточка контакта и связанная история.""" + +from django.db.models import Q + +from chatballs.conversations.client_common import PROVIDER_CODE, _actor_name, _mode +from chatballs.conversations.contact_avatars import contact_avatar_url_in +from chatballs.conversations.models import Contact, ContactMerge, Conversation, LifecycleState +from chatballs.conversations.site_fields import site_fields_payload +from chatballs.i18n import t +from chatballs.identity.audit_catalog import ( + audit_action_label, + audit_object_label, + audit_result_label, +) +from chatballs.identity.avatars import user_avatar_url_in +from chatballs.identity.models import AuditEvent + + +def _dialog_status(conversation: Conversation) -> str: + return { + "closed": t("conversations.dialog_status_closed"), + "operator": t("conversations.dialog_status_operator"), + "ai": "AI", + "wait": t("conversations.dialog_status_wait"), + }[_mode(conversation)] + + +def client_detail(organization_id: int, contact_id: int) -> dict: + contact = Contact.objects.get(organization_id=organization_id, id=contact_id) + conversation_qs = Conversation.objects.filter( + organization_id=organization_id, contact=contact + ).select_related("channel", "connection", "group", "assigned_operator", "note_author") + conversations = list(conversation_qs.order_by("-last_activity_at")) + if not conversations: + raise Contact.DoesNotExist + + channels: set[str] = set() + open_dialogs = 0 + dialogs: list[dict] = [] + for conversation in conversations: + provider = conversation.connection.provider if conversation.connection_id else None + if provider in PROVIDER_CODE: + channels.add(PROVIDER_CODE[provider]) + if conversation.lifecycle == LifecycleState.OPEN: + open_dialogs += 1 + # Тема диалога — первое сообщение, превью — последнее (кадр K4). + first = conversation.messages.order_by("created_at").first() + last = conversation.messages.order_by("-created_at").first() + title = (first.text.replace("\n", " ")[:80] if first and first.text else conversation.channel.name) + preview = (last.text.replace("\n", " ")[:120] if last and last.text else "") + dialogs.append( + { + "id": conversation.id, + "title": title, + "preview": preview, + "channelName": conversation.channel.name, + "agentName": conversation.channel.name, + "agentId": conversation.channel_id, + "agentCode": conversation.channel.code, + "groupName": conversation.group.name if conversation.group_id else "", + "groupColor": conversation.group.color if conversation.group_id else "", + "assignee": _actor_name(conversation.assigned_operator), + "assigneeAvatarUrl": user_avatar_url_in(conversation.assigned_operator, organization_id), + "note": conversation.note, + "noteAuthor": _actor_name(conversation.note_author), + "noteUpdatedAt": conversation.note_updated_at.isoformat() if conversation.note_updated_at else None, + "provider": provider, + "mode": _mode(conversation), + "status": _dialog_status(conversation), + "active": conversation.lifecycle == LifecycleState.OPEN, + "lastActivityAt": conversation.last_activity_at.isoformat(), + } + ) + + identity_qs = contact.identities.select_related("connection") + identities = [ + { + "provider": identity.connection.provider, + "value": ( + identity.external_user_id + if identity.connection.provider == "EMAIL" + else identity.display_name or identity.external_user_id + ), + "externalUserId": identity.external_user_id, + "username": identity.username, + "createdAt": identity.created_at.isoformat(), + # Подтверждённой считается идентичность, отдавшая телефон (ADR-CHATBALLS-0006). + "phoneVerifiedAt": identity.phone_verified_at.isoformat() if identity.phone_verified_at else None, + } + for identity in identity_qs.order_by("created_at") + ] + + # Активность из жизненного цикла диалогов (created/closed) — реальные события. + activity: list[dict] = [] + for conversation in conversations: + activity.append({"type": "created", "title": t("conversations.activity_started", channel=conversation.channel.name), "at": conversation.created_at.isoformat()}) + if conversation.lifecycle == LifecycleState.CLOSED: + activity.append({"type": "closed", "title": t("conversations.activity_closed", channel=conversation.channel.name), "at": conversation.last_activity_at.isoformat()}) + activity.sort(key=lambda item: item["at"], reverse=True) + + conversation_ids = [str(conversation.id) for conversation in conversations] + audit = [] + audit_scope = Q(object_type="Conversation", object_id__in=conversation_ids) + audit_scope |= Q(object_type="Contact", object_id=str(contact_id)) + audit_qs = ( + AuditEvent.objects.filter(organization_id=organization_id) + .filter(audit_scope) + .select_related("actor") + .order_by("-created_at")[:20] + ) + for event in audit_qs: + audit.append( + { + "time": event.created_at.isoformat(), + # Подписи, типы объектов и результаты — из общего каталога + # журнала действий: коды действий и enum-значения на экран + # карточки не попадают. Пустая подпись означает «её ещё нет», + # тогда показываем код — как в журнале. + "action": audit_action_label(event.action) or event.action, + "object": audit_object_label(event.object_type, event.object_id), + "actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"), + "result": audit_result_label(event.result), + } + ) + + return { + "id": contact.id, + "cid": f"CUS-{contact.id}", + "name": contact.name or t("conversations.guest"), + # Признак анонимного посетителя: интерфейс красит его аватар иначе. + # Раньше он выводился из самой подписи регуляркой по слову «Гость» — + # на другом языке это перестало бы работать. + "isGuest": not contact.name, + "phone": contact.phone, + "avatarUrl": contact_avatar_url_in(contact, contact.organization_id), + # Поля карточки из чата (описание, компания, город). + "description": contact.description, + "company": contact.company, + "city": contact.city, + "email": contact.email or next( + ( + identity.external_user_id + for identity in identity_qs + if identity.connection.provider == "EMAIL" + ), + "", + ), + "siteFields": site_fields_payload(contact), + "channels": sorted(channels), + "openDialogs": open_dialogs, + "totalDialogs": len(conversations), + "firstContactAt": contact.created_at.isoformat(), + "lastActivityAt": conversations[0].last_activity_at.isoformat() if conversations else contact.created_at.isoformat(), + "dialogs": dialogs, + "identities": identities, + "activity": activity[:8], + "audit": audit, + "duplicate": _duplicate_candidate(organization_id, contact), + "merges": _merges(organization_id, contact), + } + + +def _merges(organization_id: int, contact: Contact) -> list[dict]: + """Действующие объединения этого контакта — их можно разъединить.""" + rows = ( + ContactMerge.objects.filter(organization_id=organization_id, target=contact, reverted_at__isnull=True) + .select_related("source", "actor") + .order_by("-created_at") + ) + return [ + { + "id": row.id, + "sourceId": row.source_id, + "sourceName": row.source.name or t("conversations.guest"), + "sourceCid": f"CUS-{row.source_id}", + "reason": row.reason, + "actor": _actor_name(row.actor), + "at": row.created_at.isoformat(), + "identities": len(row.moved_identity_ids), + "conversations": len(row.moved_conversation_ids), + } + for row in rows + ] + + +def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None: + """Другой контакт с тем же телефоном. Автоматически ничего не объединяем + (ADR-CHATBALLS-0006) — это только предложение владельцу.""" + if not contact.phone: + return None + other = ( + Contact.objects.filter(organization_id=organization_id, phone=contact.phone, merged_into__isnull=True) + .exclude(id=contact.id) + .prefetch_related("identities__connection", "conversations") + .first() + ) + if other is None: + return None + identities = list(other.identities.all()) + return { + "id": other.id, + "cid": f"CUS-{other.id}", + "name": other.name or t("conversations.guest"), + "isGuest": not other.name, + "avatarUrl": contact_avatar_url_in(other, other.organization_id), + "dialogs": other.conversations.count(), + "sources": sorted({identity.connection.provider for identity in identities}), + "phone": other.phone, + # Однозначным совпадение считается, только если телефон подтверждён + # подключением хотя бы у одной стороны (ADR-CHATBALLS-0006). + "phoneVerified": any(identity.phone_verified_at is not None for identity in identities), + } diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 05c7b08..9bdde70 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -18,47 +18,16 @@ from django.db.models import ( ) from django.db.models.functions import Coalesce +from chatballs.conversations.client_common import PROVIDER_CODE, _actor_name, _mode +from chatballs.conversations.client_details import client_detail as client_detail from chatballs.conversations.contact_avatars import contact_avatar_url_in from chatballs.conversations.models import ( ConnectionIdentity, Contact, - ContactMerge, - ControlMode, Conversation, LifecycleState, ) from chatballs.i18n import t -from chatballs.identity.audit_catalog import ( - audit_action_label, - audit_object_label, - audit_result_label, -) -from chatballs.identity.avatars import user_avatar_url_in -from chatballs.identity.models import AuditEvent - -# Короткие коды для UI (совпадают с фронтовыми справочниками). -PROVIDER_CODE = { - "MAX": "MAX", - "TELEGRAM": "TG", - "VK": "VK", - "WEB": "WEB", - "EMAIL": "EMAIL", -} - - -def _actor_name(user) -> str: - return (user.full_name or user.email) if user is not None else "" - - -def _mode(latest: Conversation) -> str: - if latest.lifecycle != LifecycleState.OPEN: - return "closed" - if latest.control_mode == ControlMode.HUMAN: - return "operator" - if latest.control_mode == ControlMode.AI: - return "ai" - return "wait" - # Провайдер подключения по короткому коду канала из фильтра списка (кадр K1). PROVIDER_BY_CODE = {code: provider for provider, code in PROVIDER_CODE.items()} @@ -185,198 +154,3 @@ def client_row(contact: Contact) -> dict: "lastAssignee": _actor_name(latest.assigned_operator), "agents": sorted(agents.values(), key=lambda item: str(item["name"])), } - - -def _dialog_status(conversation: Conversation) -> str: - return { - "closed": t("conversations.dialog_status_closed"), - "operator": t("conversations.dialog_status_operator"), - "ai": "AI", - "wait": t("conversations.dialog_status_wait"), - }[_mode(conversation)] - - -def client_detail(organization_id: int, contact_id: int) -> dict: - contact = Contact.objects.get(organization_id=organization_id, id=contact_id) - conversation_qs = Conversation.objects.filter( - organization_id=organization_id, contact=contact - ).select_related("channel", "connection", "group", "assigned_operator", "note_author") - conversations = list(conversation_qs.order_by("-last_activity_at")) - if not conversations: - raise Contact.DoesNotExist - - channels: set[str] = set() - open_dialogs = 0 - dialogs: list[dict] = [] - for conversation in conversations: - provider = conversation.connection.provider if conversation.connection_id else None - if provider in PROVIDER_CODE: - channels.add(PROVIDER_CODE[provider]) - if conversation.lifecycle == LifecycleState.OPEN: - open_dialogs += 1 - # Тема диалога — первое сообщение, превью — последнее (кадр K4). - first = conversation.messages.order_by("created_at").first() - last = conversation.messages.order_by("-created_at").first() - title = (first.text.replace("\n", " ")[:80] if first and first.text else conversation.channel.name) - preview = (last.text.replace("\n", " ")[:120] if last and last.text else "") - dialogs.append( - { - "id": conversation.id, - "title": title, - "preview": preview, - "channelName": conversation.channel.name, - "agentName": conversation.channel.name, - "agentId": conversation.channel_id, - "agentCode": conversation.channel.code, - "groupName": conversation.group.name if conversation.group_id else "", - "groupColor": conversation.group.color if conversation.group_id else "", - "assignee": _actor_name(conversation.assigned_operator), - "assigneeAvatarUrl": user_avatar_url_in(conversation.assigned_operator, organization_id), - "note": conversation.note, - "noteAuthor": _actor_name(conversation.note_author), - "noteUpdatedAt": conversation.note_updated_at.isoformat() if conversation.note_updated_at else None, - "provider": provider, - "mode": _mode(conversation), - "status": _dialog_status(conversation), - "active": conversation.lifecycle == LifecycleState.OPEN, - "lastActivityAt": conversation.last_activity_at.isoformat(), - } - ) - - identity_qs = contact.identities.select_related("connection") - identities = [ - { - "provider": identity.connection.provider, - "value": ( - identity.external_user_id - if identity.connection.provider == "EMAIL" - else identity.display_name or identity.external_user_id - ), - "externalUserId": identity.external_user_id, - "username": identity.username, - "createdAt": identity.created_at.isoformat(), - # Подтверждённой считается идентичность, отдавшая телефон (ADR-CHATBALLS-0006). - "phoneVerifiedAt": identity.phone_verified_at.isoformat() if identity.phone_verified_at else None, - } - for identity in identity_qs.order_by("created_at") - ] - - # Активность из жизненного цикла диалогов (created/closed) — реальные события. - activity: list[dict] = [] - for conversation in conversations: - activity.append({"type": "created", "title": t("conversations.activity_started", channel=conversation.channel.name), "at": conversation.created_at.isoformat()}) - if conversation.lifecycle == LifecycleState.CLOSED: - activity.append({"type": "closed", "title": t("conversations.activity_closed", channel=conversation.channel.name), "at": conversation.last_activity_at.isoformat()}) - activity.sort(key=lambda item: item["at"], reverse=True) - - conversation_ids = [str(conversation.id) for conversation in conversations] - audit = [] - audit_scope = Q(object_type="Conversation", object_id__in=conversation_ids) - audit_scope |= Q(object_type="Contact", object_id=str(contact_id)) - audit_qs = ( - AuditEvent.objects.filter(organization_id=organization_id) - .filter(audit_scope) - .select_related("actor") - .order_by("-created_at")[:20] - ) - for event in audit_qs: - audit.append( - { - "time": event.created_at.isoformat(), - # Подписи, типы объектов и результаты — из общего каталога - # журнала действий: коды действий и enum-значения на экран - # карточки не попадают. Пустая подпись означает «её ещё нет», - # тогда показываем код — как в журнале. - "action": audit_action_label(event.action) or event.action, - "object": audit_object_label(event.object_type, event.object_id), - "actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"), - "result": audit_result_label(event.result), - } - ) - - return { - "id": contact.id, - "cid": f"CUS-{contact.id}", - "name": contact.name or t("conversations.guest"), - # Признак анонимного посетителя: интерфейс красит его аватар иначе. - # Раньше он выводился из самой подписи регуляркой по слову «Гость» — - # на другом языке это перестало бы работать. - "isGuest": not contact.name, - "phone": contact.phone, - "avatarUrl": contact_avatar_url_in(contact, contact.organization_id), - # Поля карточки из чата (описание, компания, город). - "description": contact.description, - "company": contact.company, - "city": contact.city, - "email": contact.email or next( - ( - identity.external_user_id - for identity in identity_qs - if identity.connection.provider == "EMAIL" - ), - "", - ), - "channels": sorted(channels), - "openDialogs": open_dialogs, - "totalDialogs": len(conversations), - "firstContactAt": contact.created_at.isoformat(), - "lastActivityAt": conversations[0].last_activity_at.isoformat() if conversations else contact.created_at.isoformat(), - "dialogs": dialogs, - "identities": identities, - "activity": activity[:8], - "audit": audit, - "duplicate": _duplicate_candidate(organization_id, contact), - "merges": _merges(organization_id, contact), - } - - -def _merges(organization_id: int, contact: Contact) -> list[dict]: - """Действующие объединения этого контакта — их можно разъединить.""" - rows = ( - ContactMerge.objects.filter(organization_id=organization_id, target=contact, reverted_at__isnull=True) - .select_related("source", "actor") - .order_by("-created_at") - ) - return [ - { - "id": row.id, - "sourceId": row.source_id, - "sourceName": row.source.name or t("conversations.guest"), - "sourceCid": f"CUS-{row.source_id}", - "reason": row.reason, - "actor": _actor_name(row.actor), - "at": row.created_at.isoformat(), - "identities": len(row.moved_identity_ids), - "conversations": len(row.moved_conversation_ids), - } - for row in rows - ] - - -def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None: - """Другой контакт с тем же телефоном. Автоматически ничего не объединяем - (ADR-CHATBALLS-0006) — это только предложение владельцу.""" - if not contact.phone: - return None - other = ( - Contact.objects.filter(organization_id=organization_id, phone=contact.phone, merged_into__isnull=True) - .exclude(id=contact.id) - .prefetch_related("identities__connection", "conversations") - .first() - ) - if other is None: - return None - identities = list(other.identities.all()) - return { - "id": other.id, - "cid": f"CUS-{other.id}", - "name": other.name or t("conversations.guest"), - "isGuest": not other.name, - "avatarUrl": contact_avatar_url_in(other, other.organization_id), - "dialogs": other.conversations.count(), - "sources": sorted({identity.connection.provider for identity in identities}), - "phone": other.phone, - # Однозначным совпадение считается, только если телефон подтверждён - # подключением хотя бы у одной стороны (ADR-CHATBALLS-0006). - "phoneVerified": any(identity.phone_verified_at is not None for identity in identities), - } diff --git a/apps/backend/chatballs/conversations/migrations/0028_site_fields_updated_event.py b/apps/backend/chatballs/conversations/migrations/0028_site_fields_updated_event.py new file mode 100644 index 0000000..853ef4a --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0028_site_fields_updated_event.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-30 14:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0027_contact_email_contactfieldvalue'), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='system_event', + field=models.CharField(blank=True, choices=[('operator_took', 'Оператор перехватил диалог'), ('returned_to_ai', 'Диалог возвращён AI'), ('returned_to_queue', 'Диалог возвращён в очередь'), ('ai_unavailable', 'AI недоступен'), ('ai_handed_over', 'AI передал диалог оператору'), ('assigned_to', 'Диалог назначен сотруднику'), ('assignment_expired', 'Назначение истекло'), ('site_fields_updated', 'site_fields_updated'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index cff7bff..211f77e 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -4,6 +4,7 @@ from django.contrib.postgres.search import SearchVector from django.db import models from django.utils import timezone +from chatballs.conversations.system_events import SystemEvent as SystemEvent from chatballs.i18n import t from chatballs.tenancy.models import TenantRelationModel @@ -309,35 +310,6 @@ class MessageAuthor(models.TextChoices): SYSTEM = "SYSTEM", t("admin.actor_system") -class SystemEvent(models.TextChoices): - """Код системного события диалога. - - Текст события раньше писался в ``text`` по-русски и оставался таким - навсегда: история — записи, а не подписи, и перевести её задним числом - нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того, - кто её читает. ``text`` продолжает заполняться: он остаётся и запасным - вариантом для строк, записанных до этого поля, и тем, что видно в базе - глазами. - """ - - OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог" - RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI" - RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь" - AI_UNAVAILABLE = "ai_unavailable", "AI недоступен" - AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору" - ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику" - ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло" - CALL_REQUESTED = "call_requested", "Запрошен звонок" - CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение" - CALL_DECLINED = "call_declined", "Клиент отклонил приглашение" - CALL_CANCELLED = "call_cancelled", "Приглашение отменено" - CALL_MISSED = "call_missed", "Звонок пропущен" - CALL_EXPIRED = "call_expired", "Приглашение истекло" - CALL_STARTED = "call_started", "Звонок начался" - CALL_ENDED = "call_ended", "Звонок завершён" - CALL_FAILED = "call_failed", "Звонок не состоялся" - - class MessageKind(models.TextChoices): TEXT = "", "Текст" CONTACT_REQUEST = "contact_request", "Запрос контакта" diff --git a/apps/backend/chatballs/conversations/serializer_context.py b/apps/backend/chatballs/conversations/serializer_context.py new file mode 100644 index 0000000..5b6f07a --- /dev/null +++ b/apps/backend/chatballs/conversations/serializer_context.py @@ -0,0 +1,80 @@ +from chatballs.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor +from chatballs.integrations.models import IntegrationProvider + + +def _history_item(conversation: Conversation) -> dict[str, object]: + last = _last_message(conversation) + # Тема карточки истории (кадр F) — первая реплика клиента; кто вёл — ответственный или AI. + first = ( + conversation.messages.filter(author_type=MessageAuthor.CONTACT) + .order_by("created_at", "id") + .values_list("text", flat=True) + .first() + ) + operator = conversation.assigned_operator + return { + "id": conversation.id, + "channelName": conversation.channel.name, + "provider": conversation.connection.provider if conversation.connection_id else None, + "lifecycle": conversation.lifecycle, + "createdAt": conversation.created_at.isoformat(), + "lastActivityAt": conversation.last_activity_at.isoformat(), + "topic": (first or "").replace("\n", " ")[:80], + "handledBy": (operator.full_name or operator.email) if operator else None, + "preview": last.text.replace("\n", " ")[:80] if last else "", + } + + +def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None: + # Username и подпись гостя живут на identity подключения (у контакта их + # может быть несколько). Только в detail-режиме — в списках это лишний + # запрос на каждый диалог. + if not conversation.connection_id: + return None + return ConnectionIdentity.objects.filter( + connection_id=conversation.connection_id, contact_id=conversation.contact_id + ).first() + + +def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool: + # Гость виджета получает имя «Гость · <код сессии>» на языке организации; + # та же подпись записана в display_name его identity. Пока имя не сменили, + # настоящего имени у контакта нет — подставлять его в ответ нельзя. + if not contact.name: + return True + return bool( + identity + and identity.display_name == contact.name + and identity.external_user_id[:6] in contact.name + ) + + +def _contact_email(conversation: Conversation) -> str: + if conversation.contact.email: + return conversation.contact.email + if ( + conversation.connection_id + and conversation.connection.provider == IntegrationProvider.EMAIL + ): + return conversation.external_chat_id + return "" + + +def _conversation_history(conversation: Conversation) -> list[Conversation]: + # Цепочка прошлых обращений того же контакта (ADR-CHATBALLS-0002). + qs = Conversation.objects.filter(contact_id=conversation.contact_id) + return list( + qs.exclude(id=conversation.id) + .select_related("channel", "connection", "assigned_operator") + .order_by("-last_activity_at")[:10] + ) + +def _last_message(conversation: Conversation) -> Message | None: + # Превью строки списка — последняя реплика клиента/AI/сотрудника; системные + # события («AI передал диалог») в превью не показываются (дизайн-базлайн v2, B). + return ( + conversation.messages.exclude(author_type=MessageAuthor.SYSTEM) + .select_related("author_user") + .order_by("-created_at", "-id") + .first() + ) diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index f6b6bdd..bedd788 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -2,16 +2,24 @@ from django.db.models import Count, Max, Q from chatballs.conversations.contact_avatars import contact_avatar_url_in from chatballs.conversations.models import ( - ConnectionIdentity, Conversation, Message, MessageAuthor, MessageKind, + SystemEvent, ) +from chatballs.conversations.serializer_context import ( + _connection_identity, + _contact_email, + _contact_is_guest, + _conversation_history, + _history_item, + _last_message, +) +from chatballs.conversations.site_fields import field_display, site_fields_payload from chatballs.i18n import t from chatballs.identity.avatars import user_avatar_url_in from chatballs.integrations.features import features_payload -from chatballs.integrations.models import IntegrationProvider def _system_text(message: Message) -> str: @@ -19,6 +27,9 @@ def _system_text(message: Message) -> str: # Вид звонка приходит кодом (AUDIO/VIDEO): слово для него — тоже в каталоге. if params.get("kind"): params = {**params, "kind": t(f"calls.kind_{str(params['kind']).lower()}")} + if message.system_event == SystemEvent.SITE_FIELDS_UPDATED: + definition = {"type": params["fieldType"]} + params = {**params, **{key: field_display(definition, params.get(key)) for key in ("old", "new")}} rendered = t(f"conversations.system.{message.system_event}", **params) # Ключа нет в каталоге — t вернул сам ключ; тогда честнее показать то, что # записано, чем служебный код. @@ -92,6 +103,7 @@ def pending_counts_for(conversation_ids: list[int], read_map: dict[int, int]) -> answered = dict( Message.objects.filter(conversation_id__in=conversation_ids) .exclude(author_type=MessageAuthor.CONTACT) + .exclude(system_event=SystemEvent.SITE_FIELDS_UPDATED) .values("conversation_id") .annotate(last_id=Max("id")) .values_list("conversation_id", "last_id") @@ -110,23 +122,12 @@ def pending_counts_for(conversation_ids: list[int], read_map: dict[int, int]) -> return dict(counts) -def _last_message(conversation: Conversation) -> Message | None: - # Превью строки списка — последняя реплика клиента/AI/сотрудника; системные - # события («AI передал диалог») в превью не показываются (дизайн-базлайн v2, B). - return ( - conversation.messages.exclude(author_type=MessageAuthor.SYSTEM) - .select_related("author_user") - .order_by("-created_at", "-id") - .first() - ) - - def _pending_count(conversation: Conversation, last_read_id: int = 0) -> int: # Бейдж непрочитанных: хвост клиентских сообщений (после последнего ответа # AI/оператора), которые просматривающий ещё не открывал (id > отметки # прочтения). Открытие диалога двигает отметку — бейдж гаснет. count = 0 - for message in conversation.messages.order_by("-created_at")[:50]: + for message in conversation.messages.exclude(system_event=SystemEvent.SITE_FIELDS_UPDATED).order_by("-created_at")[:50]: if message.author_type != MessageAuthor.CONTACT: break if message.id > last_read_id: @@ -134,74 +135,6 @@ def _pending_count(conversation: Conversation, last_read_id: int = 0) -> int: return count -def _history_item(conversation: Conversation) -> dict[str, object]: - last = _last_message(conversation) - # Тема карточки истории (кадр F) — первая реплика клиента; кто вёл — ответственный или AI. - first = ( - conversation.messages.filter(author_type=MessageAuthor.CONTACT) - .order_by("created_at", "id") - .values_list("text", flat=True) - .first() - ) - operator = conversation.assigned_operator - return { - "id": conversation.id, - "channelName": conversation.channel.name, - "provider": conversation.connection.provider if conversation.connection_id else None, - "lifecycle": conversation.lifecycle, - "createdAt": conversation.created_at.isoformat(), - "lastActivityAt": conversation.last_activity_at.isoformat(), - "topic": (first or "").replace("\n", " ")[:80], - "handledBy": (operator.full_name or operator.email) if operator else None, - "preview": last.text.replace("\n", " ")[:80] if last else "", - } - - -def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None: - # Username и подпись гостя живут на identity подключения (у контакта их - # может быть несколько). Только в detail-режиме — в списках это лишний - # запрос на каждый диалог. - if not conversation.connection_id: - return None - return ConnectionIdentity.objects.filter( - connection_id=conversation.connection_id, contact_id=conversation.contact_id - ).first() - - -def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool: - # Гость виджета получает имя «Гость · <код сессии>» на языке организации; - # та же подпись записана в display_name его identity. Пока имя не сменили, - # настоящего имени у контакта нет — подставлять его в ответ нельзя. - if not contact.name: - return True - return bool( - identity - and identity.display_name == contact.name - and identity.external_user_id[:6] in contact.name - ) - - -def _contact_email(conversation: Conversation) -> str: - if conversation.contact.email: - return conversation.contact.email - if ( - conversation.connection_id - and conversation.connection.provider == IntegrationProvider.EMAIL - ): - return conversation.external_chat_id - return "" - - -def _conversation_history(conversation: Conversation) -> list[Conversation]: - # Цепочка прошлых обращений того же контакта (ADR-CHATBALLS-0002). - qs = Conversation.objects.filter(contact_id=conversation.contact_id) - return list( - qs.exclude(id=conversation.id) - .select_related("channel", "connection", "assigned_operator") - .order_by("-last_activity_at")[:10] - ) - - def conversation_payload( conversation: Conversation, *, @@ -308,6 +241,7 @@ def conversation_payload( "createdAt": conversation.created_at.isoformat(), } if detailed: + payload["siteFields"] = site_fields_payload(conversation.contact, integration_id=conversation.connection_id) if conversation.contact_id else [] history = _conversation_history(conversation) payload["history"] = [_history_item(c) for c in history] # Запрос контакта мог уйти когда угодно — в загруженном окне истории его diff --git a/apps/backend/chatballs/conversations/signals.py b/apps/backend/chatballs/conversations/signals.py index dbe4588..88cea8d 100644 --- a/apps/backend/chatballs/conversations/signals.py +++ b/apps/backend/chatballs/conversations/signals.py @@ -15,7 +15,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from chatballs.conversations.models import Conversation, Message +from chatballs.conversations.models import Conversation, Message, SystemEvent from chatballs.conversations.realtime import ( notify_conversation_changed, notify_inbox_changed, @@ -38,7 +38,8 @@ def touch_last_message_at(sender, instance: Message, created: bool, **kwargs) -> @receiver(post_save, sender=Message, dispatch_uid="conversations.notify_message") def notify_message(sender, instance: Message, created: bool, **kwargs) -> None: - if not created: + # Данные сайта публикуются одним событием после commit всей пачки полей. + if not created or instance.system_event == SystemEvent.SITE_FIELDS_UPDATED: return notify_conversation_changed( instance.conversation_id, organization_id=instance.organization_id diff --git a/apps/backend/chatballs/conversations/site_fields.py b/apps/backend/chatballs/conversations/site_fields.py new file mode 100644 index 0000000..6fd8b02 --- /dev/null +++ b/apps/backend/chatballs/conversations/site_fields.py @@ -0,0 +1,46 @@ +"""Отображение значений сайта по действующей схеме подключения.""" + +from chatballs.conversations.models import ContactFieldValue +from chatballs.i18n import t +from chatballs.integrations.models import IntegrationProvider + + +def field_display(definition: dict, value: object) -> str: + if value is None: + return t("conversations.site_fields.empty") + if definition["type"] == "boolean": + return t("conversations.site_fields.yes" if value else "conversations.site_fields.no") + if definition["type"] == "enum": + option = next((item for item in definition.get("options", []) if item["value"] == value), None) + return option["label"] if option else str(value) + return str(value) + + +def site_fields_payload(contact, *, integration_id: int | None = None) -> list[dict]: + rows = ContactFieldValue.objects.filter( + organization_id=contact.organization_id, contact_id=contact.id, + integration__provider=IntegrationProvider.WEB, + ).select_related("integration").order_by("integration_id", "id") + if integration_id is not None: + rows = rows.filter(integration_id=integration_id) + integrations = {} + for row in rows: + integration, values = integrations.setdefault(row.integration_id, (row.integration, {})) + values[row.key] = row + payload = [] + for integration, values in integrations.values(): + for definition in sorted(integration.config.get("fields", []), key=lambda item: item.get("order", 0)): + row = values.get(definition["key"]) + if row is None: + continue + item = { + "key": row.key, "label": definition["label"], "type": definition["type"], + "value": row.value, "display": field_display(definition, row.value), + "updatedAt": row.updated_at.isoformat(), + } + if definition["type"] == "enum": + option = next((option for option in definition.get("options", []) if option["value"] == row.value), None) + if option and option.get("color"): + item["color"] = option["color"] + payload.append(item) + return payload diff --git a/apps/backend/chatballs/conversations/system_events.py b/apps/backend/chatballs/conversations/system_events.py new file mode 100644 index 0000000..f2bb9c3 --- /dev/null +++ b/apps/backend/chatballs/conversations/system_events.py @@ -0,0 +1,31 @@ +from django.db import models + + +class SystemEvent(models.TextChoices): + """Код системного события диалога. + + Текст события раньше писался в ``text`` по-русски и оставался таким + навсегда: история — записи, а не подписи, и перевести её задним числом + нельзя. Поэтому в базу идёт код, а фразу собирает интерфейс на языке того, + кто её читает. ``text`` продолжает заполняться: он остаётся и запасным + вариантом для строк, записанных до этого поля, и тем, что видно в базе + глазами. + """ + + OPERATOR_TOOK = "operator_took", "Оператор перехватил диалог" + RETURNED_TO_AI = "returned_to_ai", "Диалог возвращён AI" + RETURNED_TO_QUEUE = "returned_to_queue", "Диалог возвращён в очередь" + AI_UNAVAILABLE = "ai_unavailable", "AI недоступен" + AI_HANDED_OVER = "ai_handed_over", "AI передал диалог оператору" + ASSIGNED_TO = "assigned_to", "Диалог назначен сотруднику" + ASSIGNMENT_EXPIRED = "assignment_expired", "Назначение истекло" + SITE_FIELDS_UPDATED = "site_fields_updated", "site_fields_updated" + CALL_REQUESTED = "call_requested", "Запрошен звонок" + CALL_ACCEPTED = "call_accepted", "Клиент принял приглашение" + CALL_DECLINED = "call_declined", "Клиент отклонил приглашение" + CALL_CANCELLED = "call_cancelled", "Приглашение отменено" + CALL_MISSED = "call_missed", "Звонок пропущен" + CALL_EXPIRED = "call_expired", "Приглашение истекло" + CALL_STARTED = "call_started", "Звонок начался" + CALL_ENDED = "call_ended", "Звонок завершён" + CALL_FAILED = "call_failed", "Звонок не состоялся" diff --git a/apps/backend/chatballs/conversations/test_realtime.py b/apps/backend/chatballs/conversations/test_realtime.py index 692d427..2d7637d 100644 --- a/apps/backend/chatballs/conversations/test_realtime.py +++ b/apps/backend/chatballs/conversations/test_realtime.py @@ -54,7 +54,7 @@ class ConversationEventsTests(TransactionTestCase): async def _connect(self, user) -> WebsocketCommunicator: communicator = self._communicator(user) - connected, _ = await communicator.connect() + connected, _ = await communicator.connect(timeout=5) self.assertTrue(connected) return communicator @@ -96,6 +96,47 @@ class ConversationEventsTests(TransactionTestCase): self.assertTrue(await communicator.receive_nothing(timeout=1)) await communicator.disconnect() + async def test_site_fields_reach_socket_with_and_without_feed_event(self) -> None: + from channels.db import database_sync_to_async + + from chatballs.conversations.models import ConnectionIdentity + from chatballs.webchat.models import WebSession + from chatballs.webchat.site_fields import save_site_fields + from chatballs.webchat.testing import create_web_widget + + @database_sync_to_async + def prepare(): + widget = create_web_widget(self.channel) + integration = widget.integration + integration.config = {"fields": [ + {"key": "active", "label": "Active", "type": "boolean"}, + {"key": "number", "label": "Number", "type": "string"}, + ]} + integration.save(update_fields=["config"]) + identity = ConnectionIdentity.objects.create( + organization=self.organization, contact=self.conversation.contact, + connection=integration, external_user_id="site-visitor", + ) + return WebSession.objects.create( + organization=self.organization, connection=integration, widget=widget, + identity=identity, token_hash="site-fields-test", + ) + + session = await prepare() + communicator = await self._connect(self.owner) + await communicator.send_json_to({"type": "watch", "conversationId": self.conversation.id}) + await communicator.receive_json_from(timeout=5) + for fields in ({"active": True}, {"number": "10482"}): + await database_sync_to_async(save_site_fields)(session, fields) + events = [await communicator.receive_json_from(timeout=5) for _ in range(2)] + self.assertCountEqual(events, [ + {"type": "conversation.changed", "conversationId": self.conversation.id}, + {"type": "inbox.changed"}, + ]) + await database_sync_to_async(save_site_fields)(session, {"number": "10482"}) + self.assertTrue(await communicator.receive_nothing(timeout=1)) + await communicator.disconnect() + async def test_watch_is_refused_for_a_dialog_outside_visibility(self) -> None: employee, hidden = await self._create_employee_and_hidden_dialog() communicator = await self._connect(employee) diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 013cdb4..b7e8271 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -69,6 +69,10 @@ MESSAGES: dict[str, object] = { "conversations.system.call_missed": "Call missed: the customer did not answer", "conversations.system.call_requested": "{operator} requested {kind}", "conversations.system.call_started": "The call started: the connection is up", + "conversations.system.site_fields_updated": "The website updated data: {label} “{old}” → “{new}”", + "conversations.site_fields.yes": "Yes", + "conversations.site_fields.no": "No", + "conversations.site_fields.empty": "—", "conversations.system.operator_took": "{operator} took over the conversation", "conversations.system.returned_to_ai": "The conversation was handed back to AI", "conversations.system.returned_to_queue": "The conversation was returned to the queue", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 6bfcf6c..32dafc4 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -73,6 +73,10 @@ MESSAGES: dict[str, object] = { "conversations.system.call_missed": "Звонок пропущен: клиент не ответил", "conversations.system.call_requested": "Оператор {operator} запросил {kind}", "conversations.system.call_started": "Звонок начался: соединение установлено", + "conversations.system.site_fields_updated": "Сайт обновил данные: {label} «{old}» → «{new}»", + "conversations.site_fields.yes": "Да", + "conversations.site_fields.no": "Нет", + "conversations.site_fields.empty": "—", "conversations.system.operator_took": "Оператор {operator} перехватил диалог", "conversations.system.returned_to_ai": "Диалог возвращён AI", "conversations.system.returned_to_queue": "Диалог возвращён в очередь", diff --git a/apps/backend/chatballs/webchat/message_history.py b/apps/backend/chatballs/webchat/message_history.py new file mode 100644 index 0000000..6208b2a --- /dev/null +++ b/apps/backend/chatballs/webchat/message_history.py @@ -0,0 +1,101 @@ +"""Публичная история виджета: сообщения, состояние и приглашение на звонок.""" + +from chatballs.conversations.ai_turn import conversation_is_thinking +from chatballs.conversations.models import ( + ControlMode, + Conversation, + LifecycleState, + Message, + MessageKind, + SystemEvent, +) +from chatballs.webchat.models import WebSession + +_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"} +_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"} + + +def _call_payload(session: WebSession) -> dict | None: + # Приглашение на звонок для активной session (SPEC-CHATBALLS-0013 §7.1): + # виджет получает его этим же поллингом, без отдельного realtime-канала. + from chatballs.calls.serializers import public_invite_payload + from chatballs.calls.services import webchat_active_call + + call = webchat_active_call(session.identity) + if call is None: + return None + return public_invite_payload(call, call.invite.expires_at) + + +def _thread_is_gone(session: WebSession, since: int) -> bool: + """Переписка, которую показывает виджет, перестала существовать. + + Диалог могли удалить в рабочем месте — вместе со всеми сообщениями. Тогда + сообщения, до которого досчитал виджет, больше нет, и показывать клиенту + переписку, которой не существует, нельзя. Закрытый диалог под это правило + не подпадает: его сообщения на месте. + """ + return since > 0 and not Message.objects.filter( + id=since, + conversation__contact_id=session.identity.contact_id, + conversation__channel_id=session.connection.channel_id, + ).exists() + + +def messages_payload(session: WebSession, since: int) -> dict: + conversation = ( + Conversation.objects.filter( + channel=session.connection.channel, contact=session.identity.contact, lifecycle=LifecycleState.OPEN + ) + .order_by("-last_activity_at") + .first() + ) + reset = _thread_is_gone(session, since) + if reset: + # Лента виджета начинается заново: то, что осталось, отдаётся целиком. + since = 0 + if conversation is None: + return { + "state": "ai", + "lifecycle": LifecycleState.OPEN, + "messages": [], + "call": None, + "reset": reset, + "thinking": False, + } + # Изменения сайта адресованы оператору: клиенту не нужна пустая системная строка. + items = conversation.messages.filter(id__gt=since).exclude( + system_event=SystemEvent.SITE_FIELDS_UPDATED, + ).order_by("created_at") + return { + "reset": reset, + "state": _STATE.get(conversation.control_mode, "ai"), + "lifecycle": conversation.lifecycle, + # Ответ уже считается: виджет показывает клиенту, что агент печатает. + "thinking": conversation_is_thinking(conversation.id), + "messages": [ + { + "id": m.id, + "author": _ROLE.get(m.author_type, "ai"), + "kind": m.kind, + "text": m.text, + "createdAt": m.created_at.isoformat(), + "durationSeconds": m.duration_seconds, + "hasAudio": bool(m.audio), + **( + { + "attachment": { + "name": m.attachment_name, + "contentType": m.attachment_content_type, + "size": m.attachment_size, + "available": bool(m.attachment), + } + } + if m.kind == MessageKind.FILE + else {} + ), + } + for m in items + ], + "call": _call_payload(session), + } diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 31a2daf..9b85c23 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -1,16 +1,13 @@ import uuid -from chatballs.conversations.ai_turn import conversation_is_thinking from chatballs.conversations.ingest import ingest_inbound from chatballs.conversations.models import ( - ControlMode, Conversation, LifecycleState, - Message, - MessageKind, ) from chatballs.conversations.transports.base import InboundMessage from chatballs.webchat.configuration import public_config as public_config +from chatballs.webchat.message_history import messages_payload as messages_payload from chatballs.webchat.models import WebSession from chatballs.webchat.sessions import ( hash_session_token as hash_session_token, @@ -28,9 +25,6 @@ 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 post_message(session: WebSession, text: str) -> None: inbound = InboundMessage( @@ -125,86 +119,3 @@ def post_contact(session: WebSession, phone: str) -> None: phone=phone, ) ingest_inbound(session.connection, inbound) - - -def _call_payload(session: WebSession) -> dict | None: - # Приглашение на звонок для активной session (SPEC-CHATBALLS-0013 §7.1): - # виджет получает его этим же поллингом, без отдельного realtime-канала. - from chatballs.calls.serializers import public_invite_payload - from chatballs.calls.services import webchat_active_call - - call = webchat_active_call(session.identity) - if call is None: - return None - return public_invite_payload(call, call.invite.expires_at) - - -def _thread_is_gone(session: WebSession, since: int) -> bool: - """Переписка, которую показывает виджет, перестала существовать. - - Диалог могли удалить в рабочем месте — вместе со всеми сообщениями. Тогда - сообщения, до которого досчитал виджет, больше нет, и показывать клиенту - переписку, которой не существует, нельзя. Закрытый диалог под это правило - не подпадает: его сообщения на месте. - """ - return since > 0 and not Message.objects.filter( - id=since, - conversation__contact_id=session.identity.contact_id, - conversation__channel_id=session.connection.channel_id, - ).exists() - - -def messages_payload(session: WebSession, since: int) -> dict: - conversation = ( - Conversation.objects.filter( - channel=session.connection.channel, contact=session.identity.contact, lifecycle=LifecycleState.OPEN - ) - .order_by("-last_activity_at") - .first() - ) - reset = _thread_is_gone(session, since) - if reset: - # Лента виджета начинается заново: то, что осталось, отдаётся целиком. - since = 0 - if conversation is None: - return { - "state": "ai", - "lifecycle": LifecycleState.OPEN, - "messages": [], - "call": None, - "reset": reset, - "thinking": False, - } - items = conversation.messages.filter(id__gt=since).order_by("created_at") - return { - "reset": reset, - "state": _STATE.get(conversation.control_mode, "ai"), - "lifecycle": conversation.lifecycle, - # Ответ уже считается: виджет показывает клиенту, что агент печатает. - "thinking": conversation_is_thinking(conversation.id), - "messages": [ - { - "id": m.id, - "author": _ROLE.get(m.author_type, "ai"), - "kind": m.kind, - "text": m.text, - "createdAt": m.created_at.isoformat(), - "durationSeconds": m.duration_seconds, - "hasAudio": bool(m.audio), - **( - { - "attachment": { - "name": m.attachment_name, - "contentType": m.attachment_content_type, - "size": m.attachment_size, - "available": bool(m.attachment), - } - } - if m.kind == MessageKind.FILE - else {} - ), - } - for m in items - ], - "call": _call_payload(session), - } diff --git a/apps/backend/chatballs/webchat/site_field_events.py b/apps/backend/chatballs/webchat/site_field_events.py new file mode 100644 index 0000000..ae926f7 --- /dev/null +++ b/apps/backend/chatballs/webchat/site_field_events.py @@ -0,0 +1,45 @@ +"""История и realtime для фактических изменений данных сайта.""" + +from functools import partial + +from django.db import transaction + +from chatballs.conversations.models import ( + Conversation, + LifecycleState, + Message, + MessageAuthor, + SystemEvent, +) +from chatballs.conversations.realtime import notify_conversation_changed +from chatballs.conversations.site_fields import field_display + + +def _event_value(definition: dict, value: object) -> object: + if value is not None and definition["type"] == "enum": + return field_display(definition, value) + return value + + +def record_site_field_changes(session, changes: list[tuple[dict, object, object]]) -> None: + if not changes: + return + conversations = Conversation.objects.filter( + organization_id=session.organization_id, contact_id=session.identity.contact_id, + lifecycle=LifecycleState.OPEN, + ) + for conversation in conversations: + for definition, old, new in changes: + if definition["type"] not in {"enum", "boolean"}: + continue + # Подписи enum — исторический снимок; boolean и пустое значение + # остаются кодами, чтобы переводиться на языке читателя. + Message.objects.create( + organization_id=session.organization_id, conversation=conversation, + author_type=MessageAuthor.SYSTEM, system_event=SystemEvent.SITE_FIELDS_UPDATED, + system_params={"label": definition.get("label", definition.get("key", "")), + "old": _event_value(definition, old), "new": _event_value(definition, new), "fieldType": definition["type"]}, + ) + transaction.on_commit(partial( + notify_conversation_changed, conversation.id, organization_id=session.organization_id, + )) diff --git a/apps/backend/chatballs/webchat/site_fields.py b/apps/backend/chatballs/webchat/site_fields.py index 0ede314..42387f9 100644 --- a/apps/backend/chatballs/webchat/site_fields.py +++ b/apps/backend/chatballs/webchat/site_fields.py @@ -11,6 +11,7 @@ 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 +from chatballs.webchat.site_field_events import record_site_field_changes logger = logging.getLogger(__name__) MAX_STRING_LENGTH = 500 @@ -86,6 +87,7 @@ def save_site_fields(session, fields: object) -> None: id=session.identity.contact_id, organization_id=session.organization_id ) schema = _schema(session.connection) + changes = [] for key, raw_value in fields.items(): definition = schema.get(key) if isinstance(key, str) else None if definition is None: @@ -99,11 +101,16 @@ def save_site_fields(session, fields: object) -> None: previous = ContactFieldValue.objects.filter( contact=contact, integration=session.connection, key=key ).first() + builtin_before = getattr(contact, key) if key in RESERVED_KEYS else None 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 + old_value = previous.value if previous else None + builtin_changed = key in RESERVED_KEYS and builtin_before != getattr(contact, key) + if old_value == value and not builtin_changed: + continue if value is None: if previous: previous.delete() @@ -118,3 +125,5 @@ def save_site_fields(session, fields: object) -> None: key=key, value=value, ) + changes.append(({"key": key, **definition}, old_value, value)) + record_site_field_changes(session, changes) diff --git a/apps/backend/chatballs/webchat/test_site_field_events.py b/apps/backend/chatballs/webchat/test_site_field_events.py new file mode 100644 index 0000000..d30d664 --- /dev/null +++ b/apps/backend/chatballs/webchat/test_site_field_events.py @@ -0,0 +1,154 @@ +from unittest.mock import patch + +from django.db import transaction +from django.test import TestCase +from django.utils.translation import override + +from chatballs.conversations.models import ( + ContactFieldValue, + Conversation, + LifecycleState, + Message, + MessageAuthor, + SystemEvent, +) +from chatballs.conversations.serializers import _pending_count, message_payload, pending_counts_for +from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership +from chatballs.testing import TenantAPIClient +from chatballs.webchat import test_site_fields as storage_tests +from chatballs.webchat.services import messages_payload +from chatballs.webchat.site_fields import save_site_fields + +FIELDS = [ + {"key": "status", "label": "Статус заказа", "type": "enum", "order": 2, "aiVisible": False, + "options": [{"value": "cooking", "label": "Готовится", "color": "orange"}, + {"value": "sent", "label": "В пути", "color": "blue"}]}, + {"key": "active", "label": "Активный заказ", "type": "boolean", "order": 1}, + {"key": "number", "label": "Номер", "type": "string", "order": 3}, +] + + +class SiteFieldEventTests(TestCase): + def setUp(self): + storage_tests.SiteFieldApiTests.setUp(self) + self.widget.integration.config = {"fields": FIELDS} + self.widget.integration.save(update_fields=["config"]) + self.token, self.session = storage_tests.SiteFieldApiTests._session(self, {"status": "cooking", "active": True}) + self.conversation = Conversation.objects.create( + organization=self.organization, channel=self.widget.integration.channel, + connection=self.widget.integration, contact=self.session.identity.contact, + ) + + def update(self, fields): + response = storage_tests.SiteFieldApiTests._fields(self, self.token, fields) + self.assertEqual(response.status_code, 200, response.content) + + def test_enum_event_keeps_historical_labels_and_renders_for_reader(self): + self.update({"status": "sent"}) + message = self.conversation.messages.get() + self.assertEqual(message.system_event, SystemEvent.SITE_FIELDS_UPDATED) + self.assertEqual(message.text, "") + self.assertEqual(message.system_params, { + "label": "Статус заказа", "old": "Готовится", "new": "В пути", "fieldType": "enum", + }) + with override("ru"): + self.assertEqual(message_payload(message)["text"], "Сайт обновил данные: Статус заказа «Готовится» → «В пути»") + with override("en"): + self.assertEqual(message_payload(message)["text"], "The website updated data: Статус заказа “Готовится” → “В пути”") + + def test_boolean_and_clear_translate_on_read(self): + self.update({"active": False}) + message = self.conversation.messages.get() + with override("ru"): + self.assertIn("«Да» → «Нет»", message_payload(message)["text"]) + with override("en"): + self.assertIn("“Yes” → “No”", message_payload(message)["text"]) + self.update({"active": None}) + with override("en"): + self.assertIn("“No” → “—”", message_payload(self.conversation.messages.latest("id"))["text"]) + self.update({"active": True}) + self.assertIsNone(self.conversation.messages.latest("id").system_params["old"]) + + def test_unchanged_invalid_and_other_types_do_not_add_events(self): + previous = ContactFieldValue.objects.get(contact=self.session.identity.contact, key="status") + self.update({"status": "cooking", "active": True, "number": "10482", "name": "Visitor"}) + self.update({"status": "unknown", "active": "yes", "missing": True}) + self.update({"number": None}) + self.assertFalse(self.conversation.messages.exists()) + self.assertEqual(ContactFieldValue.objects.get(pk=previous.pk).updated_at, previous.updated_at) + + def test_closed_dialog_and_initial_session_do_not_add_events(self): + self.assertFalse(Message.objects.exists()) + self.conversation.lifecycle = LifecycleState.CLOSED + self.conversation.save(update_fields=["lifecycle"]) + self.update({"status": "sent", "active": False}) + self.assertFalse(self.conversation.messages.exists()) + + def test_each_open_dialog_of_contact_gets_event(self): + other = Conversation.objects.create( + organization=self.organization, channel=self.widget.integration.channel, + connection=self.widget.integration, contact=self.session.identity.contact, + ) + self.update({"status": "sent"}) + self.assertEqual(set(Message.objects.values_list("conversation_id", flat=True)), {self.conversation.id, other.id}) + + def test_batch_notifies_after_commit_and_rollback_does_not_notify(self): + with patch("chatballs.webchat.site_field_events.notify_conversation_changed") as notify: + with self.captureOnCommitCallbacks(execute=True): + self.update({"status": "sent", "active": False, "number": "10482"}) + notify.assert_not_called() + notify.assert_called_once_with(self.conversation.id, organization_id=self.organization.id) + notify.reset_mock() + with self.captureOnCommitCallbacks(execute=True): + self.update({"status": "sent"}) + notify.assert_not_called() + with self.captureOnCommitCallbacks(execute=True): + try: + with transaction.atomic(): + save_site_fields(self.session, {"status": "cooking"}) + raise ValueError("rollback") + except ValueError: + pass + notify.assert_not_called() + self.assertEqual(self.conversation.messages.count(), 2) + + def test_string_change_notifies_without_feed_event(self): + with patch("chatballs.webchat.site_field_events.notify_conversation_changed") as notify: + with self.captureOnCommitCallbacks(execute=True): + self.update({"number": "10482"}) + notify.assert_called_once() + self.assertFalse(self.conversation.messages.exists()) + + def test_site_field_events_are_not_sent_to_customer_widget(self): + self.update({"status": "sent"}) + self.assertTrue(self.conversation.messages.exists()) + self.assertEqual(messages_payload(self.session, 0)["messages"], []) + + def test_site_field_event_does_not_clear_unread_customer_message(self): + Message.objects.create(conversation=self.conversation, author_type=MessageAuthor.CONTACT, text="Hello") + self.update({"status": "sent"}) + self.assertEqual(_pending_count(self.conversation), 1) + self.assertEqual(pending_counts_for([self.conversation.id], {}), {self.conversation.id: 1}) + + def test_dialog_and_contact_api_follow_current_schema(self): + self.update({"number": "10482", "email": "visitor@example.test"}) + user = HumanUser.objects.create_user(email="reader@example.test", ui_language="en") + OrganizationMembership.objects.create(user=user, organization=self.organization, role=EmployeeRole.ADMIN) + client = TenantAPIClient() + client.force_authenticate(user) + conversation_url = f"/api/v1/conversations/{self.conversation.id}/" + contact_url = f"/api/v1/conversations/clients/{self.session.identity.contact_id}/" + for url, wrapper in [(conversation_url, "conversation"), (contact_url, "client")]: + response = client.get(url) + self.assertEqual(response.status_code, 200, response.content) + fields = response.json()[wrapper]["siteFields"] + self.assertEqual([item["key"] for item in fields], ["active", "status", "number"]) + self.assertEqual(fields[0]["display"], "Yes") + self.assertEqual(fields[1]["display"], "Готовится") + self.assertEqual(fields[1]["color"], "orange") + self.assertEqual(set(fields[0]), {"key", "label", "type", "value", "display", "updatedAt"}) + self.widget.integration.config = {"fields": [FIELDS[0]]} + self.widget.integration.save(update_fields=["config"]) + self.assertEqual([item["key"] for item in client.get(conversation_url).json()["conversation"]["siteFields"]], ["status"]) + self.assertEqual([item["key"] for item in client.get(contact_url).json()["client"]["siteFields"]], ["status"]) + self.assertTrue(ContactFieldValue.objects.filter(contact=self.session.identity.contact, key="number").exists()) diff --git a/apps/internal-ui/src/features/conversations/apiTypes.ts b/apps/internal-ui/src/features/conversations/apiTypes.ts new file mode 100644 index 0000000..e324a2c --- /dev/null +++ b/apps/internal-ui/src/features/conversations/apiTypes.ts @@ -0,0 +1,104 @@ +import type { SiteField } from "@chatballs/contracts"; + +// kind: "" — текст, "contact_request" — запрос контакта, "contact" — клиент поделился номером. +export type ApiMessage = { + id: number; + author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; + authorUserId?: number | null; + authorName?: string; + authorAvatarUrl?: string | null; + kind?: string; + text: string; + // Код системного события: строку сервер уже собрал на языке читателя, код + // остаётся интерфейсу для тона строки. + systemEvent?: string; + contentHtml?: string; + createdAt: string; + // Голосовое (kind="voice", дизайн-базлайн v2 кадр H). + audioUrl?: string | null; + durationSeconds?: number; + transcript?: string; + transcriptStatus?: "NONE" | "READY" | "FAILED"; + // Файл или фото (kind="file"): text — подпись. + attachmentUrl?: string | null; + attachmentName?: string; + attachmentContentType?: string; + attachmentSize?: number; +}; + +export const isImageAttachment = (message: Pick) => + /^image\/(jpeg|png|gif|webp)$/.test(message.attachmentContentType ?? ""); + +export type HistoryItem = { + id: number; + channelName: string; + provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB" | null; + lifecycle: "OPEN" | "CLOSED" | "SPAM"; + createdAt: string; + lastActivityAt: string; + topic?: string; + handledBy?: string | null; + preview: string; +}; + +export type ConversationPriority = "HIGH" | "MEDIUM" | "LOW" | "NONE"; + +export type ConversationLabelRef = { id: number; name: string; color: string }; + +export type ConversationCounters = { + all: number; + waiting: number; + /** Ничей: взять может любой. */ + queue: number; + /** Назначен лично на меня и ждёт, пока я возьму. */ + waitingOnMe: number; + mine: number; + /** Сколько минут держится личная очередь, прежде чем диалог вернётся всем. */ + assignmentTimeoutMinutes?: number; + ungrouped: number; + groups: Array<{ id: number; name: string; color?: string; count: number }>; + agents: Array<{ id: number; code: string; name: string; count: number }>; + assignees: Array<{ id: number; name: string; count: number; avatarUrl?: string | null }>; +}; + +export type ReplyTemplateRef = { id: number; title: string; text: string; updatedAt: string }; + +export type ApiConversation = { + id: number; + channel: { id: number; code: string; name: string }; + // voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»). + connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null; + // Контакт — единственный источник identity диалога. + // phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме. + // isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя. + siteFields?: SiteField[]; + contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null; + lifecycle: "OPEN" | "CLOSED" | "SPAM"; + controlMode: "AI" | "HUMAN" | "PAUSED"; + expectedResponder: string; + assignedOperatorId: number | null; + assignedOperator: { id: number; name: string; avatarUrl?: string | null } | null; + isAssignedToViewer: boolean; + /** С какого момента диалог ждёт человека (макет Q3/Q4). */ + waitingSince?: string | null; + /** Когда назначили: от него считается срок личной очереди. */ + assignedAt?: string | null; + group: { id: number; name: string; color?: string } | null; + // Дизайн-базлайн v2: приоритет, метки, заметка, архив. + priority: ConversationPriority; + labels: ConversationLabelRef[]; + note: string; + archivedAt: string | null; + lastActivityAt: string; + createdAt: string; + lastMessage: ApiMessage | null; + pendingCount?: number; + // Сообщений в карточке нет: история — отдельная лента с окном (fetchMessages). + history?: HistoryItem[]; + // Запрос контакта мог уйти вне загруженного окна истории — факт считает сервер. + contactRequested?: boolean; +}; + +// Живые ленты (инбокс, история диалога) приходят окном: записи, признак +// продолжения и курсор на следующее окно. +export type WindowPage = { items: T[]; hasMore: boolean; cursor: number | null }; diff --git a/apps/internal-ui/src/features/conversations/conversationApi.ts b/apps/internal-ui/src/features/conversations/conversationApi.ts new file mode 100644 index 0000000..15c39e4 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/conversationApi.ts @@ -0,0 +1,198 @@ +import { api, apiUpload } from "../../api/client"; +import type { ApiConversation, ApiMessage, ConversationCounters, ConversationPriority, ConversationLabelRef, ReplyTemplateRef, WindowPage } from "./apiTypes"; +import type { ListSort } from "./types"; + +export type ConversationListFilters = Partial<{ + group: string; // id | "none" + agent: number; // id канала-агента + assigned: "me" | number; + waiting: boolean; + queue: boolean; + waitingOnMe: boolean; + lifecycle: "OPEN" | "CLOSED" | "SPAM"; + archived: boolean; + q: string; +}>; + +// Запрос окна инбокса: фильтры, порядок и курсор — всё серверное. +export type ConversationListQuery = ConversationListFilters & { + sort?: ListSort; + cursor?: number | null; + limit?: number; +}; + +export type ConversationWindow = WindowPage & { total: number }; + +export const fetchConversations = (query: ConversationListQuery = {}) => { + const params = new URLSearchParams(); + if (query.group) params.set("group", query.group); + if (query.agent) params.set("agent", String(query.agent)); + if (query.assigned) params.set("assigned", String(query.assigned)); + if (query.waiting) params.set("waiting", "1"); + if (query.queue) params.set("queue", "1"); + if (query.waitingOnMe) params.set("waitingOnMe", "1"); + if (query.lifecycle) params.set("lifecycle", query.lifecycle); + if (query.archived) params.set("archived", "1"); + if (query.q) params.set("q", query.q); + if (query.sort) params.set("sort", query.sort); + if (query.cursor) params.set("cursor", String(query.cursor)); + if (query.limit) params.set("limit", String(query.limit)); + const suffix = params.size ? `?${params.toString()}` : ""; + return api(`/api/v1/conversations/${suffix}`); +}; + +// Окно истории: без курсора — хвост переписки; before — вверх по ленте; +// after — то, что появилось после последнего показанного сообщения. +export type MessageWindowQuery = { before?: number | null; after?: number | null; limit?: number }; + +export const fetchMessages = (conversationId: number, query: MessageWindowQuery = {}) => { + const params = new URLSearchParams(); + if (query.before) params.set("before", String(query.before)); + if (query.after) params.set("after", String(query.after)); + if (query.limit) params.set("limit", String(query.limit)); + const suffix = params.size ? `?${params.toString()}` : ""; + return api>(`/api/v1/conversations/${conversationId}/messages/${suffix}`); +}; + +export const fetchConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/`).then((r) => r.conversation); +export const claimConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/claim/`, { method: "POST" }).then((r) => r.conversation); +export const releaseConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/release/`, { method: "POST" }).then((r) => r.conversation); +export const sendOperatorMessage = (id: number, text: string) => api(`/api/v1/conversations/${id}/messages/`, { method: "POST", body: JSON.stringify({ text }) }); +export const returnToQueue = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/return-queue/`, { method: "POST" }).then((r) => r.conversation); +// Запрос контакта: в TG/MAX клиент видит кнопку «Поделиться контактом», в веб-чате — форму телефона. +export const requestContact = (id: number) => api(`/api/v1/conversations/${id}/request-contact/`, { method: "POST" }); +export const closeConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/close/`, { method: "POST" }).then((r) => r.conversation); +export const markConversationAsSpam = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/spam/`, { method: "POST" }).then((r) => r.conversation); +// Бейдж ожидающих диалогов. ConversationStatsView сейчас sales-only (SPEC §12: +// support-метрики — отдельный endpoint); department-параметр backend не использует. +export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/conversations/stats/").then((r) => r.waiting); + +// --- Дизайн-базлайн v2: карточка «Диалог», метки, шаблоны, счётчики --- + +// Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для +// назначения — доступен и сотруднику, у которого нет менеджерских списков. +export type ChatDirectoryEmployee = { + id: number; + name: string; + avatarUrl?: string | null; + role?: string; + /** Приложение открыто прямо сейчас. Признак приблизительный — см. Q5. */ + online?: boolean; + lastSeenAt?: string | null; + /** Сколько открытых диалогов уже на человеке: второй признак после присутствия. */ + openDialogs?: number; +}; + +export type ChatDirectory = { + groups: Array<{ id: number; name: string; color?: string }>; + // Выдача коллег ограничена, поиск — на сервере: ростер организации может + // быть каким угодно, а выбор ответственного — не список. + employees: ChatDirectoryEmployee[]; + hasMoreEmployees?: boolean; +}; + +// Карточка контакта из диалога (карандаш у имени, дизайн-базлайн v2). +export const updateContactCard = (conversationId: number, fields: Partial<{ name: string; description: string; phone: string; company: string; city: string }>) => + conversationAction(conversationId, "contact", fields); + +export const fetchChatDirectory = (query = "") => + api(`/api/v1/conversations/directory/${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ""}`); + +const conversationAction = (id: number, suffix: string, body: object) => + api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/${suffix}/`, { + method: "POST", + body: JSON.stringify(body), + }).then((r) => r.conversation); + +export const setConversationPriority = (id: number, priority: ConversationPriority) => + conversationAction(id, "priority", { priority }); +export const setConversationNote = (id: number, note: string) => + conversationAction(id, "note", { note }); +export const setConversationLabels = (id: number, labelIds: number[]) => + conversationAction(id, "labels", { labelIds }); +export const setConversationArchived = (id: number, archived: boolean) => + conversationAction(id, "archive", { archived }); +/** Удалить диалог вместе с перепиской. Право — у владельца и администратора. */ +export const deleteConversation = (id: number) => + api(`/api/v1/conversations/${id}/`, { method: "DELETE" }); +export const setConversationGroup = (id: number, groupId: number | null) => + conversationAction(id, "group", { groupId }); +export const setConversationAssignee = (id: number, userId: number | null) => + conversationAction(id, "assignee", { userId }); + +export const fetchConversationCounters = () => + api("/api/v1/conversations/counters/"); +export const fetchConversationLabels = () => + api<{ items: ConversationLabelRef[] }>("/api/v1/conversations/labels/").then((r) => r.items); +export const createConversationLabel = (name: string, color = "") => + api<{ label: ConversationLabelRef }>("/api/v1/conversations/labels/", { + method: "POST", + body: JSON.stringify({ name, color }), + }).then((r) => r.label); +export const fetchReplyTemplates = () => + api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items); +export const createReplyTemplate = (title: string, text: string) => + api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", { + method: "POST", + body: JSON.stringify({ title, text }), + }).then((r) => r.template); +export const updateReplyTemplate = (id: number, title: string, text: string) => + api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, { + method: "PATCH", + body: JSON.stringify({ title, text }), + }).then((r) => r.template); +export const deleteReplyTemplate = (id: number) => + api(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" }); + +export const transcribeMessage = (messageId: number) => + api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message); + +export const sendVoiceMessage = (conversationId: number, audio: Blob, durationSeconds: number) => { + const form = new FormData(); + const extension = audio.type.includes("ogg") ? "ogg" : audio.type.includes("webm") ? "webm" : "bin"; + form.append("audio", audio, `voice.${extension}`); + form.append("duration", String(Math.round(durationSeconds))); + return apiUpload<{ message: ApiMessage }>(`/api/v1/conversations/${conversationId}/voice/`, form).then((r) => r.message); +}; + +// Файл из композера («Прикрепить»): multipart file + подпись text. +export const sendFileMessage = (conversationId: number, file: File, caption: string) => { + const form = new FormData(); + form.append("file", file, file.name); + if (caption) form.append("text", caption); + return apiUpload<{ message: ApiMessage }>(`/api/v1/conversations/${conversationId}/attachments/`, form).then((r) => r.message); +}; + +// --- Онлайн-звонки (SPEC-CHATBALLS-0013): запрос из диалога, ожидание, отмена --- + +export type CallKind = "AUDIO" | "VIDEO"; + +export type ApiCall = { + id: string; + conversationId: number; + status: "REQUESTED" | "RINGING" | "ACCEPTED" | "CONNECTING" | "ACTIVE" | "DECLINED" | "CANCELLED" | "MISSED" | "ENDED" | "FAILED" | "EXPIRED"; + kind: CallKind; + requestedAt: string; + acceptedAt: string | null; + connectedAt: string | null; + endedAt: string | null; + endedBy: string | null; + failureCode: string | null; + durationSeconds: number | null; +}; + +export type CallAccess = { accessToken: string; iceServers: RTCIceServer[] }; +export type CreatedCall = { call: ApiCall; access: CallAccess }; + +// Запрос звонка: при режиме AI backend атомарно выполняет takeover (§6). +export const requestCall = (conversationId: number, kind: CallKind = "AUDIO") => + api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST", body: JSON.stringify({ kind }) }) + .then((r): CreatedCall => ({ call: r.call, access: { accessToken: r.staffAccessToken, iceServers: r.iceServers } })); +export const fetchActiveCall = (conversationId: number) => + api<{ call: ApiCall | null }>(`/api/v1/calls/conversations/${conversationId}/active/`).then((r) => r.call); +export const fetchCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/`).then((r) => r.call); +export const cancelCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/cancel/`, { method: "POST" }).then((r) => r.call); +export const fetchStaffCallAccess = (callId: string) => + api<{ accessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/${callId}/access-token/`, { method: "POST" }); +export const endCallByAccess = (accessToken: string) => + api<{ call: ApiCall }>("/api/v1/calls/access/end/", { method: "POST", headers: { Authorization: `Bearer ${accessToken}` } }).then((r) => r.call); diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index 22a289c..2ca54c4 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -1,108 +1,9 @@ -import { api, apiUpload } from "../../api/client"; -import type { ChannelKey, ConversationListItem, ControlMode, DialogMode, ListSort } from "./types"; +import type { ChannelKey, ConversationListItem, ControlMode, DialogMode } from "./types"; import { fmt, t } from "../../i18n"; -// kind: "" — текст, "contact_request" — запрос контакта, "contact" — клиент поделился номером. -export type ApiMessage = { - id: number; - author: "CONTACT" | "AI" | "OPERATOR" | "SYSTEM"; - authorUserId?: number | null; - authorName?: string; - authorAvatarUrl?: string | null; - kind?: string; - text: string; - // Код системного события: строку сервер уже собрал на языке читателя, код - // остаётся интерфейсу для тона строки. - systemEvent?: string; - contentHtml?: string; - createdAt: string; - // Голосовое (kind="voice", дизайн-базлайн v2 кадр H). - audioUrl?: string | null; - durationSeconds?: number; - transcript?: string; - transcriptStatus?: "NONE" | "READY" | "FAILED"; - // Файл или фото (kind="file"): text — подпись. - attachmentUrl?: string | null; - attachmentName?: string; - attachmentContentType?: string; - attachmentSize?: number; -}; - -export const isImageAttachment = (message: Pick) => - /^image\/(jpeg|png|gif|webp)$/.test(message.attachmentContentType ?? ""); - -export type HistoryItem = { - id: number; - channelName: string; - provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB" | null; - lifecycle: "OPEN" | "CLOSED" | "SPAM"; - createdAt: string; - lastActivityAt: string; - topic?: string; - handledBy?: string | null; - preview: string; -}; - -export type ConversationPriority = "HIGH" | "MEDIUM" | "LOW" | "NONE"; - -export type ConversationLabelRef = { id: number; name: string; color: string }; - -export type ConversationCounters = { - all: number; - waiting: number; - /** Ничей: взять может любой. */ - queue: number; - /** Назначен лично на меня и ждёт, пока я возьму. */ - waitingOnMe: number; - mine: number; - /** Сколько минут держится личная очередь, прежде чем диалог вернётся всем. */ - assignmentTimeoutMinutes?: number; - ungrouped: number; - groups: Array<{ id: number; name: string; color?: string; count: number }>; - agents: Array<{ id: number; code: string; name: string; count: number }>; - assignees: Array<{ id: number; name: string; count: number; avatarUrl?: string | null }>; -}; - -export type ReplyTemplateRef = { id: number; title: string; text: string; updatedAt: string }; - -export type ApiConversation = { - id: number; - channel: { id: number; code: string; name: string }; - // voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»). - connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null; - // Контакт — единственный источник identity диалога. - // phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме. - // isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя. - contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null; - lifecycle: "OPEN" | "CLOSED" | "SPAM"; - controlMode: "AI" | "HUMAN" | "PAUSED"; - expectedResponder: string; - assignedOperatorId: number | null; - assignedOperator: { id: number; name: string; avatarUrl?: string | null } | null; - isAssignedToViewer: boolean; - /** С какого момента диалог ждёт человека (макет Q3/Q4). */ - waitingSince?: string | null; - /** Когда назначили: от него считается срок личной очереди. */ - assignedAt?: string | null; - group: { id: number; name: string; color?: string } | null; - // Дизайн-базлайн v2: приоритет, метки, заметка, архив. - priority: ConversationPriority; - labels: ConversationLabelRef[]; - note: string; - archivedAt: string | null; - lastActivityAt: string; - createdAt: string; - lastMessage: ApiMessage | null; - pendingCount?: number; - // Сообщений в карточке нет: история — отдельная лента с окном (fetchMessages). - history?: HistoryItem[]; - // Запрос контакта мог уйти вне загруженного окна истории — факт считает сервер. - contactRequested?: boolean; -}; - -// Живые ленты (инбокс, история диалога) приходят окном: записи, признак -// продолжения и курсор на следующее окно. -export type WindowPage = { items: T[]; hasMore: boolean; cursor: number | null }; +export * from "./apiTypes"; +import { isImageAttachment } from "./apiTypes"; +import type { ApiConversation } from "./apiTypes"; const AVATAR_PALETTE = ["#eb6f4b", "#3b82c4", "#9254de", "#13a8a8", "#d4860b", "#52a838", "#c4413b", "#6b5be0"]; // Цвет агента и цвет точки группы — стабильно из идентификатора. Палитра идёт @@ -243,197 +144,4 @@ export function toConversationListItem( // Видимость inbox решает backend (ADR-CHATBALLS-0043): группы сотрудника + без группы // + назначенные ему; владелец и админ видят всё. Фильтры — серверные. -export type ConversationListFilters = Partial<{ - group: string; // id | "none" - agent: number; // id канала-агента - assigned: "me" | number; - waiting: boolean; - queue: boolean; - waitingOnMe: boolean; - lifecycle: "OPEN" | "CLOSED" | "SPAM"; - archived: boolean; - q: string; -}>; - -// Запрос окна инбокса: фильтры, порядок и курсор — всё серверное. -export type ConversationListQuery = ConversationListFilters & { - sort?: ListSort; - cursor?: number | null; - limit?: number; -}; - -export type ConversationWindow = WindowPage & { total: number }; - -export const fetchConversations = (query: ConversationListQuery = {}) => { - const params = new URLSearchParams(); - if (query.group) params.set("group", query.group); - if (query.agent) params.set("agent", String(query.agent)); - if (query.assigned) params.set("assigned", String(query.assigned)); - if (query.waiting) params.set("waiting", "1"); - if (query.queue) params.set("queue", "1"); - if (query.waitingOnMe) params.set("waitingOnMe", "1"); - if (query.lifecycle) params.set("lifecycle", query.lifecycle); - if (query.archived) params.set("archived", "1"); - if (query.q) params.set("q", query.q); - if (query.sort) params.set("sort", query.sort); - if (query.cursor) params.set("cursor", String(query.cursor)); - if (query.limit) params.set("limit", String(query.limit)); - const suffix = params.size ? `?${params.toString()}` : ""; - return api(`/api/v1/conversations/${suffix}`); -}; - -// Окно истории: без курсора — хвост переписки; before — вверх по ленте; -// after — то, что появилось после последнего показанного сообщения. -export type MessageWindowQuery = { before?: number | null; after?: number | null; limit?: number }; - -export const fetchMessages = (conversationId: number, query: MessageWindowQuery = {}) => { - const params = new URLSearchParams(); - if (query.before) params.set("before", String(query.before)); - if (query.after) params.set("after", String(query.after)); - if (query.limit) params.set("limit", String(query.limit)); - const suffix = params.size ? `?${params.toString()}` : ""; - return api>(`/api/v1/conversations/${conversationId}/messages/${suffix}`); -}; - -export const fetchConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/`).then((r) => r.conversation); -export const claimConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/claim/`, { method: "POST" }).then((r) => r.conversation); -export const releaseConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/release/`, { method: "POST" }).then((r) => r.conversation); -export const sendOperatorMessage = (id: number, text: string) => api(`/api/v1/conversations/${id}/messages/`, { method: "POST", body: JSON.stringify({ text }) }); -export const returnToQueue = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/return-queue/`, { method: "POST" }).then((r) => r.conversation); -// Запрос контакта: в TG/MAX клиент видит кнопку «Поделиться контактом», в веб-чате — форму телефона. -export const requestContact = (id: number) => api(`/api/v1/conversations/${id}/request-contact/`, { method: "POST" }); -export const closeConversation = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/close/`, { method: "POST" }).then((r) => r.conversation); -export const markConversationAsSpam = (id: number) => api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/spam/`, { method: "POST" }).then((r) => r.conversation); -// Бейдж ожидающих диалогов. ConversationStatsView сейчас sales-only (SPEC §12: -// support-метрики — отдельный endpoint); department-параметр backend не использует. -export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/conversations/stats/").then((r) => r.waiting); - -// --- Дизайн-базлайн v2: карточка «Диалог», метки, шаблоны, счётчики --- - -// Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для -// назначения — доступен и сотруднику, у которого нет менеджерских списков. -export type ChatDirectoryEmployee = { - id: number; - name: string; - avatarUrl?: string | null; - role?: string; - /** Приложение открыто прямо сейчас. Признак приблизительный — см. Q5. */ - online?: boolean; - lastSeenAt?: string | null; - /** Сколько открытых диалогов уже на человеке: второй признак после присутствия. */ - openDialogs?: number; -}; - -export type ChatDirectory = { - groups: Array<{ id: number; name: string; color?: string }>; - // Выдача коллег ограничена, поиск — на сервере: ростер организации может - // быть каким угодно, а выбор ответственного — не список. - employees: ChatDirectoryEmployee[]; - hasMoreEmployees?: boolean; -}; - -// Карточка контакта из диалога (карандаш у имени, дизайн-базлайн v2). -export const updateContactCard = (conversationId: number, fields: Partial<{ name: string; description: string; phone: string; company: string; city: string }>) => - conversationAction(conversationId, "contact", fields); - -export const fetchChatDirectory = (query = "") => - api(`/api/v1/conversations/directory/${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ""}`); - -const conversationAction = (id: number, suffix: string, body: object) => - api<{ conversation: ApiConversation }>(`/api/v1/conversations/${id}/${suffix}/`, { - method: "POST", - body: JSON.stringify(body), - }).then((r) => r.conversation); - -export const setConversationPriority = (id: number, priority: ConversationPriority) => - conversationAction(id, "priority", { priority }); -export const setConversationNote = (id: number, note: string) => - conversationAction(id, "note", { note }); -export const setConversationLabels = (id: number, labelIds: number[]) => - conversationAction(id, "labels", { labelIds }); -export const setConversationArchived = (id: number, archived: boolean) => - conversationAction(id, "archive", { archived }); -/** Удалить диалог вместе с перепиской. Право — у владельца и администратора. */ -export const deleteConversation = (id: number) => - api(`/api/v1/conversations/${id}/`, { method: "DELETE" }); -export const setConversationGroup = (id: number, groupId: number | null) => - conversationAction(id, "group", { groupId }); -export const setConversationAssignee = (id: number, userId: number | null) => - conversationAction(id, "assignee", { userId }); - -export const fetchConversationCounters = () => - api("/api/v1/conversations/counters/"); -export const fetchConversationLabels = () => - api<{ items: ConversationLabelRef[] }>("/api/v1/conversations/labels/").then((r) => r.items); -export const createConversationLabel = (name: string, color = "") => - api<{ label: ConversationLabelRef }>("/api/v1/conversations/labels/", { - method: "POST", - body: JSON.stringify({ name, color }), - }).then((r) => r.label); -export const fetchReplyTemplates = () => - api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items); -export const createReplyTemplate = (title: string, text: string) => - api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", { - method: "POST", - body: JSON.stringify({ title, text }), - }).then((r) => r.template); -export const updateReplyTemplate = (id: number, title: string, text: string) => - api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, { - method: "PATCH", - body: JSON.stringify({ title, text }), - }).then((r) => r.template); -export const deleteReplyTemplate = (id: number) => - api(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" }); - -export const transcribeMessage = (messageId: number) => - api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message); - -export const sendVoiceMessage = (conversationId: number, audio: Blob, durationSeconds: number) => { - const form = new FormData(); - const extension = audio.type.includes("ogg") ? "ogg" : audio.type.includes("webm") ? "webm" : "bin"; - form.append("audio", audio, `voice.${extension}`); - form.append("duration", String(Math.round(durationSeconds))); - return apiUpload<{ message: ApiMessage }>(`/api/v1/conversations/${conversationId}/voice/`, form).then((r) => r.message); -}; - -// Файл из композера («Прикрепить»): multipart file + подпись text. -export const sendFileMessage = (conversationId: number, file: File, caption: string) => { - const form = new FormData(); - form.append("file", file, file.name); - if (caption) form.append("text", caption); - return apiUpload<{ message: ApiMessage }>(`/api/v1/conversations/${conversationId}/attachments/`, form).then((r) => r.message); -}; - -// --- Онлайн-звонки (SPEC-CHATBALLS-0013): запрос из диалога, ожидание, отмена --- - -export type CallKind = "AUDIO" | "VIDEO"; - -export type ApiCall = { - id: string; - conversationId: number; - status: "REQUESTED" | "RINGING" | "ACCEPTED" | "CONNECTING" | "ACTIVE" | "DECLINED" | "CANCELLED" | "MISSED" | "ENDED" | "FAILED" | "EXPIRED"; - kind: CallKind; - requestedAt: string; - acceptedAt: string | null; - connectedAt: string | null; - endedAt: string | null; - endedBy: string | null; - failureCode: string | null; - durationSeconds: number | null; -}; - -export type CallAccess = { accessToken: string; iceServers: RTCIceServer[] }; -export type CreatedCall = { call: ApiCall; access: CallAccess }; - -// Запрос звонка: при режиме AI backend атомарно выполняет takeover (§6). -export const requestCall = (conversationId: number, kind: CallKind = "AUDIO") => - api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST", body: JSON.stringify({ kind }) }) - .then((r): CreatedCall => ({ call: r.call, access: { accessToken: r.staffAccessToken, iceServers: r.iceServers } })); -export const fetchActiveCall = (conversationId: number) => - api<{ call: ApiCall | null }>(`/api/v1/calls/conversations/${conversationId}/active/`).then((r) => r.call); -export const fetchCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/`).then((r) => r.call); -export const cancelCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/cancel/`, { method: "POST" }).then((r) => r.call); -export const fetchStaffCallAccess = (callId: string) => - api<{ accessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/${callId}/access-token/`, { method: "POST" }); -export const endCallByAccess = (accessToken: string) => - api<{ call: ApiCall }>("/api/v1/calls/access/end/", { method: "POST", headers: { Authorization: `Bearer ${accessToken}` } }).then((r) => r.call); +export * from "./conversationApi"; diff --git a/apps/internal-ui/src/features/conversations/useConversationEvents.test.tsx b/apps/internal-ui/src/features/conversations/useConversationEvents.test.tsx new file mode 100644 index 0000000..99d5c5e --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useConversationEvents.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RealtimeMessage } from "../realtime/connection"; +import { useConversationEvents } from "./useConversationEvents"; + +const handlers = vi.hoisted(() => new Map void>()); +vi.mock("../realtime/RealtimeProvider", () => ({ + useRealtime: () => ({ connected: true, watch: vi.fn() }), + useRealtimeEvent: (type: string, handler: (message: RealtimeMessage) => void) => handlers.set(type, handler), +})); + +describe("useConversationEvents", () => { + beforeEach(() => handlers.clear()); + + it("обновляет карточку и ленту открытого диалога после изменения полей сайта", () => { + const refreshCard = vi.fn(); + const catchUp = vi.fn(); + const refreshInbox = vi.fn(); + function Workspace() { + useConversationEvents({ + conversationId: 42, + onInboxChanged: refreshInbox, + onConversationChanged: (id) => { refreshCard(id); catchUp(); }, + }); + return null; + } + renderToStaticMarkup(); + handlers.get("conversation.changed")!({ type: "conversation.changed", conversationId: 42 }); + expect(refreshCard).toHaveBeenCalledWith(42); + expect(catchUp).toHaveBeenCalledOnce(); + handlers.get("conversation.changed")!({ type: "conversation.changed", conversationId: 99 }); + handlers.get("conversation.changed")!({ type: "conversation.changed", conversationId: "42" as unknown as number }); + expect(refreshCard).toHaveBeenCalledOnce(); + handlers.get("inbox.changed")!({ type: "inbox.changed" }); + expect(refreshInbox).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/internal-ui/src/features/conversations/useConversationEvents.ts b/apps/internal-ui/src/features/conversations/useConversationEvents.ts index 358391b..be5a059 100644 --- a/apps/internal-ui/src/features/conversations/useConversationEvents.ts +++ b/apps/internal-ui/src/features/conversations/useConversationEvents.ts @@ -27,7 +27,10 @@ export function useConversationEvents({ useRealtimeEvent("inbox.changed", () => onInboxChanged()); useRealtimeEvent("conversation.changed", (message) => { - if (typeof message.conversationId === "number") onConversationChanged(message.conversationId); + // Включает изменения siteFields: вызывающий обновляет карточку и дельту ленты. + if (message.conversationId === conversationId && typeof message.conversationId === "number") { + onConversationChanged(message.conversationId); + } }); // Открытый диалог сообщается сокету заново и после переподключения: подписка diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3e4f3fb..6501a9c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,16 @@ export interface HealthResponse { checks?: Record; } +export interface SiteField { + key: string; + label: string; + type: "string" | "number" | "boolean" | "datetime" | "enum" | "email" | "phone" | "url"; + value: string | number | boolean; + display: string; + color?: string; + updatedAt: string; +} + export interface DepartmentSummary { departmentId: string; generatedAt: string;