mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): события и обновление данных сайта в диалогах
This commit is contained in:
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),
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
|
||||
// Открытый диалог сообщается сокету заново и после переподключения: подписка
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user