✨ feat(webchat): события и обновление данных сайта в диалогах

This commit is contained in:
Andrey committed 2026-09-30 18:59:34 +03:00
1 parent 416e37e441
commit a940dcc2e3
25 files changed
+1184 -730

No files matched your search

@@ -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 сохранены, рабочее дерево чистое. Повторные адресные проверки после разрешения конфликта прошли.
@@ -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"
@@ -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),
}
+2 -228
View File
@@ -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),
}
@@ -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),
),
]
+1 -29
View File
@@ -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", "Запрос контакта"
@@ -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()
)
@@ -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]
# Запрос контакта мог уйти когда угодно — в загруженном окне истории его
@@ -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
@@ -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
@@ -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", "Звонок не состоялся"
@@ -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)
@@ -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",
@@ -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": "Диалог возвращён в очередь",
@@ -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),
}
+1 -90
View File
@@ -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),
}
@@ -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,
))
@@ -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)
@@ -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())
@@ -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<ApiMessage, "attachmentContentType">) =>
/^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<T> = { items: T[]; hasMore: boolean; cursor: number | null };
@@ -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<ApiConversation> & { 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<ConversationWindow>(`/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<WindowPage<ApiMessage>>(`/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<ChatDirectory>(`/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<void>(`/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<ConversationCounters>("/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<void>(`/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);
@@ -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<ApiMessage, "attachmentContentType">) =>
/^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<T> = { 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<ApiConversation> & { 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<ConversationWindow>(`/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<WindowPage<ApiMessage>>(`/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<ChatDirectory>(`/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<void>(`/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<ConversationCounters>("/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<void>(`/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";
@@ -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<string, (message: RealtimeMessage) => 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(<Workspace />);
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();
});
});
@@ -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);
}
});
// Открытый диалог сообщается сокету заново и после переподключения: подписка
+10
View File
@@ -4,6 +4,16 @@ export interface HealthResponse {
checks?: Record<string, boolean>;
}
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;