mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(queue): очередь к оператору, эскалация и уведомления по макету
Очередь помнит, с какого момента ждёт клиент (waiting_since), и «дольше всех ждущий» больше не считается по последнему сообщению: клиент, напомнивший о себе, уезжал в конец очереди. Постановка в очередь сведена в одно место (conversations.queue) вместо шести копий одного правила. Уведомление видно тем же, кому виден диалог: раньше оно фильтровалось только правом, и оператор чужой группы получал оклик с именем клиента и куском переписки по диалогу, который не может открыть. Получатели считаются одним запросом вместо запроса на каждого сотрудника. Уведомления доезжают событием по сокету, а не следующим опросом: сокет поднят на уровень оболочки и работает на любом экране. Появились уведомления самого браузера — без service worker и Web Push, чтобы не тащить на self-hosted зависимость от чужого push-сервиса. Настройка «о чём звать» стала одна на все транспорты (NotificationPreference), набор событий приведён к макету: «новый диалог» и «клиент запросил оператора» разделены, добавлены «назначили на меня» и «долго ждёт человека». Забытый диалог больше не тонет в тишине: свип напоминает группе, потом всем, потом руководству; сроки — настройка организации в новом разделе «Когда звать на помощь». Назначение стало осмысленным актом — назначенного зовут лично, у него есть срок, и не взял — диалог возвращается всем. Присутствие берётся из открытого сокета и подсказывает двум местам: очереди — что напоминать некому, и выбору ответственного — кто сейчас за рабочим местом. Макет: design/baseline/Очередь и уведомления. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eeed0ed51a
commit
5d8362e8f6
94 files changed
+6518
-336
No files matched your search
@@ -1,5 +1,9 @@
|
||||
Если в окружении не доступен инструмент, например - Python, PHP, попробуй docker, если в проекте есть его файлы.
|
||||
|
||||
Тесты: гонять только те, что относятся к изменённому в текущем шаге. Полный
|
||||
прогон — только по моему явному указанию, никогда по своей инициативе. Полный
|
||||
набор идёт ~20 минут, и гонять его после каждой правки запрещено.
|
||||
|
||||
Без моего явного указания не меняй ничего!
|
||||
Если я задал вопросы, это не значит что ты можешь менять файлы!
|
||||
Любые правки только после моего явного указания, например - "делай".
|
||||
@@ -56,7 +60,11 @@ Engineering rules / обязательные практики:
|
||||
- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык.
|
||||
|
||||
Definition of Done:
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации: изменил
|
||||
presence — гоняешь тесты присутствия, изменил тексты — гоняешь каталог i18n.
|
||||
Полный прогон всех тестов выполнять ТОЛЬКО по явному указанию владельца.
|
||||
Использовать `--reuse-db`; `--create-db` — лишняя минута на переигрывание
|
||||
миграций, она нужна только когда схема действительно поменялась.
|
||||
- Измененные файлы должны быть проверены на NO GOD violations.
|
||||
- Новый компонент не должен владеть несвязанными ответственностями.
|
||||
- Не должно быть придуманных текстов, иконок, layout-решений или состояний вне design/docs.
|
||||
|
||||
@@ -22,6 +22,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
from chatballs.conversations.queue_models import policy_for
|
||||
from chatballs.conversations.selectors import apply_conversation_visibility
|
||||
from chatballs.conversations.serializers import conversation_payload
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
@@ -30,6 +31,7 @@ from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import can_administer_access
|
||||
from chatballs.presence import ONLINE_WITHIN_SECONDS, last_seen
|
||||
|
||||
|
||||
def _label_payload(label: ConversationLabel) -> dict[str, object]:
|
||||
@@ -258,10 +260,21 @@ class ConversationCountersView(ConversationViewBase):
|
||||
}
|
||||
for assignee in assignees:
|
||||
assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id)
|
||||
# Два разных ожидания (макет Q3): диалог ничей — взять может любой;
|
||||
# диалог назначен лично на меня и ждёт, пока я его возьму. Смешивать их
|
||||
# в одном счётчике значит прятать своё среди чужого.
|
||||
waiting_qs = open_qs.filter(control_mode=ControlMode.PAUSED)
|
||||
return Response(
|
||||
{
|
||||
"all": open_qs.count(),
|
||||
"waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(),
|
||||
"waiting": waiting_qs.count(),
|
||||
"queue": waiting_qs.filter(assigned_operator__isnull=True).count(),
|
||||
"waitingOnMe": waiting_qs.filter(assigned_operator_id=request.user.id).count(),
|
||||
# Срок личной очереди: по нему клиент считает, через сколько
|
||||
# диалог вернётся всем.
|
||||
"assignmentTimeoutMinutes": policy_for(
|
||||
request.tenant_context.organization
|
||||
).assignment_timeout_minutes,
|
||||
"mine": base.filter(assigned_operator_id=request.user.id).count(),
|
||||
"ungrouped": ungrouped,
|
||||
"groups": groups,
|
||||
@@ -301,6 +314,22 @@ class ConversationDirectoryView(APIView):
|
||||
# Ответственного можно назначить и вне выдачи — по поиску, поэтому
|
||||
# оставшихся не прячем молча, а сообщаем признаком hasMore.
|
||||
rows = list(members[: DIRECTORY_LIMIT + 1])
|
||||
shown = rows[:DIRECTORY_LIMIT]
|
||||
# Присутствие и загрузка — второй и третий признак при выборе
|
||||
# ответственного (макет «Очередь и уведомления», кадр Q5). Назначить
|
||||
# отсутствующего можно: признак приблизительный и ничего не запрещает.
|
||||
user_ids = [member.user_id for member in shown]
|
||||
seen = last_seen(organization_id, user_ids)
|
||||
now = timezone.now()
|
||||
load = dict(
|
||||
Conversation.objects.filter(
|
||||
organization_id=organization_id,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
assigned_operator_id__in=user_ids,
|
||||
)
|
||||
.values_list("assigned_operator_id")
|
||||
.annotate(total=Count("id"))
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups],
|
||||
@@ -309,8 +338,15 @@ class ConversationDirectoryView(APIView):
|
||||
"id": member.user_id,
|
||||
"name": member.user.full_name or member.user.email,
|
||||
"avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id),
|
||||
"role": member.role,
|
||||
"online": member.user_id in seen
|
||||
and (now - seen[member.user_id]).total_seconds() <= ONLINE_WITHIN_SECONDS,
|
||||
"lastSeenAt": (
|
||||
seen[member.user_id].isoformat() if member.user_id in seen else None
|
||||
),
|
||||
"openDialogs": load.get(member.user_id, 0),
|
||||
}
|
||||
for member in rows[:DIRECTORY_LIMIT]
|
||||
for member in shown
|
||||
],
|
||||
"hasMoreEmployees": len(rows) > DIRECTORY_LIMIT,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""WebSocket оповещений о диалогах (см. chatballs.conversations.realtime).
|
||||
"""WebSocket оповещений рабочего места (см. chatballs.conversations.realtime и
|
||||
chatballs.notifications.realtime).
|
||||
|
||||
Сокет один на сессию: по нему идут и события диалогов, и события уведомлений.
|
||||
Второй сокет ради второго источника означал бы второе переподключение, вторую
|
||||
аутентификацию и вторую точку отказа на ровном месте.
|
||||
|
||||
Правила:
|
||||
- аутентификация — сессией того же SPA (AuthMiddlewareStack), отдельного токена
|
||||
@@ -23,6 +28,8 @@ from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.realtime import conversation_group, inbox_group
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.notifications.realtime import user_group
|
||||
from chatballs.presence import touch
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_public_id
|
||||
|
||||
@@ -36,6 +43,8 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
self.organization_id: int | None = None
|
||||
self.membership_id: int | None = None
|
||||
self.watched: str | None = None
|
||||
self.personal: str | None = None
|
||||
self.user_id: int | None = None
|
||||
user = self.scope.get("user")
|
||||
if user is None or not user.is_authenticated:
|
||||
await self.close(code=NOT_A_MEMBER_CLOSE)
|
||||
@@ -46,20 +55,42 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.close(code=NOT_A_MEMBER_CLOSE)
|
||||
return
|
||||
self.organization_id, self.membership_id = resolved
|
||||
self.user_id = user.id
|
||||
# Открытый сокет и есть присутствие: ничего специально «включать» для
|
||||
# этого сотрудник не должен (chatballs.presence).
|
||||
await self._touch_presence()
|
||||
await self.channel_layer.group_add(inbox_group(self.organization_id), self.channel_name)
|
||||
# Уведомления адресованы человеку, а не организации: у каждого своя группа.
|
||||
self.personal = user_group(user.id)
|
||||
await self.channel_layer.group_add(self.personal, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, code: int) -> None:
|
||||
if self.organization_id is not None and self.user_id is not None:
|
||||
# Не «его нет», а «здесь он был в последний раз».
|
||||
await database_sync_to_async(touch)(self.organization_id, self.user_id)
|
||||
if self.organization_id is not None:
|
||||
await self.channel_layer.group_discard(
|
||||
inbox_group(self.organization_id), self.channel_name
|
||||
)
|
||||
if self.personal is not None:
|
||||
await self.channel_layer.group_discard(self.personal, self.channel_name)
|
||||
if self.watched is not None:
|
||||
await self.channel_layer.group_discard(self.watched, self.channel_name)
|
||||
|
||||
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||
"""Клиент сообщает, какой диалог открыт: событий по нему он и ждёт."""
|
||||
if content.get("type") != "watch" or self.organization_id is None:
|
||||
"""Клиент сообщает, какой диалог открыт: событий по нему он и ждёт.
|
||||
|
||||
Он же раз в минуту присылает heartbeat — по нему продлевается отметка
|
||||
присутствия. Без неё ключ истекает сам, и оборванное соединение
|
||||
перестаёт считаться живым без отдельного уборщика.
|
||||
"""
|
||||
if self.organization_id is None:
|
||||
return
|
||||
if content.get("type") == "ping":
|
||||
await self._touch_presence()
|
||||
return
|
||||
if content.get("type") != "watch":
|
||||
return
|
||||
conversation_id = content.get("conversationId")
|
||||
if self.watched is not None:
|
||||
@@ -80,6 +111,11 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def fanout(self, event: dict) -> None:
|
||||
await self.send_json(event["payload"])
|
||||
|
||||
async def _touch_presence(self) -> None:
|
||||
if self.organization_id is None or self.user_id is None:
|
||||
return
|
||||
await database_sync_to_async(touch)(self.organization_id, self.user_id)
|
||||
|
||||
@database_sync_to_async
|
||||
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Что происходит, когда диалог ждёт слишком долго.
|
||||
|
||||
Раньше не происходило ничего. Про ждущий диалог операторов окликали ровно один
|
||||
раз, и дальше `dedup_key` на сутки гарантировал тишину: если в тот момент никто
|
||||
не смотрел на экран, диалог мог провисеть до автозакрытия, и узнать об этом было
|
||||
неоткуда.
|
||||
|
||||
Уровень выбирается по времени ожидания, а не по счётчику попыток: состояние
|
||||
хранить не нужно, потому что повтор гасит тот же `dedup_key` — свой у каждого
|
||||
уровня. Свип идеемпотентен и может выполняться сколь угодно часто.
|
||||
|
||||
Назначенный диалог не эскалируется: он не в общей очереди, а в личной, и у неё
|
||||
свой срок — не взял, значит возвращаем всем.
|
||||
|
||||
Присутствие сокращает ожидание, но не заменяет его. Если в группе диалога сейчас
|
||||
никого нет за рабочим местом, ждать второго порога бессмысленно: напоминать
|
||||
некому, и круг расширяется сразу. Обратного правила нет — присутствие никого не
|
||||
задерживает и ничего не запрещает, потому что ошибается в обе стороны.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
|
||||
from chatballs.conversations.services import operator_label
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.models import (
|
||||
NotificationAudience,
|
||||
NotificationLevel,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.recipients import audience_user_ids
|
||||
from chatballs.notifications.services import notify, notify_management
|
||||
from chatballs.presence import online_user_ids
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _contact_name(conversation: Conversation) -> str:
|
||||
return getattr(conversation.contact, "name", "") or t("conversations.guest")
|
||||
|
||||
|
||||
def _waiting_conversations(context: TenantContext):
|
||||
return Conversation.objects.filter(
|
||||
organization=context.organization,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
waiting_since__isnull=False,
|
||||
).select_related("contact", "assigned_operator", "group")
|
||||
|
||||
|
||||
def sweep_waiting_conversations(context: TenantContext) -> int:
|
||||
"""Оклики по ждущим диалогам организации. Возвращает число новых уведомлений."""
|
||||
policy = policy_for(context.organization)
|
||||
now = timezone.now()
|
||||
fired = 0
|
||||
for conversation in _waiting_conversations(context):
|
||||
if conversation.assigned_operator_id:
|
||||
fired += _expire_stale_assignment(context, conversation, policy, now)
|
||||
else:
|
||||
fired += _escalate(context, conversation, policy, now)
|
||||
return fired
|
||||
|
||||
|
||||
def _nobody_is_watching(context: TenantContext, conversation: Conversation) -> bool:
|
||||
"""В группе диалога никого нет за рабочим местом."""
|
||||
watchers = audience_user_ids(
|
||||
organization_id=context.organization_id,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
group_id=conversation.group_id,
|
||||
)
|
||||
return not online_user_ids(context.organization_id, watchers)
|
||||
|
||||
|
||||
def _escalate(
|
||||
context: TenantContext,
|
||||
conversation: Conversation,
|
||||
policy: QueueEscalationPolicy,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
waited = now - conversation.waiting_since
|
||||
contact = _contact_name(conversation)
|
||||
common = {
|
||||
"context": context,
|
||||
"type": NotificationType.DIALOG_WAITING_LONG,
|
||||
"text_params": {"contact": contact},
|
||||
"target_id": conversation.id,
|
||||
"source_type": "Conversation",
|
||||
"source_id": conversation.id,
|
||||
}
|
||||
fired = 0
|
||||
if waited >= timedelta(minutes=policy.remind_after_minutes):
|
||||
# Тот же круг, что и в первый раз: смена на месте, просто не заметила.
|
||||
fired += notify(
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог всё ещё ждёт · {contact}",
|
||||
title_key="notifications.still_waiting",
|
||||
body_key="notifications.still_waiting_body",
|
||||
dedup_key=f"waiting:{conversation.id}:remind",
|
||||
**common,
|
||||
) is not None
|
||||
widen_after = policy.widen_after_minutes
|
||||
if waited >= timedelta(minutes=policy.remind_after_minutes) and _nobody_is_watching(
|
||||
context, conversation
|
||||
):
|
||||
# Некому заметить напоминание — второй порог ждать незачем.
|
||||
widen_after = min(widen_after, policy.remind_after_minutes)
|
||||
if waited >= timedelta(minutes=widen_after):
|
||||
# Круг шире группы: в своей группе ответить некому.
|
||||
fired += notify(
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=None,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог всё ещё ждёт · {contact}",
|
||||
title_key="notifications.still_waiting",
|
||||
body_key="notifications.still_waiting_body",
|
||||
dedup_key=f"waiting:{conversation.id}:widen",
|
||||
**common,
|
||||
) is not None
|
||||
if waited >= timedelta(minutes=policy.escalate_after_minutes):
|
||||
# Это уже не про сменщика, а про то, что смены нет.
|
||||
fired += notify_management(
|
||||
level=NotificationLevel.CRITICAL,
|
||||
title=f"Диалог никто не берёт · {contact}",
|
||||
title_key="notifications.waiting_unattended",
|
||||
body_key="notifications.waiting_unattended_body",
|
||||
dedup_key=f"waiting:{conversation.id}:management",
|
||||
**common,
|
||||
)
|
||||
return fired
|
||||
|
||||
|
||||
def _expire_stale_assignment(
|
||||
context: TenantContext,
|
||||
conversation: Conversation,
|
||||
policy: QueueEscalationPolicy,
|
||||
now: datetime,
|
||||
) -> int:
|
||||
if conversation.assigned_at is None:
|
||||
return 0
|
||||
if now - conversation.assigned_at < timedelta(minutes=policy.assignment_timeout_minutes):
|
||||
return 0
|
||||
label = operator_label(conversation.assigned_operator)
|
||||
conversation.assigned_operator = None
|
||||
conversation.assigned_at = None
|
||||
conversation.save(update_fields=["assigned_operator", "assigned_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.ASSIGNMENT_EXPIRED,
|
||||
system_params={"operator": label},
|
||||
text=f"{label} не взял диалог — он вернулся в очередь",
|
||||
)
|
||||
logger.info("Assignment on conversation %s expired", conversation.id)
|
||||
contact = _contact_name(conversation)
|
||||
return notify(
|
||||
context=context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
level=NotificationLevel.WARNING,
|
||||
title=f"Диалог снова ничей · {contact}",
|
||||
title_key="notifications.assignment_expired",
|
||||
text_params={"contact": contact, "operator": label},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"unassigned:{conversation.id}:{conversation.waiting_since.isoformat()}",
|
||||
) is not None
|
||||
@@ -30,6 +30,7 @@ from chatballs.conversations.models import (
|
||||
SystemEvent,
|
||||
TranscriptStatus,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, is_waiting
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.models import EventOwnership, InboxEvent
|
||||
from chatballs.i18n import t
|
||||
@@ -249,6 +250,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
external_chat_id=inbound.chat_id,
|
||||
control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED,
|
||||
expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR,
|
||||
waiting_since=None if ai_available else timezone.now(),
|
||||
previous_conversation=previous,
|
||||
)
|
||||
elif inbound.chat_id and not conversation.external_chat_id:
|
||||
@@ -282,9 +284,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.last_activity_at = timezone.now()
|
||||
update_fields = ["external_chat_id", "last_activity_at"]
|
||||
if conversation.control_mode == ControlMode.AI and not ai_available:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
update_fields.extend(["control_mode", "expected_responder"])
|
||||
enter_queue(conversation)
|
||||
update_fields.extend(QUEUE_FIELDS)
|
||||
if inbound.thread_meta:
|
||||
# Email: Message-ID последнего входящего — для ответа в тред;
|
||||
# тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035).
|
||||
@@ -298,10 +299,18 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
conversation.save(update_fields=update_fields)
|
||||
|
||||
if is_new:
|
||||
# Диалог, которым занялся агент, — это «новый диалог» и больше ничего.
|
||||
# Диалог, отвечать в котором некому, — уже просьба о человеке: событие
|
||||
# одно, а смысл для смены разный, и подписки на них тоже разные.
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=(
|
||||
NotificationType.OPERATOR_REQUESTED
|
||||
if is_waiting(conversation)
|
||||
else NotificationType.NEW_DIALOG
|
||||
),
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Новый диалог · {channel.name}",
|
||||
body=f"{contact.name or 'Гость'} · {integration.provider}: {message_text[:80]}",
|
||||
title_key="notifications.new_dialog",
|
||||
@@ -324,6 +333,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_NEW_MESSAGE,
|
||||
audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
recipient_user=operator,
|
||||
title=f"Новое сообщение · {contact.name or 'Гость'}",
|
||||
body=message_text[:120],
|
||||
@@ -353,15 +363,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
ai_input = transcribe_voice_message(channel, message)
|
||||
if (is_voice and not ai_input) or files_only:
|
||||
if conversation.control_mode == ControlMode.AI:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
conversation.save(update_fields=["control_mode", "expected_responder"])
|
||||
enter_queue(conversation)
|
||||
conversation.save(update_fields=QUEUE_FIELDS)
|
||||
if is_new:
|
||||
return
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
@@ -385,10 +395,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
|
||||
# оператору, уведомляем и отвечаем клиенту понятным fallback.
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
enter_queue(conversation)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
conversation.save(update_fields=["control_mode", "expected_responder", "last_activity_at"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
@@ -399,8 +408,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"Нужен оператор · {contact.name or 'Гость'}",
|
||||
title_key="notifications.operator_needed",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
@@ -413,7 +423,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
)
|
||||
notify_management(
|
||||
context=context,
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
title=f"Ошибка AI · {channel.name}",
|
||||
body="AI временно недоступен, диалог передан оператору",
|
||||
title_key="notifications.ai_error",
|
||||
@@ -435,11 +445,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=reply)
|
||||
conversation.last_activity_at = timezone.now()
|
||||
if handoff:
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
enter_queue(conversation)
|
||||
else:
|
||||
conversation.expected_responder = ExpectedResponder.CUSTOMER
|
||||
conversation.save(update_fields=["control_mode", "last_activity_at", "expected_responder"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "last_activity_at"])
|
||||
|
||||
if handoff:
|
||||
Message.objects.create(
|
||||
@@ -450,8 +459,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
)
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=conversation.group,
|
||||
title=f"AI передал диалог · {contact.name or 'Гость'}",
|
||||
title_key="notifications.ai_handed_over",
|
||||
text_params={"contact": contact.name or t("conversations.guest")},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Время ожидания в очереди — отдельным полем.
|
||||
|
||||
Раньше «дольше всех ждущий» вычислялся по времени последнего сообщения, и
|
||||
очередь работала обратно смыслу: клиент, напомнивший о себе, двигал
|
||||
last_message_at вперёд и падал в конец очереди. Поле ставится один раз при входе
|
||||
в очередь (chatballs.conversations.queue) и снимается при выходе из неё.
|
||||
|
||||
Backfill берёт last_message_at — единственное, что известно про уже ждущие
|
||||
диалоги. Для них порядок не ухудшится: в старой сортировке ключ был тот же.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
BACKFILL = """
|
||||
UPDATE conversations_conversation
|
||||
SET waiting_since = last_message_at
|
||||
WHERE lifecycle = 'OPEN' AND control_mode = 'PAUSED' AND waiting_since IS NULL
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0021_i18n_events'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='waiting_since',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop),
|
||||
migrations.AddIndex(
|
||||
model_name='conversation',
|
||||
index=models.Index(
|
||||
fields=['organization', 'waiting_since'], name='conv_waiting_order'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Личная очередь и пороги эскалации.
|
||||
|
||||
assigned_at нужен, чтобы у назначения был срок: waiting_since для этого не
|
||||
годится — назначить могут и через час после того, как диалог встал в очередь.
|
||||
Пороги — строка на организацию с дефолтами: «долго» у круглосуточной
|
||||
поддержки и у приёма по будням означает разное.
|
||||
"""
|
||||
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0022_conversation_waiting_since'),
|
||||
('identity', '0039_remove_organization_currency'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='assigned_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
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', 'Назначение истекло'), ('call_requested', 'Запрошен звонок'), ('call_accepted', 'Клиент принял приглашение'), ('call_declined', 'Клиент отклонил приглашение'), ('call_cancelled', 'Приглашение отменено'), ('call_missed', 'Звонок пропущен'), ('call_expired', 'Приглашение истекло'), ('call_started', 'Звонок начался'), ('call_ended', 'Звонок завершён'), ('call_failed', 'Звонок не состоялся')], default='', max_length=32),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QueueEscalationPolicy',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('remind_after_minutes', models.PositiveIntegerField(default=5)),
|
||||
('widen_after_minutes', models.PositiveIntegerField(default=15)),
|
||||
('escalate_after_minutes', models.PositiveIntegerField(default=30)),
|
||||
('assignment_timeout_minutes', models.PositiveIntegerField(default=10)),
|
||||
('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='queue_policy', to='identity.organization')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'conversations_queueescalationpolicy',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-13 19:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0023_queue_escalation'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='queueescalationpolicy',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='queueescalationpolicy',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -166,6 +166,10 @@ class Conversation(models.Model):
|
||||
lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN)
|
||||
control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI)
|
||||
expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI)
|
||||
# С какого момента диалог ждёт человека (chatballs.conversations.queue).
|
||||
# Не «последнее сообщение»: клиент, написавший повторно, ждёт не меньше, а
|
||||
# больше прежнего, и в очереди обязан оставаться выше, а не ниже.
|
||||
waiting_since = models.DateTimeField(null=True, blank=True)
|
||||
# Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при
|
||||
# создании, переносится вручную. NULL — диалог виден всем сотрудникам.
|
||||
group = models.ForeignKey(
|
||||
@@ -177,6 +181,9 @@ class Conversation(models.Model):
|
||||
)
|
||||
# «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп.
|
||||
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
|
||||
# Когда назначили. С этого момента идёт срок личной очереди: не взял —
|
||||
# диалог возвращается в общую (chatballs.conversations.escalation).
|
||||
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
|
||||
priority = models.CharField(
|
||||
max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE
|
||||
@@ -219,6 +226,10 @@ class Conversation(models.Model):
|
||||
models.Index(
|
||||
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
|
||||
),
|
||||
# Очередь к оператору: кто ждёт дольше всех и не дождался порога.
|
||||
models.Index(
|
||||
fields=["organization", "waiting_since"], name="conv_waiting_order"
|
||||
),
|
||||
]
|
||||
constraints = [
|
||||
# Диалог всегда принадлежит контакту.
|
||||
@@ -274,6 +285,8 @@ class SystemEvent(models.TextChoices):
|
||||
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", "Клиент отклонил приглашение"
|
||||
@@ -400,3 +413,11 @@ class ReplyTemplate(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"template:{self.organization_id}/{self.title}"
|
||||
|
||||
|
||||
# Django импортирует только models.py: пороги очереди лежат рядом, чтобы не
|
||||
# растить этот файл, и переэкспортируются здесь ради регистрации модели.
|
||||
from chatballs.conversations.queue_models import ( # noqa: E402, F401
|
||||
QueueEscalationPolicy,
|
||||
policy_for,
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Очередь к оператору: единственное место, где диалог в неё входит и выходит.
|
||||
|
||||
Правило «диалог ждёт человека» — это три поля сразу: control_mode = PAUSED,
|
||||
expected_responder = OPERATOR и момент, с которого пошло ожидание. Раньше первые
|
||||
два выставлялись в шести местах подряд (создание диалога без доступного AI,
|
||||
клиент написал в диалог без AI, голосовое без расшифровки, сбой провайдера,
|
||||
хендофф агента, ручной возврат оператором), а третьего не было вовсе: «дольше
|
||||
всех ждущий» считался по времени последнего сообщения.
|
||||
|
||||
Из-за этого очередь вела себя обратно смыслу. Клиент, который писал повторно,
|
||||
двигал last_message_at вперёд и падал в конец очереди: чем настойчивее человек,
|
||||
тем позже до него доходили руки. Поэтому waiting_since ставится один раз — при
|
||||
входе в очередь — и не обновляется, пока диалог из неё не вышел.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
)
|
||||
|
||||
# Что пишет enter_queue. Вызывающий кладёт это в update_fields своего save():
|
||||
# состояние диалога меняется вместе с остальными полями, одной записью.
|
||||
QUEUE_FIELDS = ("control_mode", "expected_responder", "waiting_since")
|
||||
|
||||
|
||||
def is_waiting(conversation: Conversation) -> bool:
|
||||
"""Диалог стоит в очереди к человеку.
|
||||
|
||||
Закрытый и спам тоже лежат в PAUSED, но никого не ждут — отсюда проверка
|
||||
жизненного цикла.
|
||||
"""
|
||||
return (
|
||||
conversation.lifecycle == LifecycleState.OPEN
|
||||
and conversation.control_mode == ControlMode.PAUSED
|
||||
)
|
||||
|
||||
|
||||
def enter_queue(conversation: Conversation, *, now: datetime | None = None) -> bool:
|
||||
"""Ставит диалог в очередь. True — если он в неё только что попал.
|
||||
|
||||
Возврат нужен вызывающему, чтобы решить, звать ли операторов: повторное
|
||||
сообщение клиента в уже ждущий диалог очередь не меняет и второго оклика не
|
||||
заслуживает.
|
||||
"""
|
||||
entered = not is_waiting(conversation)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
if entered:
|
||||
conversation.waiting_since = now or timezone.now()
|
||||
return entered
|
||||
|
||||
|
||||
def leave_queue(conversation: Conversation) -> None:
|
||||
"""Диалог больше никого не ждёт.
|
||||
|
||||
Несимметрично enter_queue намеренно: вход в очередь — одно состояние, а
|
||||
выходов несколько (оператор взял, диалог вернули AI, закрыли, пометили
|
||||
спамом), и control_mode у каждого свой. Общее у них только одно — ожидание
|
||||
закончилось, и его начало больше не имеет смысла.
|
||||
"""
|
||||
conversation.waiting_since = None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Пороги очереди: через сколько напоминать, расширять круг и звать руководство.
|
||||
|
||||
Числа разные у разных организаций — у круглосуточной поддержки хостинга и у
|
||||
клиники с приёмом по будням «долго» означает не одно и то же, — поэтому они
|
||||
настройка, а не константа в коде. Строка одна на организацию и заводится с
|
||||
дефолтами при первом обращении.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class QueueEscalationPolicy(models.Model):
|
||||
organization = models.OneToOneField(
|
||||
"identity.Organization", on_delete=models.CASCADE, related_name="queue_policy"
|
||||
)
|
||||
# Диалог ждёт дольше этого — повторный оклик той же группе.
|
||||
remind_after_minutes = models.PositiveIntegerField(default=5)
|
||||
# Ждёт ещё дольше — круг расширяется за пределы группы диалога.
|
||||
widen_after_minutes = models.PositiveIntegerField(default=15)
|
||||
# Совсем долго — это уже не про сменщика, а про руководство.
|
||||
escalate_after_minutes = models.PositiveIntegerField(default=30)
|
||||
# Назначили ответственного, а он не взял — диалог возвращается в общую
|
||||
# очередь. Без этого назначение работает как способ спрятать диалог: из
|
||||
# общей очереди он ушёл, а отвечать некому.
|
||||
assignment_timeout_minutes = models.PositiveIntegerField(default=10)
|
||||
# Кто и когда менял: в разделе настроек это подпись под формой. Сроки —
|
||||
# правило работы смены, и знать, чьё это решение, важнее, чем кажется.
|
||||
updated_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
|
||||
)
|
||||
|
||||
# Значения по умолчанию — они же «обычные сроки» в кнопке сброса.
|
||||
DEFAULTS = {
|
||||
"remind_after_minutes": 5,
|
||||
"widen_after_minutes": 15,
|
||||
"escalate_after_minutes": 30,
|
||||
"assignment_timeout_minutes": 10,
|
||||
}
|
||||
|
||||
class Meta:
|
||||
db_table = "conversations_queueescalationpolicy"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"queue-policy:{self.organization_id}"
|
||||
|
||||
|
||||
def policy_for(organization) -> QueueEscalationPolicy:
|
||||
policy, _ = QueueEscalationPolicy.objects.get_or_create(organization=organization)
|
||||
return policy
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Сроки очереди: раздел «Когда звать на помощь» (макет Q2).
|
||||
|
||||
До этого пороги правились только в служебной админке — то есть де-факто никем.
|
||||
Читает их тот, кто видит настройки; меняет — тот, кто ими управляет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy, policy_for
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.policy import ResourceScope, authorize
|
||||
|
||||
FIELDS = tuple(QueueEscalationPolicy.DEFAULTS)
|
||||
# Сутки: всё, что дольше, — это не «позвать на помощь», а другая задача.
|
||||
MAX_MINUTES = 24 * 60
|
||||
|
||||
|
||||
def _payload(policy: QueueEscalationPolicy) -> dict:
|
||||
author = policy.updated_by
|
||||
return {
|
||||
**{field: getattr(policy, field) for field in FIELDS},
|
||||
"defaults": dict(QueueEscalationPolicy.DEFAULTS),
|
||||
"updatedAt": policy.updated_at.isoformat() if policy.updated_at else None,
|
||||
"updatedBy": (author.full_name or author.email) if author else "",
|
||||
}
|
||||
|
||||
|
||||
class QueuePolicyView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
context = request.tenant_context
|
||||
if not authorize(context.membership, "settings.view", ResourceScope(context.organization_id)):
|
||||
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
|
||||
return Response(_payload(policy_for(context.organization)))
|
||||
|
||||
def patch(self, request: Request) -> Response:
|
||||
context = request.tenant_context
|
||||
if not authorize(context.membership, "settings.manage", ResourceScope(context.organization_id)):
|
||||
return Response({"detail": t("settings.queue_policy_forbidden")}, status=403)
|
||||
policy = policy_for(context.organization)
|
||||
changed = []
|
||||
for field in FIELDS:
|
||||
if field not in request.data:
|
||||
continue
|
||||
value = request.data[field]
|
||||
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= MAX_MINUTES:
|
||||
return Response({"detail": t("settings.queue_minutes_range")}, status=400)
|
||||
if getattr(policy, field) != value:
|
||||
setattr(policy, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
policy.updated_at = timezone.now()
|
||||
policy.updated_by = context.actor_user
|
||||
policy.save(update_fields=[*changed, "updated_at", "updated_by"])
|
||||
return Response(_payload(policy))
|
||||
@@ -13,12 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from chatballs.realtime import publish
|
||||
|
||||
INBOX_EVENT = "inbox.changed"
|
||||
CONVERSATION_EVENT = "conversation.changed"
|
||||
@@ -32,27 +27,12 @@ def conversation_group(conversation_id: int) -> str:
|
||||
return f"conv.{conversation_id}"
|
||||
|
||||
|
||||
def _publish(group: str, payload: dict[str, object]) -> None:
|
||||
"""Оповещение — вспомогательный путь: его сбой не должен ронять запись.
|
||||
|
||||
Сообщение уже сохранено к моменту отправки; если канал недоступен, клиент
|
||||
узнает об изменении следующим опросом — он остаётся как запасной путь.
|
||||
"""
|
||||
layer = get_channel_layer()
|
||||
if layer is None:
|
||||
return
|
||||
try:
|
||||
async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload})
|
||||
except Exception: # noqa: BLE001 — канал не должен ломать сохранение
|
||||
logger.warning("realtime fanout failed for %s", group, exc_info=True)
|
||||
|
||||
|
||||
def notify_inbox_changed(organization_id: int) -> None:
|
||||
_publish(inbox_group(organization_id), {"type": INBOX_EVENT})
|
||||
publish(inbox_group(organization_id), {"type": INBOX_EVENT})
|
||||
|
||||
|
||||
def notify_conversation_changed(conversation_id: int, *, organization_id: int) -> None:
|
||||
_publish(
|
||||
publish(
|
||||
conversation_group(conversation_id),
|
||||
{"type": CONVERSATION_EVENT, "conversationId": conversation_id},
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.db.models import (
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from chatballs.api.pagination import SortKey
|
||||
from chatballs.conversations.models import (
|
||||
@@ -63,6 +64,11 @@ def order_conversations(
|
||||
Сортировка живёт на сервере вместе с окном: клиент видит не весь набор, и
|
||||
переставлять в браузере ему нечего. `waiting` — «ждущие человека первыми,
|
||||
дольше всех ждущий выше», остальные — по убыванию активности.
|
||||
|
||||
«Дольше всех ждущий» считается от waiting_since — момента, когда диалог
|
||||
встал в очередь. По времени последнего сообщения его считать нельзя: клиент,
|
||||
написавший повторно, двигал бы себя в конец очереди, и чем настойчивее он
|
||||
напоминал о себе, тем позже до него доходила очередь.
|
||||
"""
|
||||
if sort != "waiting":
|
||||
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
|
||||
@@ -72,7 +78,9 @@ def order_conversations(
|
||||
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
|
||||
),
|
||||
_wait_at=Case(
|
||||
When(waits, then=F("last_message_at")),
|
||||
# Coalesce — страховка для строк, встававших в очередь до появления
|
||||
# waiting_since: без неё они ушли бы в конец очереди вместо начала.
|
||||
When(waits, then=Coalesce(F("waiting_since"), F("last_message_at"))),
|
||||
default=Value(_NOT_WAITING_AT),
|
||||
output_field=DateTimeField(),
|
||||
),
|
||||
|
||||
@@ -257,6 +257,17 @@ def conversation_payload(
|
||||
"isAssignedToViewer": bool(
|
||||
viewer_id and conversation.assigned_operator_id == viewer_id
|
||||
),
|
||||
# Ожидание (макет «Очередь и уведомления», кадры Q3 и Q4): с какого
|
||||
# момента диалог ждёт человека и с какого — конкретного человека.
|
||||
# Считать «сколько осталось» клиент должен сам: минута на сервере и
|
||||
# минута на экране расходятся, и показывать замерший счётчик хуже, чем
|
||||
# не показывать никакого.
|
||||
"waitingSince": (
|
||||
conversation.waiting_since.isoformat() if conversation.waiting_since else None
|
||||
),
|
||||
"assignedAt": (
|
||||
conversation.assigned_at.isoformat() if conversation.assigned_at else None
|
||||
),
|
||||
"group": (
|
||||
{"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color}
|
||||
if conversation.group_id
|
||||
|
||||
@@ -13,9 +13,12 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -37,7 +40,7 @@ def _require_open(conversation: Conversation) -> None:
|
||||
|
||||
|
||||
|
||||
def _operator_label(operator) -> str:
|
||||
def operator_label(operator) -> str:
|
||||
|
||||
return getattr(operator, "full_name", "") or operator.email
|
||||
|
||||
@@ -101,7 +104,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
|
||||
|
||||
Message.objects.create(
|
||||
|
||||
@@ -111,9 +116,9 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat
|
||||
|
||||
system_event=SystemEvent.OPERATOR_TOOK,
|
||||
|
||||
system_params={"operator": _operator_label(operator)},
|
||||
system_params={"operator": operator_label(operator)},
|
||||
|
||||
text=f"Оператор {_operator_label(operator)} перехватил диалог",
|
||||
text=f"Оператор {operator_label(operator)} перехватил диалог",
|
||||
|
||||
)
|
||||
|
||||
@@ -147,7 +152,9 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.AI
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder", "waiting_since"])
|
||||
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
@@ -176,13 +183,12 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa
|
||||
|
||||
_require_open(conversation)
|
||||
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
enter_queue(conversation)
|
||||
|
||||
conversation.assigned_operator = None
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
|
||||
conversation.save(update_fields=[*QUEUE_FIELDS, "assigned_operator"])
|
||||
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
@@ -337,6 +343,8 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -348,6 +356,7 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -383,6 +392,8 @@ def mark_conversation_as_spam(
|
||||
|
||||
conversation.expected_responder = ExpectedResponder.NOBODY
|
||||
|
||||
leave_queue(conversation)
|
||||
|
||||
conversation.save(
|
||||
|
||||
update_fields=[
|
||||
@@ -394,6 +405,7 @@ def mark_conversation_as_spam(
|
||||
"assigned_operator",
|
||||
|
||||
"expected_responder",
|
||||
"waiting_since",
|
||||
|
||||
]
|
||||
|
||||
@@ -401,3 +413,53 @@ def mark_conversation_as_spam(
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def assign_operator(*, context: TenantContext, conversation_id: int, assignee) -> Conversation:
|
||||
"""Назначить ответственного за диалог (или снять назначение).
|
||||
|
||||
Назначение — не взятие: человек ещё не ответил и мог даже не увидеть
|
||||
диалог. Но из общей очереди диалог уходит — отвечать в нём, кроме
|
||||
назначенного и руководства, уже никто не может, — поэтому назначенного надо
|
||||
позвать лично и поставить срок. Не успел — диалог возвращается в общую
|
||||
очередь (chatballs.conversations.escalation).
|
||||
|
||||
Раньше эта операция молча писала внешний ключ: назначенный не узнавал,
|
||||
диалог продолжал числиться в общей очереди, а взять его оттуда было уже
|
||||
нельзя.
|
||||
"""
|
||||
conversation = Conversation.objects.select_for_update().get(
|
||||
id=conversation_id, organization=context.organization
|
||||
)
|
||||
_require_open(conversation)
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.assigned_at = timezone.now() if assignee is not None else None
|
||||
conversation.save(update_fields=["assigned_operator", "assigned_at"])
|
||||
if assignee is None:
|
||||
return conversation
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
author_type=MessageAuthor.SYSTEM,
|
||||
system_event=SystemEvent.ASSIGNED_TO,
|
||||
system_params={"operator": operator_label(assignee)},
|
||||
text=f"Диалог назначен на {operator_label(assignee)}",
|
||||
)
|
||||
# Себе назначил — сам и знает.
|
||||
if context.actor_user is not None and assignee.pk == context.actor_user.pk:
|
||||
return conversation
|
||||
contact_name = getattr(conversation.contact, "name", "") or t("conversations.guest")
|
||||
notify(
|
||||
context=context,
|
||||
type=NotificationType.DIALOG_ASSIGNED,
|
||||
audience=NotificationAudience.USER,
|
||||
recipient_user=assignee,
|
||||
title=f"Вам назначен диалог · {contact_name}",
|
||||
title_key="notifications.assigned_to_you",
|
||||
text_params={"contact": contact_name},
|
||||
target_id=conversation.id,
|
||||
source_type="Conversation",
|
||||
source_id=conversation.id,
|
||||
dedup_key=f"assign:{conversation.id}:{assignee.pk}",
|
||||
)
|
||||
return conversation
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Справочник выбора ответственного: ограниченная выдача и поиск на сервере."""
|
||||
"""Справочник выбора ответственного: ограниченная выдача, поиск и присутствие."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.chat_extras_views import DIRECTORY_LIMIT
|
||||
from chatballs.conversations.models import Contact, Conversation, LifecycleState
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -10,6 +17,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.presence import touch
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
@@ -54,3 +62,65 @@ class ConversationDirectoryTests(TestCase):
|
||||
|
||||
def test_groups_are_returned_as_before(self) -> None:
|
||||
self.assertIn("groups", self._directory())
|
||||
|
||||
|
||||
class DirectoryPresenceTests(TestCase):
|
||||
"""Присутствие и загрузка в выборе ответственного (макет Q5).
|
||||
|
||||
Назначить отсутствующего можно — признак ничего не запрещает; он лишь
|
||||
отвечает на вопрос «кто сейчас за рабочим местом».
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
cache.clear()
|
||||
self.organization = Organization.objects.create(name="Example", slug="directory-presence")
|
||||
self.owner = self._employee("owner@dir.test", EmployeeRole.OWNER)
|
||||
self.away = self._employee("away@dir.test", EmployeeRole.EMPLOYEE)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner.user)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _rows(self) -> dict[int, dict]:
|
||||
response = self.client.get("/api/v1/conversations/directory/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return {row["id"]: row for row in response.json()["employees"]}
|
||||
|
||||
def test_presence_is_reported_for_whoever_is_in_the_app(self) -> None:
|
||||
touch(self.organization.id, self.owner.user_id)
|
||||
rows = self._rows()
|
||||
self.assertTrue(rows[self.owner.user_id]["online"])
|
||||
self.assertIsNotNone(rows[self.owner.user_id]["lastSeenAt"])
|
||||
self.assertFalse(rows[self.away.user_id]["online"])
|
||||
self.assertIsNone(rows[self.away.user_id]["lastSeenAt"])
|
||||
|
||||
def test_long_gone_employee_is_not_online_but_remembered(self) -> None:
|
||||
with mock.patch(
|
||||
"chatballs.presence.timezone.now",
|
||||
return_value=timezone.now() - timedelta(minutes=25),
|
||||
):
|
||||
touch(self.organization.id, self.away.user_id)
|
||||
row = self._rows()[self.away.user_id]
|
||||
self.assertFalse(row["online"])
|
||||
self.assertIsNotNone(row["lastSeenAt"])
|
||||
|
||||
def test_load_counts_only_open_dialogs_of_that_person(self) -> None:
|
||||
channel = Channel.objects.create(
|
||||
organization=self.organization, code="dir-presence", name="Канал"
|
||||
)
|
||||
contact = Contact.objects.create(organization=self.organization, name="Клиент")
|
||||
for lifecycle in (LifecycleState.OPEN, LifecycleState.OPEN, LifecycleState.CLOSED):
|
||||
Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=channel,
|
||||
contact=contact,
|
||||
lifecycle=lifecycle,
|
||||
assigned_operator=self.owner.user,
|
||||
)
|
||||
rows = self._rows()
|
||||
self.assertEqual(rows[self.owner.user_id]["openDialogs"], 2)
|
||||
self.assertEqual(rows[self.away.user_id]["openDialogs"], 0)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Эскалация ждущих диалогов и личная очередь назначенного.
|
||||
|
||||
Регрессия, ради которой всё это заведено: про ждущий диалог операторов окликали
|
||||
ровно один раз, и дальше суточный dedup гарантировал тишину. Назначение при этом
|
||||
молча писало внешний ключ — назначенный не узнавал, а из общей очереди диалог
|
||||
уже ушёл.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.escalation import sweep_waiting_conversations
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
SystemEvent,
|
||||
)
|
||||
from chatballs.conversations.queue_models import policy_for
|
||||
from chatballs.conversations.services import assign_operator
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.notifications.models import Notification, NotificationAudience
|
||||
from chatballs.presence import touch
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class QueueTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="queue-escalation")
|
||||
self.support = EmployeeGroup.objects.create(
|
||||
organization=self.organization, name="Поддержка"
|
||||
)
|
||||
self.owner = self._employee("owner@queue.test", EmployeeRole.OWNER)
|
||||
self.operator = self._employee("operator@queue.test", EmployeeRole.EMPLOYEE)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=self.support, employee=self.operator
|
||||
)
|
||||
self.context = tenant_context_for(self.owner.user, self.organization)
|
||||
self.policy = policy_for(self.organization)
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, group=self.support, code="esc", name="Очередь"
|
||||
)
|
||||
self.conversation = self._waiting_conversation()
|
||||
# Обычный случай: смена на месте. Пустую смену проверяет
|
||||
# PresenceEscalationTests, и она её задаёт явно.
|
||||
cache.clear()
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _waiting_conversation(self) -> Conversation:
|
||||
contact = Contact.objects.create(organization=self.organization, name="Клиент")
|
||||
return Conversation.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
group=self.support,
|
||||
contact=contact,
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
expected_responder=ExpectedResponder.OPERATOR,
|
||||
waiting_since=timezone.now(),
|
||||
)
|
||||
|
||||
def _wait_for(self, minutes: int) -> None:
|
||||
Conversation.objects.filter(pk=self.conversation.pk).update(
|
||||
waiting_since=timezone.now() - timedelta(minutes=minutes)
|
||||
)
|
||||
|
||||
def _dedup_keys(self) -> set[str]:
|
||||
return set(
|
||||
Notification.objects.filter(organization=self.organization).values_list(
|
||||
"dedup_key", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
class QueueEscalationTests(QueueTestBase):
|
||||
def test_fresh_dialog_is_not_escalated(self) -> None:
|
||||
self.assertEqual(sweep_waiting_conversations(self.context), 0)
|
||||
self.assertEqual(self._dedup_keys(), set())
|
||||
|
||||
def test_reminder_goes_to_the_group_of_the_dialog(self) -> None:
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
self.assertEqual(sweep_waiting_conversations(self.context), 1)
|
||||
reminder = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:remind")
|
||||
self.assertEqual(reminder.audience, NotificationAudience.OPERATORS)
|
||||
self.assertEqual(reminder.audience_group_id, self.support.id)
|
||||
|
||||
def test_circle_widens_beyond_the_group(self) -> None:
|
||||
self._wait_for(self.policy.widen_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
widened = Notification.objects.get(dedup_key=f"waiting:{self.conversation.id}:widen")
|
||||
self.assertIsNone(widened.audience_group_id)
|
||||
|
||||
def test_management_is_called_last(self) -> None:
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
management = Notification.objects.filter(
|
||||
dedup_key__startswith=f"waiting:{self.conversation.id}:management"
|
||||
)
|
||||
self.assertEqual(
|
||||
{item.recipient_user_id for item in management}, {self.owner.user_id}
|
||||
)
|
||||
|
||||
def test_second_sweep_does_not_repeat_itself(self) -> None:
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
first = sweep_waiting_conversations(self.context)
|
||||
second = sweep_waiting_conversations(self.context)
|
||||
self.assertGreater(first, 0)
|
||||
self.assertEqual(second, 0)
|
||||
|
||||
def test_assigned_dialog_is_not_escalated_to_everyone(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
self._wait_for(self.policy.escalate_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertEqual(
|
||||
{key for key in self._dedup_keys() if key.startswith("waiting:")}, set()
|
||||
)
|
||||
|
||||
|
||||
class AssignmentTests(QueueTestBase):
|
||||
def test_assignment_notifies_the_assignee_and_writes_history(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
notification = Notification.objects.get(
|
||||
dedup_key=f"assign:{self.conversation.id}:{self.operator.user_id}"
|
||||
)
|
||||
self.assertEqual(notification.audience, NotificationAudience.USER)
|
||||
self.assertEqual(notification.recipient_user_id, self.operator.user_id)
|
||||
self.assertEqual(
|
||||
list(self.conversation.messages.values_list("system_event", flat=True)),
|
||||
[SystemEvent.ASSIGNED_TO],
|
||||
)
|
||||
|
||||
def test_assigning_to_yourself_does_not_ping_you(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.owner.user,
|
||||
)
|
||||
self.assertFalse(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"assign:{self.conversation.id}:{self.owner.user_id}"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_unclaimed_assignment_returns_the_dialog_to_the_queue(self) -> None:
|
||||
assign_operator(
|
||||
context=self.context,
|
||||
conversation_id=self.conversation.id,
|
||||
assignee=self.operator.user,
|
||||
)
|
||||
Conversation.objects.filter(pk=self.conversation.pk).update(
|
||||
assigned_at=timezone.now()
|
||||
- timedelta(minutes=self.policy.assignment_timeout_minutes + 1)
|
||||
)
|
||||
sweep_waiting_conversations(self.context)
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertIsNone(self.conversation.assigned_operator_id)
|
||||
self.assertIsNone(self.conversation.assigned_at)
|
||||
# Ожидание не обнулилось: клиент ждёт с того же момента, что и ждал.
|
||||
self.assertIsNotNone(self.conversation.waiting_since)
|
||||
self.assertIn(
|
||||
SystemEvent.ASSIGNMENT_EXPIRED,
|
||||
set(self.conversation.messages.values_list("system_event", flat=True)),
|
||||
)
|
||||
|
||||
|
||||
class PresenceEscalationTests(QueueTestBase):
|
||||
"""Присутствие сокращает ожидание, но ничего не запрещает."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
# Начинаем с пустой смены: за рабочим местом нет никого.
|
||||
cache.clear()
|
||||
|
||||
def test_circle_widens_at_the_first_threshold_when_nobody_is_online(self) -> None:
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_present_operator_keeps_the_second_threshold(self) -> None:
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
# Напомнили группе, но круг не расширили: в группе есть кому ответить.
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:remind"
|
||||
).exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_presence_never_delays_the_wider_circle(self) -> None:
|
||||
touch(self.organization.id, self.operator.user_id)
|
||||
self._wait_for(self.policy.widen_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_operator_of_another_group_does_not_count_as_present(self) -> None:
|
||||
outsider = self._employee("outsider@queue.test", EmployeeRole.EMPLOYEE)
|
||||
touch(self.organization.id, outsider.user_id)
|
||||
self._wait_for(self.policy.remind_after_minutes + 1)
|
||||
sweep_waiting_conversations(self.context)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
dedup_key=f"waiting:{self.conversation.id}:widen"
|
||||
).exists()
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Очередь к оператору: с какого момента идёт ожидание и в каком порядке разбирают.
|
||||
|
||||
Регрессия, ради которой заведён waiting_since: «дольше всех ждущий» считался по
|
||||
времени последнего сообщения, поэтому клиент, напомнивший о себе, уезжал в конец
|
||||
очереди. Чем настойчивее человек, тем позже до него доходили руки.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import AIAgent
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import ControlMode, Conversation
|
||||
from chatballs.conversations.selectors import order_conversations
|
||||
from chatballs.conversations.services import (
|
||||
claim_conversation,
|
||||
close_conversation,
|
||||
return_to_queue,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationType
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class QueueTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.owner = HumanUser.objects.get(email="owner@example.com")
|
||||
self.context = tenant_context_for(self.owner, self.organization)
|
||||
# Канал без активного агента: каждый входящий сразу создаёт очередь.
|
||||
self.channel = Channel.objects.create(
|
||||
organization=self.organization, code="queue-order", name="Очередь"
|
||||
)
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="queue-bot",
|
||||
channel=self.channel,
|
||||
)
|
||||
|
||||
def _ingest(self, external_id: str, chat_id: str, text: str) -> None:
|
||||
inbound = InboundMessage(
|
||||
external_id=external_id,
|
||||
user_id=chat_id,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
display_name=chat_id,
|
||||
)
|
||||
with (
|
||||
mock.patch("chatballs.conversations.ingest.run_channel_turn"),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply"),
|
||||
):
|
||||
ingest_inbound(self.integration, inbound)
|
||||
|
||||
def _waiting_order(self) -> list[int]:
|
||||
ordered, _ = order_conversations(
|
||||
Conversation.objects.filter(organization=self.organization), "waiting"
|
||||
)
|
||||
return list(ordered.values_list("id", flat=True))
|
||||
|
||||
|
||||
|
||||
class QueueOrderTests(QueueTestBase):
|
||||
def test_new_dialog_without_agent_starts_waiting(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertEqual(conversation.control_mode, ControlMode.PAUSED)
|
||||
self.assertIsNotNone(conversation.waiting_since)
|
||||
|
||||
def test_reminder_from_customer_does_not_restart_the_wait(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
started = conversation.waiting_since
|
||||
|
||||
self._ingest("m-2", "chat-1", "Ну что там?")
|
||||
|
||||
conversation.refresh_from_db()
|
||||
self.assertEqual(conversation.waiting_since, started)
|
||||
# Свежесть диалога при этом обновилась — ожидание и активность разные вещи.
|
||||
self.assertGreater(conversation.last_message_at, started)
|
||||
|
||||
def test_longest_waiting_goes_first_even_after_a_reminder(self) -> None:
|
||||
self._ingest("a-1", "chat-a", "Первый вопрос")
|
||||
first = Conversation.objects.get(external_chat_id="chat-a")
|
||||
# Первый диалог ждёт заметно дольше второго.
|
||||
Conversation.objects.filter(pk=first.pk).update(
|
||||
waiting_since=timezone.now() - timedelta(hours=2)
|
||||
)
|
||||
self._ingest("b-1", "chat-b", "Второй вопрос")
|
||||
second = Conversation.objects.get(external_chat_id="chat-b")
|
||||
|
||||
# …и именно он напоминает о себе, двигая своё последнее сообщение вперёд.
|
||||
self._ingest("a-2", "chat-a", "Всё ещё жду")
|
||||
|
||||
self.assertEqual(self._waiting_order(), [first.id, second.id])
|
||||
|
||||
def test_claim_ends_the_wait_and_return_to_queue_starts_a_new_one(self) -> None:
|
||||
self._ingest("m-1", "chat-1", "Здравствуйте")
|
||||
conversation = self.channel.conversations.get()
|
||||
first_wait = conversation.waiting_since
|
||||
|
||||
claimed = claim_conversation(context=self.context, conversation_id=conversation.id)
|
||||
self.assertEqual(claimed.control_mode, ControlMode.HUMAN)
|
||||
self.assertIsNone(claimed.waiting_since)
|
||||
|
||||
returned = return_to_queue(context=self.context, conversation_id=conversation.id)
|
||||
self.assertEqual(returned.control_mode, ControlMode.PAUSED)
|
||||
self.assertIsNotNone(returned.waiting_since)
|
||||
# Ожидание началось заново: диалог успел побывать у оператора.
|
||||
self.assertGreater(returned.waiting_since, first_wait)
|
||||
|
||||
def test_closed_dialog_leaves_the_queue(self) -> None:
|
||||
self._ingest("z-1", "chat-z", "Вопрос")
|
||||
conversation = self.channel.conversations.get()
|
||||
self.assertIsNotNone(conversation.waiting_since)
|
||||
|
||||
closed = close_conversation(context=self.context, conversation_id=conversation.id)
|
||||
self.assertIsNone(closed.waiting_since)
|
||||
|
||||
|
||||
class NewDialogNotificationTests(QueueTestBase):
|
||||
"""«Новый диалог» и «клиент запросил оператора» — разные события.
|
||||
|
||||
Диалог начинается и на канале с работающим агентом, где человека никто не
|
||||
звал. Назвать такой оклик просьбой о человеке — соврать тому, кто на него
|
||||
подписан.
|
||||
"""
|
||||
|
||||
def _last_type(self) -> str:
|
||||
return (
|
||||
Notification.objects.filter(organization=self.organization)
|
||||
.order_by("-id")
|
||||
.values_list("type", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
def test_dialog_without_an_agent_asks_for_a_person(self) -> None:
|
||||
self._ingest("n-1", "chat-n", "Здравствуйте")
|
||||
self.assertEqual(self._last_type(), NotificationType.OPERATOR_REQUESTED)
|
||||
|
||||
def test_dialog_handled_by_the_agent_is_just_a_new_dialog(self) -> None:
|
||||
agent = AIAgent.objects.create(
|
||||
organization=self.organization,
|
||||
channel=self.channel,
|
||||
name="Консультант",
|
||||
is_active=True,
|
||||
)
|
||||
self.assertTrue(agent.is_active)
|
||||
with mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
return_value=mock.Mock(text="Здравствуйте!"),
|
||||
), mock.patch("chatballs.conversations.ingest.transports.send_reply"):
|
||||
ingest_inbound(
|
||||
self.integration,
|
||||
InboundMessage(
|
||||
external_id="a-1",
|
||||
user_id="chat-a",
|
||||
chat_id="chat-a",
|
||||
text="Здравствуйте",
|
||||
display_name="chat-a",
|
||||
),
|
||||
)
|
||||
self.assertEqual(self._last_type(), NotificationType.NEW_DIALOG)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Сроки очереди через API (макет Q2).
|
||||
|
||||
До этого пороги правились только в служебной админке — то есть де-факто никем.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.conversations.queue_models import QueueEscalationPolicy
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
URL = "/api/v1/conversations/queue-policy/"
|
||||
|
||||
|
||||
class QueuePolicyApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="queue-policy")
|
||||
self.owner = self._employee("owner@policy.test", EmployeeRole.OWNER)
|
||||
self.operator = self._employee("operator@policy.test", EmployeeRole.EMPLOYEE)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner.user)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def test_first_read_returns_usual_delays(self) -> None:
|
||||
response = self.client.get(URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["remind_after_minutes"], 5)
|
||||
self.assertEqual(payload["assignment_timeout_minutes"], 10)
|
||||
self.assertEqual(payload["defaults"], payload["defaults"] | {"widen_after_minutes": 15})
|
||||
self.assertIsNone(payload["updatedAt"])
|
||||
|
||||
def test_saving_remembers_who_and_when(self) -> None:
|
||||
response = self.client.patch(URL, {"remind_after_minutes": 3}, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["remind_after_minutes"], 3)
|
||||
self.assertEqual(payload["updatedBy"], self.owner.user.full_name or self.owner.user.email)
|
||||
self.assertIsNotNone(payload["updatedAt"])
|
||||
self.assertEqual(QueueEscalationPolicy.objects.get().remind_after_minutes, 3)
|
||||
|
||||
def test_nonsense_values_are_refused(self) -> None:
|
||||
for value in (0, -5, 10000, "быстро", True):
|
||||
with self.subTest(value=value):
|
||||
response = self.client.patch(URL, {"widen_after_minutes": value}, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(QueueEscalationPolicy.objects.get().widen_after_minutes, 15)
|
||||
|
||||
def test_operator_reads_but_does_not_change(self) -> None:
|
||||
self.client.force_authenticate(self.operator.user)
|
||||
self.assertEqual(self.client.get(URL).status_code, 403)
|
||||
self.assertEqual(
|
||||
self.client.patch(URL, {"remind_after_minutes": 1}, format="json").status_code, 403
|
||||
)
|
||||
@@ -421,7 +421,7 @@ class WebchatContactTests(TestCase):
|
||||
)
|
||||
self.assertTrue(
|
||||
Notification.objects.filter(
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
target_id=str(conversation.id),
|
||||
).exists()
|
||||
@@ -429,7 +429,7 @@ class WebchatContactTests(TestCase):
|
||||
self.assertEqual(
|
||||
set(
|
||||
Notification.objects.filter(
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
audience=NotificationAudience.USER,
|
||||
target_id=str(conversation.id),
|
||||
).values_list("recipient_user__email", flat=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from chatballs.conversations import (
|
||||
attachment_views,
|
||||
chat_extras_views,
|
||||
queue_policy_views,
|
||||
reporting_views,
|
||||
views,
|
||||
voice_views,
|
||||
@@ -30,6 +31,7 @@ urlpatterns = [
|
||||
path("<int:conversation_id>/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"),
|
||||
path("<int:conversation_id>/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"),
|
||||
path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"),
|
||||
path("queue-policy/", queue_policy_views.QueuePolicyView.as_view(), name="conversation-queue-policy"),
|
||||
path("directory/", chat_extras_views.ConversationDirectoryView.as_view(), name="conversation-directory"),
|
||||
path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"),
|
||||
path("labels/<int:label_id>/", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"),
|
||||
|
||||
@@ -30,6 +30,7 @@ from chatballs.conversations.serializers import (
|
||||
)
|
||||
from chatballs.conversations.services import (
|
||||
ClaimError,
|
||||
assign_operator,
|
||||
claim_conversation,
|
||||
close_conversation,
|
||||
mark_conversation_as_spam,
|
||||
@@ -87,6 +88,21 @@ class ConversationListView(ConversationViewBase):
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED
|
||||
)
|
||||
# Две вкладки ожидания (макет «Очередь и уведомления», кадр Q3): общая
|
||||
# очередь — диалоги без ответственного, их берёт кто угодно; «на мне» —
|
||||
# назначенные лично и ждущие, пока их возьмут.
|
||||
if params.get("queue") == "1":
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
assigned_operator__isnull=True,
|
||||
)
|
||||
if params.get("waitingOnMe") == "1":
|
||||
items = items.filter(
|
||||
lifecycle=LifecycleState.OPEN,
|
||||
control_mode=ControlMode.PAUSED,
|
||||
assigned_operator_id=request.user.id,
|
||||
)
|
||||
query = params.get("q", "").strip()
|
||||
if query:
|
||||
from django.contrib.postgres.search import SearchQuery, SearchVector
|
||||
@@ -469,8 +485,11 @@ class ConversationAssigneeView(ConversationViewBase):
|
||||
if membership is None:
|
||||
return Response({"detail": t("admin.employee_not_found")}, status=400)
|
||||
assignee = membership.user
|
||||
conversation.assigned_operator = assignee
|
||||
conversation.save(update_fields=["assigned_operator"])
|
||||
conversation = assign_operator(
|
||||
context=request.tenant_context,
|
||||
conversation_id=conversation.id,
|
||||
assignee=assignee,
|
||||
)
|
||||
self._audit(request, "assignee_changed", conversation)
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.calls.maintenance import expire_stale_calls
|
||||
from chatballs.conversations.escalation import sweep_waiting_conversations
|
||||
from chatballs.conversations.maintenance import close_stale_conversations
|
||||
from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
@@ -22,6 +23,9 @@ logger = logging.getLogger(__name__)
|
||||
MESSENGER_POLL_INTERVAL = 3.0 # seconds between messenger long-poll cycles
|
||||
MAINTENANCE_INTERVAL = 3600.0 # seconds between maintenance cycles (auto-close stale dialogs)
|
||||
CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry, stuck connect)
|
||||
# Пороги очереди задаются в минутах, поэтому раз в полминуты — с запасом:
|
||||
# проверка дешёвая, а повтор гасится dedup-ключом уровня.
|
||||
QUEUE_SWEEP_INTERVAL = 30.0 # seconds between waiting-queue escalation sweeps
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -39,6 +43,7 @@ class Command(BaseCommand):
|
||||
last_poll = 0.0
|
||||
last_maintenance = 0.0
|
||||
last_call_sweep = 0.0
|
||||
last_queue_sweep = 0.0
|
||||
while True:
|
||||
try:
|
||||
event = claim_next_outbox_event()
|
||||
@@ -91,6 +96,14 @@ class Command(BaseCommand):
|
||||
expire_stale_calls(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_queue_sweep >= QUEUE_SWEEP_INTERVAL:
|
||||
last_queue_sweep = now
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
sweep_waiting_conversations(context)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Waiting queue sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
|
||||
@@ -54,6 +54,8 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.not_found": "Conversation not found",
|
||||
"conversations.note_too_long": "The note is longer than 4000 characters",
|
||||
"conversations.restore_admin_only": "Only an administrator can restore a conversation",
|
||||
"conversations.system.assigned_to": "Conversation assigned to {operator}",
|
||||
"conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue",
|
||||
"conversations.system.ai_handed_over": "AI handed the conversation to an operator",
|
||||
"conversations.system.ai_unavailable": "AI is unavailable — the conversation was handed to an operator",
|
||||
"conversations.system.call_accepted": "The customer accepted the call invitation",
|
||||
@@ -96,6 +98,12 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_dialog_body": "{contact} · {provider}: {preview}",
|
||||
"notifications.new_message": "New message · {contact}",
|
||||
"notifications.operator_needed": "An operator is needed · {contact}",
|
||||
"notifications.still_waiting": "Conversation is still waiting · {contact}",
|
||||
"notifications.still_waiting_body": "Nobody has picked this conversation up. Open it if you can reply.",
|
||||
"notifications.waiting_unattended": "Nobody is picking this conversation up · {contact}",
|
||||
"notifications.waiting_unattended_body": "The customer has been waiting longer than usual and the queue is not being worked.",
|
||||
"notifications.assigned_to_you": "A conversation was assigned to you · {contact}",
|
||||
"notifications.assignment_expired": "Conversation is unassigned again · {contact}",
|
||||
"notifications.voice_without_transcript": "A voice message without a transcript",
|
||||
"onboarding.membership_required": "Onboarding is available to organization members",
|
||||
"onboarding.unknown_action": "Unknown onboarding action",
|
||||
@@ -103,6 +111,10 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
|
||||
"portals.file_not_found": "File not found",
|
||||
"portals.helpful_must_be_boolean": "helpful must be a boolean",
|
||||
"portals.installation_domain_missing": (
|
||||
"The installation address has no domain: set one in Settings, "
|
||||
"otherwise there is nowhere to host the portal"
|
||||
),
|
||||
"portals.invalid_article": "Invalid article",
|
||||
"portals.invalid_article_data": "Invalid article data",
|
||||
"portals.invalid_category": "Invalid category",
|
||||
@@ -111,10 +123,6 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_revision": "Invalid revision",
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.installation_domain_missing": (
|
||||
"The installation address has no domain: set one in Settings, "
|
||||
"otherwise there is nowhere to host the portal"
|
||||
),
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
@@ -349,6 +357,10 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
"notifications.ids_list_or_all": "ids must be a list or use all=true",
|
||||
"notifications.push_types_list": "pushTypes must be a list",
|
||||
"notifications.enabled_bool": "enabled must be true or false",
|
||||
"settings.queue_minutes_range": "The delay is set in minutes, from 1 to 1440",
|
||||
"settings.queue_policy_forbidden": "Only someone who manages settings can change these delays",
|
||||
"notifications.unknown_transport": "Unknown delivery channel",
|
||||
"platform.idempotency_key_required": "Idempotency-Key header is required",
|
||||
"portals.articles_non_empty": "articles must be a non-empty list",
|
||||
"profile.current_password_invalid": "Current password is invalid",
|
||||
@@ -481,10 +493,12 @@ MESSAGES: dict[str, object] = {
|
||||
"ai.unexpected_provider_response": "Unexpected provider response: {error}",
|
||||
"settings.base_url_rejected": "Base URL: {error}",
|
||||
"settings.proxy_url_rejected": "Proxy: {error}",
|
||||
"notifications.type_dialog_waiting": "A dialog is waiting for an operator",
|
||||
"notifications.type_dialog_new_message": "A new message in a dialog",
|
||||
"notifications.type_release_published": "An agent release was published",
|
||||
"notifications.type_integration_error": "Integration error",
|
||||
"notifications.type_new_dialog": "A new conversation",
|
||||
"notifications.type_operator_requested": "A customer asked for a person",
|
||||
"notifications.type_dialog_new_message": "A new message in my conversation",
|
||||
"notifications.type_dialog_assigned": "A conversation was assigned to me",
|
||||
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
|
||||
"notifications.type_ai_stopped": "AI stopped by an error or a limit",
|
||||
"integrations.check_no_connection": "No connection: {error}",
|
||||
"integrations.check_api_key_missing": "The API key is not set",
|
||||
"integrations.check_base_url_missing": "Base URL is not set",
|
||||
|
||||
@@ -59,6 +59,8 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.note_too_long": "Заметка длиннее 4000 символов",
|
||||
"conversations.restore_admin_only": "Восстановить диалог может только администратор",
|
||||
"conversations.system.ai_handed_over": "AI передал диалог оператору",
|
||||
"conversations.system.assigned_to": "Диалог назначен на {operator}",
|
||||
"conversations.system.assignment_expired": "{operator} не взял диалог — он вернулся в очередь",
|
||||
"conversations.system.ai_unavailable": "AI недоступен — диалог передан оператору",
|
||||
"conversations.system.call_accepted": "Клиент принял приглашение на звонок",
|
||||
"conversations.system.call_cancelled": "Сотрудник отменил приглашение на звонок",
|
||||
@@ -101,12 +103,22 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "Новое сообщение · {contact}",
|
||||
"notifications.operator_needed": "Нужен оператор · {contact}",
|
||||
"notifications.voice_without_transcript": "Голосовое без расшифровки",
|
||||
"notifications.still_waiting": "Диалог всё ещё ждёт · {contact}",
|
||||
"notifications.still_waiting_body": "Диалог никто не взял. Откройте его, если можете ответить.",
|
||||
"notifications.waiting_unattended": "Диалог никто не берёт · {contact}",
|
||||
"notifications.waiting_unattended_body": "Клиент ждёт ответа дольше обычного, и очередь не разбирают.",
|
||||
"notifications.assigned_to_you": "Вам назначен диалог · {contact}",
|
||||
"notifications.assignment_expired": "Диалог снова ничей · {contact}",
|
||||
"onboarding.membership_required": "Онбординг доступен участнику организации",
|
||||
"onboarding.unknown_action": "Неизвестное действие онбординга",
|
||||
"portals.article_not_found": "Статья не найдена",
|
||||
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"portals.file_not_found": "Файл не найден",
|
||||
"portals.helpful_must_be_boolean": "helpful должен быть boolean",
|
||||
"portals.installation_domain_missing": (
|
||||
"Адрес установки задан без домена: укажите домен в «Настройках», "
|
||||
"иначе портал негде разместить"
|
||||
),
|
||||
"portals.invalid_article": "Некорректная статья",
|
||||
"portals.invalid_article_data": "Некорректные данные статьи",
|
||||
"portals.invalid_category": "Некорректная категория",
|
||||
@@ -115,10 +127,6 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_revision": "Некорректная версия",
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.installation_domain_missing": (
|
||||
"Адрес установки задан без домена: укажите домен в «Настройках», "
|
||||
"иначе портал негде разместить"
|
||||
),
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
@@ -353,6 +361,10 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
"notifications.ids_list_or_all": "ids — список, либо all=true",
|
||||
"notifications.push_types_list": "pushTypes — список",
|
||||
"notifications.enabled_bool": "enabled — да или нет",
|
||||
"settings.queue_minutes_range": "Срок указывается в минутах: от 1 до 1440",
|
||||
"settings.queue_policy_forbidden": "Менять сроки может тот, кто управляет настройками",
|
||||
"notifications.unknown_transport": "Неизвестный способ доставки",
|
||||
"platform.idempotency_key_required": "Нужен заголовок Idempotency-Key",
|
||||
"portals.articles_non_empty": "articles — непустой список",
|
||||
"profile.current_password_invalid": "Текущий пароль неверен",
|
||||
@@ -485,10 +497,12 @@ MESSAGES: dict[str, object] = {
|
||||
"ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}",
|
||||
"settings.base_url_rejected": "Base URL: {error}",
|
||||
"settings.proxy_url_rejected": "Прокси: {error}",
|
||||
"notifications.type_dialog_waiting": "Диалог ждёт оператора",
|
||||
"notifications.type_dialog_new_message": "Новое сообщение в диалоге",
|
||||
"notifications.type_release_published": "Опубликован релиз агента",
|
||||
"notifications.type_integration_error": "Ошибка интеграции",
|
||||
"notifications.type_new_dialog": "Новый диалог",
|
||||
"notifications.type_operator_requested": "Клиент запросил оператора",
|
||||
"notifications.type_dialog_new_message": "Новое сообщение в моём диалоге",
|
||||
"notifications.type_dialog_assigned": "Диалог назначили на меня",
|
||||
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
|
||||
"notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом",
|
||||
"integrations.check_no_connection": "Нет связи: {error}",
|
||||
"integrations.check_api_key_missing": "Не указан API-ключ",
|
||||
"integrations.check_base_url_missing": "Не указан Base URL",
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
],
|
||||
"notifications": [
|
||||
{
|
||||
"type": "DIALOG_WAITING",
|
||||
"type": "OPERATOR_REQUESTED",
|
||||
"level": "WARNING",
|
||||
"title": "Dmitry Orlov is waiting for an operator",
|
||||
"body": "Web Chat: the client asked for a member of staff. Waiting 6 min.",
|
||||
@@ -107,7 +107,7 @@
|
||||
"minutesAgo": 4
|
||||
},
|
||||
{
|
||||
"type": "INTEGRATION_ERROR",
|
||||
"type": "AI_STOPPED",
|
||||
"level": "CRITICAL",
|
||||
"title": "Telegram · delivery bot: connection error",
|
||||
"body": "401 Unauthorized: bot token was revoked in @BotFather. Check the token in the connection settings.",
|
||||
@@ -116,7 +116,7 @@
|
||||
"minutesAgo": 4200
|
||||
},
|
||||
{
|
||||
"type": "RELEASE_PUBLISHED",
|
||||
"type": "DIALOG_ASSIGNED",
|
||||
"level": "SUCCESS",
|
||||
"title": "Chatballs update: voice messages",
|
||||
"body": "Operators can send voice messages to Telegram and MAX, and incoming ones are transcribed with a single button.",
|
||||
@@ -136,9 +136,9 @@
|
||||
"connection": "tg",
|
||||
"externalChatId": "482900010",
|
||||
"pushTypes": [
|
||||
"DIALOG_WAITING",
|
||||
"OPERATOR_REQUESTED",
|
||||
"DIALOG_NEW_MESSAGE",
|
||||
"INTEGRATION_ERROR"
|
||||
"AI_STOPPED"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -146,7 +146,7 @@
|
||||
"connection": "tg",
|
||||
"externalChatId": "482900011",
|
||||
"pushTypes": [
|
||||
"DIALOG_WAITING"
|
||||
"OPERATOR_REQUESTED"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
],
|
||||
"notifications": [
|
||||
{
|
||||
"type": "DIALOG_WAITING",
|
||||
"type": "OPERATOR_REQUESTED",
|
||||
"level": "WARNING",
|
||||
"title": "Дмитрий Орлов ждёт оператора",
|
||||
"body": "Web Chat: клиент попросил сотрудника. Ждёт 6 мин.",
|
||||
@@ -107,7 +107,7 @@
|
||||
"minutesAgo": 4
|
||||
},
|
||||
{
|
||||
"type": "INTEGRATION_ERROR",
|
||||
"type": "AI_STOPPED",
|
||||
"level": "CRITICAL",
|
||||
"title": "Telegram · бот доставки: ошибка подключения",
|
||||
"body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.",
|
||||
@@ -116,7 +116,7 @@
|
||||
"minutesAgo": 4200
|
||||
},
|
||||
{
|
||||
"type": "RELEASE_PUBLISHED",
|
||||
"type": "DIALOG_ASSIGNED",
|
||||
"level": "SUCCESS",
|
||||
"title": "Обновление Chatballs: голосовые сообщения",
|
||||
"body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.",
|
||||
@@ -136,9 +136,9 @@
|
||||
"connection": "tg",
|
||||
"externalChatId": "482900010",
|
||||
"pushTypes": [
|
||||
"DIALOG_WAITING",
|
||||
"OPERATOR_REQUESTED",
|
||||
"DIALOG_NEW_MESSAGE",
|
||||
"INTEGRATION_ERROR"
|
||||
"AI_STOPPED"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -146,7 +146,7 @@
|
||||
"connection": "tg",
|
||||
"externalChatId": "482900011",
|
||||
"pushTypes": [
|
||||
"DIALOG_WAITING"
|
||||
"OPERATOR_REQUESTED"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ from chatballs.calls.models import (
|
||||
ParticipantConnectionState,
|
||||
ParticipantSide,
|
||||
)
|
||||
from chatballs.conversations.queue_models import policy_for
|
||||
from chatballs.identity.demo_seed import manifest
|
||||
from chatballs.identity.demo_seed.loaders.common import backdate, moment, now
|
||||
from chatballs.identity.demo_seed.refs import DemoRefs
|
||||
@@ -28,7 +29,9 @@ from chatballs.notifications.models import (
|
||||
NotificationAudience,
|
||||
NotificationLevel,
|
||||
NotificationRead,
|
||||
NotificationTransport,
|
||||
)
|
||||
from chatballs.notifications.preferences import update_preference
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
FINISHED = {
|
||||
@@ -44,6 +47,9 @@ FINISHED = {
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("operations", refs.language)
|
||||
current = now()
|
||||
# Пороги очереди — данные организации, а не установки: демо-организация
|
||||
# заводит их со значениями по умолчанию, как завела бы живая.
|
||||
policy_for(refs.organization)
|
||||
for item in data.get("calls", []):
|
||||
_ensure_call(refs, item, current)
|
||||
for item in data.get("notifications", []):
|
||||
@@ -55,9 +61,15 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
defaults={
|
||||
"organization": refs.organization,
|
||||
"external_chat_id": item["externalChatId"],
|
||||
"push_types": item.get("pushTypes", []),
|
||||
},
|
||||
)
|
||||
update_preference(
|
||||
organization_id=refs.organization.id,
|
||||
user_id=refs.users[item["user"]].id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
enabled=True,
|
||||
types=item.get("pushTypes", []),
|
||||
)
|
||||
for item in data.get("messengerBindingCodes", []):
|
||||
MessengerBindingCode.objects.get_or_create(
|
||||
code=item["code"],
|
||||
|
||||
@@ -15,6 +15,12 @@ from chatballs.identity.models import EmployeeRole, OrganizationMembership
|
||||
# сохраняется; scope-модель и отделы упразднены.
|
||||
|
||||
|
||||
# Руководство организации: видит всё независимо от групп. Набор один и тот же
|
||||
# в проверке доступа, в видимости диалогов и в подборе получателей уведомлений,
|
||||
# поэтому записан здесь один раз.
|
||||
MANAGEMENT_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceScope:
|
||||
organization_id: int
|
||||
@@ -47,6 +53,18 @@ def authorize(actor, capability: str, resource_scope: ResourceScope) -> bool:
|
||||
return capability in _role_capabilities(profile.role)
|
||||
|
||||
|
||||
def roles_with_capability(capability: str) -> frozenset[str]:
|
||||
"""Роли, которым право выдано.
|
||||
|
||||
Нужна там, где получателей ищут пачкой: спрашивать authorize() по одному
|
||||
сотруднику — это запрос в базу на каждого, то есть сотня запросов на сотню
|
||||
рабочих мест. Право выводится из роли, значит и отбирать можно по роли.
|
||||
"""
|
||||
return frozenset(
|
||||
role for role in EmployeeRole.values if capability in _role_capabilities(role)
|
||||
)
|
||||
|
||||
|
||||
def has_capability_any_scope(actor, capability: str) -> bool:
|
||||
profile = _active_membership(actor)
|
||||
if profile is None:
|
||||
@@ -56,7 +74,7 @@ def has_capability_any_scope(actor, capability: str) -> bool:
|
||||
|
||||
def can_administer_access(actor) -> bool:
|
||||
profile = _active_membership(actor)
|
||||
return profile is not None and profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}
|
||||
return profile is not None and profile.role in MANAGEMENT_ROLES
|
||||
|
||||
|
||||
def conversation_visibility(actor) -> dict | None:
|
||||
@@ -69,7 +87,7 @@ def conversation_visibility(actor) -> dict | None:
|
||||
profile = _active_membership(actor)
|
||||
if profile is None:
|
||||
return {"none": True}
|
||||
if profile.role in {EmployeeRole.OWNER, EmployeeRole.ADMIN}:
|
||||
if profile.role in MANAGEMENT_ROLES:
|
||||
return None
|
||||
from chatballs.identity.group_models import member_group_ids
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ from django.test import TransactionTestCase
|
||||
|
||||
|
||||
class MembershipMigrationTests(TransactionTestCase):
|
||||
"""Перенос сотрудников на членства (identity/0012) не теряет идентификаторы.
|
||||
|
||||
Тест уводит базу к состоянию до переноса, поэтому обязан вернуть её обратно
|
||||
за собой. Иначе всё, что запускается после него в той же сессии, работает
|
||||
на схеме годичной давности: откат по identity утягивает и зависимые
|
||||
миграции других приложений, и чем их больше, тем разрушительнее последствия.
|
||||
"""
|
||||
|
||||
migrate_from = [("identity", "0011_enforce_capability_registry")]
|
||||
migrate_to = [("identity", "0012_membership_identity")]
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ from chatballs.conversations import transports
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.i18n import customer_language, first_chosen, normalize_language, t
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import MessengerBinding, MessengerBindingCode
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
MessengerBindingCode,
|
||||
NotificationTransport,
|
||||
)
|
||||
from chatballs.notifications.preferences import update_preference
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,6 +115,13 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) -
|
||||
integration=integration,
|
||||
defaults={"external_chat_id": inbound.chat_id or inbound.user_id},
|
||||
)
|
||||
# Привязка — и есть согласие получать: до неё звать было просто некуда.
|
||||
update_preference(
|
||||
organization_id=integration.organization_id,
|
||||
user_id=binding_code.user_id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
enabled=True,
|
||||
)
|
||||
binding_code.delete()
|
||||
# Подтверждение читает конкретный сотрудник — язык берём из его профиля.
|
||||
done = t(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Доставка уведомлений в мессенджеры через сервисных ботов.
|
||||
|
||||
Событие notifications.notification_created кладётся в outbox из notify() и
|
||||
обрабатывается worker'ом: аудитория разворачивается в получателей (зеркало
|
||||
selectors.visible_for), их привязки получают сообщение через транспорт бота.
|
||||
обрабатывается worker'ом: аудитория разворачивается в получателей
|
||||
(chatballs.notifications.recipients), их привязки получают сообщение через
|
||||
транспорт бота.
|
||||
|
||||
Отправка best-effort: сбой одной привязки логируется и не валит событие.
|
||||
"""
|
||||
|
||||
@@ -12,15 +14,13 @@ import logging
|
||||
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationLevel,
|
||||
)
|
||||
from chatballs.notifications.selectors import visible_for
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.notifications.preferences import messenger_types
|
||||
from chatballs.notifications.recipients import recipient_user_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,21 +34,6 @@ _LEVEL_MARK = {
|
||||
}
|
||||
|
||||
|
||||
def _recipient_user_ids(notification: Notification) -> list[int]:
|
||||
if notification.audience == NotificationAudience.USER:
|
||||
return [notification.recipient_user_id] if notification.recipient_user_id else []
|
||||
profiles = OrganizationMembership.objects.filter(
|
||||
organization_id=notification.organization_id,
|
||||
blocked_at__isnull=True,
|
||||
user__is_active=True,
|
||||
).select_related("user")
|
||||
return [
|
||||
profile.user_id
|
||||
for profile in profiles
|
||||
if visible_for(TenantContext.for_membership(profile)).filter(id=notification.id).exists()
|
||||
]
|
||||
|
||||
|
||||
def _message_text(notification: Notification) -> str:
|
||||
mark = _LEVEL_MARK.get(notification.level, "🔔")
|
||||
lines = [f"{mark} {notification.title}"]
|
||||
@@ -60,8 +45,14 @@ def _message_text(notification: Notification) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def deliver_notification(notification: Notification) -> int:
|
||||
user_ids = _recipient_user_ids(notification)
|
||||
def deliver_notification(notification: Notification, *, user_ids: list[int] | None = None) -> int:
|
||||
"""Рассылает уведомление в привязанные мессенджеры.
|
||||
|
||||
Получателей можно передать готовыми: тот же список нужен и оповещению
|
||||
открытого приложения, а считать его дважды на каждое уведомление незачем.
|
||||
"""
|
||||
if user_ids is None:
|
||||
user_ids = recipient_user_ids(notification)
|
||||
if not user_ids:
|
||||
return 0
|
||||
bindings = (
|
||||
@@ -71,7 +62,10 @@ def deliver_notification(notification: Notification) -> int:
|
||||
text = _message_text(notification)
|
||||
sent = 0
|
||||
for binding in bindings:
|
||||
if notification.type not in (binding.push_types or []):
|
||||
allowed = messenger_types(
|
||||
organization_id=notification.organization_id, user_id=binding.user_id
|
||||
)
|
||||
if notification.type not in allowed:
|
||||
continue
|
||||
try:
|
||||
if transports.send_reply(binding.integration, chat_id=binding.external_chat_id, user_id="", text=text):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.notifications.delivery import NOTIFICATION_CREATED, deliver_notification
|
||||
from chatballs.notifications.models import Notification
|
||||
from chatballs.notifications.realtime import notify_notifications_changed
|
||||
from chatballs.notifications.recipients import recipient_user_ids
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -11,5 +13,11 @@ def handle_notification_created(payload: dict, context: TenantContext | None) ->
|
||||
notification = Notification.objects.filter(
|
||||
pk=payload.get("notificationId"), organization=context.organization
|
||||
).first()
|
||||
if notification is not None:
|
||||
deliver_notification(notification)
|
||||
if notification is None:
|
||||
return
|
||||
# Получатели считаются один раз на оба пути доставки.
|
||||
user_ids = recipient_user_ids(notification)
|
||||
# Сначала те, кто сидит в приложении: это мгновенно и ничего не стоит.
|
||||
# Мессенджер — следом, он медленнее и ходит наружу.
|
||||
notify_notifications_changed(user_ids)
|
||||
deliver_notification(notification, user_ids=user_ids)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Граница видимости уведомления — та же, что у диалога.
|
||||
|
||||
Раньше уведомления фильтровались только правом «видеть диалоги», а сами диалоги
|
||||
— ещё и группой (ADR-CHATBALLS-0043 §4). Из-за расхождения оператор одной группы
|
||||
получал оклик с именем клиента и куском переписки по диалогу другой группы и,
|
||||
перейдя по нему, упирался в «диалог не найден».
|
||||
|
||||
Старые записи остаются с NULL: группу диалога, из которого они родились, задним
|
||||
числом восстанавливать нечем, а NULL здесь означает прежнее поведение — видно
|
||||
всем, кого пропускает аудитория.
|
||||
"""
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('identity', '0020_employeegroup_employeegroupmember_and_more'),
|
||||
('notifications', '0010_i18n_events'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='audience_group',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='notifications',
|
||||
to='identity.employeegroup',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Настройка оклика уезжает из привязки к мессенджеру в отдельную сущность.
|
||||
|
||||
Пока транспорт был один, список типов на привязке работал. Со вторым (браузер) в
|
||||
профиле оказались бы два списка галочек про одно и то же, и человек, отключивший
|
||||
«новое сообщение», продолжал бы получать его с другой стороны.
|
||||
|
||||
Порядок операций здесь важен: сначала новая таблица, потом перенос, и только
|
||||
потом удаление старого поля. Django сгенерировал обратный порядок — в нём
|
||||
настройки всех, кто подключил бота, молча обнулились бы.
|
||||
"""
|
||||
|
||||
import chatballs.notifications.models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
MESSENGER = "MESSENGER"
|
||||
|
||||
|
||||
def carry_over_push_types(apps, schema_editor):
|
||||
MessengerBinding = apps.get_model("notifications", "MessengerBinding")
|
||||
NotificationPreference = apps.get_model("notifications", "NotificationPreference")
|
||||
rows = []
|
||||
seen = set()
|
||||
for binding in MessengerBinding.objects.all().iterator():
|
||||
# Мессенджер у сотрудника один на организацию, но привязок в базе могло
|
||||
# остаться несколько: берём первую и не плодим дубли под уникальным ключом.
|
||||
key = (binding.organization_id, binding.user_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append(
|
||||
NotificationPreference(
|
||||
organization_id=binding.organization_id,
|
||||
user_id=binding.user_id,
|
||||
transport=MESSENGER,
|
||||
# Привязка существует — значит, согласие получать уже дано.
|
||||
enabled=True,
|
||||
types=binding.push_types or [],
|
||||
)
|
||||
)
|
||||
NotificationPreference.objects.bulk_create(rows, ignore_conflicts=True)
|
||||
|
||||
|
||||
def restore_push_types(apps, schema_editor):
|
||||
MessengerBinding = apps.get_model("notifications", "MessengerBinding")
|
||||
NotificationPreference = apps.get_model("notifications", "NotificationPreference")
|
||||
by_user = {
|
||||
(preference.organization_id, preference.user_id): preference.types
|
||||
for preference in NotificationPreference.objects.filter(transport=MESSENGER)
|
||||
}
|
||||
for binding in MessengerBinding.objects.all().iterator():
|
||||
types = by_user.get((binding.organization_id, binding.user_id))
|
||||
if types is not None:
|
||||
binding.push_types = types
|
||||
binding.save(update_fields=["push_types"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('identity', '0039_remove_organization_currency'),
|
||||
('notifications', '0011_notification_audience_group'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationPreference',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transport', models.CharField(choices=[('BROWSER', 'Браузер'), ('MESSENGER', 'Мессенджер')], max_length=16)),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('types', models.JSONField(blank=True, default=chatballs.notifications.models.default_push_types)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notification_preferences', to='identity.organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('organization', 'user', 'transport'), name='uniq_notification_preference')],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(carry_over_push_types, restore_push_types),
|
||||
migrations.RemoveField(
|
||||
model_name='messengerbinding',
|
||||
name='push_types',
|
||||
),
|
||||
]
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""Набор типов событий — из макета «Очередь и уведомления», фрейм Q1.
|
||||
|
||||
Раньше «клиент запросил оператора» и «диалог долго ждёт» были одним типом
|
||||
DIALOG_WAITING, и отписаться от второго, не потеряв первое, было нельзя. Теперь
|
||||
это разные события, и к ним добавилось «диалог назначили на меня».
|
||||
|
||||
Старые записи разделяются по dedup_key — единственному, что о них известно
|
||||
задним числом: ключи эскалации начинаются с «waiting:», ключи назначения — с
|
||||
«assign:». Всё остальное, что было DIALOG_WAITING, — это просьба о человеке.
|
||||
|
||||
Выбор сотрудника в настройках переносится по тому же соответствию, иначе человек после
|
||||
обновления молча перестал бы получать то, на что подписан.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
# Старый код -> новый. RELEASE_PUBLISHED не переносится: он был заделом на
|
||||
# будущее, никогда не отправлялся и в новом наборе ему места нет.
|
||||
OLD_TO_NEW = {
|
||||
"DIALOG_WAITING": "OPERATOR_REQUESTED",
|
||||
"INTEGRATION_ERROR": "AI_STOPPED",
|
||||
}
|
||||
|
||||
SPLIT_BY_DEDUP_PREFIX = (
|
||||
("waiting:", "DIALOG_WAITING_LONG"),
|
||||
("assign:", "DIALOG_ASSIGNED"),
|
||||
)
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
for old, new in OLD_TO_NEW.items():
|
||||
Notification.objects.filter(type=old).update(type=new)
|
||||
for prefix, new in SPLIT_BY_DEDUP_PREFIX:
|
||||
Notification.objects.filter(
|
||||
type="OPERATOR_REQUESTED", dedup_key__startswith=prefix
|
||||
).update(type=new)
|
||||
_remap_preferences(apps, {**OLD_TO_NEW})
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
reverse = {
|
||||
"OPERATOR_REQUESTED": "DIALOG_WAITING",
|
||||
"DIALOG_WAITING_LONG": "DIALOG_WAITING",
|
||||
"DIALOG_ASSIGNED": "DIALOG_WAITING",
|
||||
"AI_STOPPED": "INTEGRATION_ERROR",
|
||||
}
|
||||
for new, old in reverse.items():
|
||||
Notification.objects.filter(type=new).update(type=old)
|
||||
_remap_preferences(apps, reverse)
|
||||
|
||||
|
||||
def _remap_preferences(apps, mapping):
|
||||
"""Подписки сотрудников: коды внутри JSON-списка."""
|
||||
NotificationPreference = apps.get_model("notifications", "NotificationPreference")
|
||||
for preference in NotificationPreference.objects.all().iterator():
|
||||
types = preference.types or []
|
||||
moved = [mapping.get(code, code) for code in types]
|
||||
# Дубли возможны, когда два старых кода сходятся в один новый.
|
||||
unique = list(dict.fromkeys(moved))
|
||||
if unique != types:
|
||||
preference.types = unique
|
||||
preference.save(update_fields=["types"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0012_notification_preference'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, backward),
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('OPERATOR_REQUESTED', 'Клиент запросил оператора'),
|
||||
('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'),
|
||||
('DIALOG_ASSIGNED', 'Диалог назначили на меня'),
|
||||
('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'),
|
||||
('AI_STOPPED', 'AI остановлен ошибкой или лимитом'),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""«Новый диалог» — отдельное событие (макет Q1, дополнение владельца).
|
||||
|
||||
В присланном наборе такого типа не было, и оклик о новом диалоге попал в
|
||||
«клиент запросил оператора». Но диалог начинается и на канале с работающим
|
||||
агентом, где человека никто не звал: называть это просьбой о человеке — врать.
|
||||
|
||||
Старые записи узнаются по dedup_key «dialog:» — так помечены именно оклики о
|
||||
новом диалоге. Разделить их по тому, был ли тогда доступен агент, задним числом
|
||||
нельзя, поэтому все они становятся «новым диалогом»: это верно для
|
||||
подавляющего большинства (канал с агентом — обычный случай) и, в отличие от
|
||||
обратного, ничего не приписывает клиенту.
|
||||
|
||||
Подписки: кому приходило «клиент запросил оператора», тому добавляется и «новый
|
||||
диалог». До этой миграции это было одно событие, и человек не должен молча
|
||||
перестать получать половину того, на что был подписан.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
Notification.objects.filter(
|
||||
type="OPERATOR_REQUESTED", dedup_key__startswith="dialog:"
|
||||
).update(type="NEW_DIALOG")
|
||||
NotificationPreference = apps.get_model("notifications", "NotificationPreference")
|
||||
for preference in NotificationPreference.objects.all().iterator():
|
||||
types = preference.types or []
|
||||
if "OPERATOR_REQUESTED" in types and "NEW_DIALOG" not in types:
|
||||
preference.types = ["NEW_DIALOG", *types]
|
||||
preference.save(update_fields=["types"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
Notification.objects.filter(type="NEW_DIALOG").update(type="OPERATOR_REQUESTED")
|
||||
NotificationPreference = apps.get_model("notifications", "NotificationPreference")
|
||||
for preference in NotificationPreference.objects.all().iterator():
|
||||
types = preference.types or []
|
||||
if "NEW_DIALOG" in types:
|
||||
kept = [code for code in types if code != "NEW_DIALOG"]
|
||||
preference.types = kept if "OPERATOR_REQUESTED" in kept else ["OPERATOR_REQUESTED", *kept]
|
||||
preference.save(update_fields=["types"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0013_notification_types_from_baseline'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('NEW_DIALOG', 'Новый диалог'),
|
||||
('OPERATOR_REQUESTED', 'Клиент запросил оператора'),
|
||||
('DIALOG_NEW_MESSAGE', 'Новое сообщение в моём диалоге'),
|
||||
('DIALOG_ASSIGNED', 'Диалог назначили на меня'),
|
||||
('DIALOG_WAITING_LONG', 'Диалог долго ждёт человека'),
|
||||
('AI_STOPPED', 'AI остановлен ошибкой или лимитом'),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forward, backward),
|
||||
]
|
||||
@@ -8,11 +8,24 @@ from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
DIALOG_WAITING = "DIALOG_WAITING", "Диалог ждёт оператора"
|
||||
DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в диалоге"
|
||||
# Задел на будущее (добавляются записью в реестр notifications.services.TYPE_META):
|
||||
RELEASE_PUBLISHED = "RELEASE_PUBLISHED", "Опубликован релиз агента"
|
||||
INTEGRATION_ERROR = "INTEGRATION_ERROR", "Ошибка интеграции"
|
||||
"""Что произошло. Набор — из макета «Очередь и уведомления», фрейм Q1.
|
||||
|
||||
Это ровно тот список, который сотрудник видит галочками в профиле, поэтому
|
||||
типы разделены по смыслу для человека, а не по месту в коде. «Клиент
|
||||
запросил оператора» и «диалог долго ждёт» раньше были одним типом, и
|
||||
отписаться от второго, не потеряв первое, было нельзя.
|
||||
|
||||
«Новый диалог» стоит отдельно от «клиент запросил оператора» по той же
|
||||
причине: диалог может начаться и на канале с работающим агентом, где
|
||||
человека никто не звал, и называть такой оклик просьбой о человеке — врать.
|
||||
"""
|
||||
|
||||
NEW_DIALOG = "NEW_DIALOG", "Новый диалог"
|
||||
OPERATOR_REQUESTED = "OPERATOR_REQUESTED", "Клиент запросил оператора"
|
||||
DIALOG_NEW_MESSAGE = "DIALOG_NEW_MESSAGE", "Новое сообщение в моём диалоге"
|
||||
DIALOG_ASSIGNED = "DIALOG_ASSIGNED", "Диалог назначили на меня"
|
||||
DIALOG_WAITING_LONG = "DIALOG_WAITING_LONG", "Диалог долго ждёт человека"
|
||||
AI_STOPPED = "AI_STOPPED", "AI остановлен ошибкой или лимитом"
|
||||
|
||||
|
||||
class NotificationLevel(models.TextChoices):
|
||||
@@ -47,6 +60,17 @@ class Notification(models.Model):
|
||||
target_route = models.CharField(max_length=64, blank=True)
|
||||
target_id = models.CharField(max_length=64, blank=True)
|
||||
audience = models.CharField(max_length=16, choices=NotificationAudience.choices, default=NotificationAudience.ALL)
|
||||
# Граница видимости уведомления (ADR-CHATBALLS-0043 §4). Уведомление о диалоге
|
||||
# видно тем же, кому виден сам диалог: иначе оператор чужой группы получает
|
||||
# оклик с именем клиента и куском переписки, а открыть диалог не может.
|
||||
# NULL — видно всем, кого пропускает аудитория.
|
||||
audience_group = models.ForeignKey(
|
||||
"identity.EmployeeGroup",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="notifications",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
recipient_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="direct_notifications")
|
||||
source_type = models.CharField(max_length=64, blank=True)
|
||||
source_id = models.CharField(max_length=64, blank=True)
|
||||
@@ -72,8 +96,57 @@ class NotificationRead(TenantRelationModel):
|
||||
|
||||
|
||||
def default_push_types() -> list[str]:
|
||||
# Дефолт: диалоговые события (новый диалог / ждёт оператора / новое сообщение).
|
||||
return [NotificationType.DIALOG_WAITING, NotificationType.DIALOG_NEW_MESSAGE]
|
||||
# Дефолт — то, что касается лично и требует действия: позвали человека,
|
||||
# написали в мой диалог, назначили на меня. «Долго ждёт» и сбой AI —
|
||||
# подписка по желанию, иначе оклик обесценивается.
|
||||
# «Новый диалог» сюда не входит: диалог, которым занимается агент, лично
|
||||
# никого не касается. Кому он нужен — включит галочкой.
|
||||
return [
|
||||
NotificationType.OPERATOR_REQUESTED,
|
||||
NotificationType.DIALOG_NEW_MESSAGE,
|
||||
NotificationType.DIALOG_ASSIGNED,
|
||||
]
|
||||
|
||||
|
||||
class NotificationTransport(models.TextChoices):
|
||||
BROWSER = "BROWSER", "Браузер"
|
||||
MESSENGER = "MESSENGER", "Мессенджер"
|
||||
|
||||
|
||||
class NotificationPreference(models.Model):
|
||||
"""Куда и о чём окликать сотрудника.
|
||||
|
||||
Одна настройка на все транспорты. Браузер и бот в мессенджере отвечают на
|
||||
один и тот же вопрос — «о чём меня звать», — и второй такой же список
|
||||
галочек в профиле означал бы два расходящихся ответа на него. Новый
|
||||
транспорт (почта, мобильное приложение) — это строка здесь, а не ещё одна
|
||||
таблица настроек.
|
||||
|
||||
Разрешение самого браузера тут не хранится: оно живёт в браузере, своё на
|
||||
каждом устройстве, и сервер его ни выдать, ни отозвать не может. Здесь
|
||||
только намерение человека — хочет ли он, чтобы его окликали.
|
||||
"""
|
||||
|
||||
organization = models.ForeignKey(
|
||||
"identity.Organization", on_delete=models.PROTECT, related_name="notification_preferences"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notification_preferences"
|
||||
)
|
||||
transport = models.CharField(max_length=16, choices=NotificationTransport.choices)
|
||||
enabled = models.BooleanField(default=False)
|
||||
# Подмножество NotificationType; пустой список — не звать вовсе.
|
||||
types = models.JSONField(default=default_push_types, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["organization", "user", "transport"], name="uniq_notification_preference"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"pref:{self.user_id}/{self.transport}"
|
||||
|
||||
|
||||
class MessengerBinding(TenantRelationModel):
|
||||
@@ -87,8 +160,8 @@ class MessengerBinding(TenantRelationModel):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="messenger_bindings")
|
||||
integration = models.ForeignKey("integrations.Integration", on_delete=models.CASCADE, related_name="messenger_bindings")
|
||||
external_chat_id = models.CharField(max_length=128)
|
||||
# Типы уведомлений, которые доставляются в мессенджер (подмножество NotificationType).
|
||||
push_types = models.JSONField(default=default_push_types, blank=True)
|
||||
# Типы событий живут не здесь, а в NotificationPreference: вопрос «о чём
|
||||
# звать» один на все транспорты, и ответ на него обязан быть один.
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Настройки оклика сотрудника: о чём звать и каким транспортом.
|
||||
|
||||
Раньше список типов жил на привязке к мессенджеру. Пока транспорт был один, это
|
||||
работало; со вторым (браузер) в профиле появились бы два списка галочек про одно
|
||||
и то же, и человек, отключивший «новое сообщение», продолжал бы получать его с
|
||||
другой стороны. Поэтому список вынесен из привязки и заведён на транспорт.
|
||||
|
||||
Про `enabled` важно не ошибиться: это согласие получать, и у двух транспортов оно
|
||||
выражено по-разному. У мессенджера согласие — сама привязка бота: она есть, и
|
||||
звать есть куда; нет — и `enabled` ничего не спасёт. У браузера предъявить нечего,
|
||||
кроме намерения человека, поэтому его и храним. Отсюда две разные функции ниже:
|
||||
доставка в мессенджер спрашивает только типы (получателей она уже отобрала по
|
||||
наличию привязки), а браузер — типы вместе с согласием.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from chatballs.notifications.models import (
|
||||
NotificationPreference,
|
||||
NotificationTransport,
|
||||
NotificationType,
|
||||
default_push_types,
|
||||
)
|
||||
|
||||
|
||||
def preference_for(
|
||||
*, organization_id: int, user_id: int, transport: str
|
||||
) -> NotificationPreference:
|
||||
"""Настройка транспорта; при первом обращении заводится с дефолтами."""
|
||||
preference, _ = NotificationPreference.objects.get_or_create(
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
transport=transport,
|
||||
defaults={"enabled": False, "types": default_push_types()},
|
||||
)
|
||||
return preference
|
||||
|
||||
|
||||
def preference_types(*, organization_id: int, user_id: int, transport: str) -> list[str]:
|
||||
"""Типы, выбранные для транспорта. Без строки — дефолтный набор.
|
||||
|
||||
Согласие не проверяется: у мессенджера его подтверждает привязка, и
|
||||
отсутствие строки настроек не повод замолчать.
|
||||
"""
|
||||
preference = NotificationPreference.objects.filter(
|
||||
organization_id=organization_id, user_id=user_id, transport=transport
|
||||
).first()
|
||||
if preference is None:
|
||||
return default_push_types()
|
||||
return list(preference.types or [])
|
||||
|
||||
|
||||
def enabled_types(*, organization_id: int, user_id: int, transport: str) -> list[str]:
|
||||
"""Типы транспорта, который человек включил. Не включил — пустой список."""
|
||||
preference = NotificationPreference.objects.filter(
|
||||
organization_id=organization_id, user_id=user_id, transport=transport
|
||||
).first()
|
||||
if preference is None or not preference.enabled:
|
||||
return []
|
||||
return list(preference.types or [])
|
||||
|
||||
|
||||
def update_preference(
|
||||
*,
|
||||
organization_id: int,
|
||||
user_id: int,
|
||||
transport: str,
|
||||
enabled: bool | None = None,
|
||||
types: list[str] | None = None,
|
||||
) -> NotificationPreference:
|
||||
preference = preference_for(
|
||||
organization_id=organization_id, user_id=user_id, transport=transport
|
||||
)
|
||||
fields = []
|
||||
if enabled is not None and preference.enabled != enabled:
|
||||
preference.enabled = enabled
|
||||
fields.append("enabled")
|
||||
if types is not None:
|
||||
# Чужие коды в список не пускаем: он приходит из браузера.
|
||||
allowed = [code for code in types if code in NotificationType.values]
|
||||
if preference.types != allowed:
|
||||
preference.types = allowed
|
||||
fields.append("types")
|
||||
if fields:
|
||||
preference.save(update_fields=fields)
|
||||
return preference
|
||||
|
||||
|
||||
def messenger_types(*, organization_id: int, user_id: int) -> list[str]:
|
||||
return preference_types(
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Оповещение сотрудника о том, что у него появились уведомления.
|
||||
|
||||
Событие адресное — в сокет каждого получателя отдельно. Группа организации, на
|
||||
которой живут события инбокса, здесь не годится: уведомления сотрудник видит не
|
||||
все, и рассылка на организацию означала бы, что о чужих он как минимум узнаёт.
|
||||
|
||||
Как и у диалогов, событие несёт только повод перезапросить: ни текста, ни
|
||||
идентификаторов. Содержимое клиент забирает обычным запросом, где и живёт
|
||||
проверка видимости — канал о ней не знает и потому не может в ней ошибиться.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from chatballs.realtime import publish
|
||||
|
||||
NOTIFICATIONS_EVENT = "notifications.changed"
|
||||
|
||||
|
||||
def user_group(user_id: int) -> str:
|
||||
return f"user.{user_id}"
|
||||
|
||||
|
||||
def notify_notifications_changed(user_ids: Iterable[int]) -> None:
|
||||
for user_id in set(user_ids):
|
||||
publish(user_group(user_id), {"type": NOTIFICATIONS_EVENT})
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Кому адресовано уведомление.
|
||||
|
||||
Ответ нужен двум транспортам сразу — сокету открытого приложения и боту в
|
||||
мессенджере, — поэтому он считается здесь один раз, а не в каждом из них.
|
||||
|
||||
Раньше получатели искались перебором: для каждого сотрудника организации
|
||||
выполнялся отдельный запрос `visible_for(...).exists()`. На сотню рабочих мест
|
||||
это сотня запросов на каждое входящее сообщение — терпимо, только пока
|
||||
уведомления доставляются раз в пятнадцать секунд. Здесь два запроса независимо
|
||||
от размера организации: право выводится из роли, членство в группе — одним
|
||||
IN-условием.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.identity.policy import MANAGEMENT_ROLES, roles_with_capability
|
||||
from chatballs.notifications.models import Notification, NotificationAudience
|
||||
|
||||
# Аудитория — это право её читать. Зеркало notifications.selectors.visible_for:
|
||||
# расходиться этим двум таблицам нельзя, иначе уведомление придёт тому, кто его
|
||||
# потом не увидит в списке, или наоборот.
|
||||
AUDIENCE_CAPABILITY = {
|
||||
NotificationAudience.ALL: "company.view",
|
||||
NotificationAudience.OPERATORS: "conversations.view",
|
||||
NotificationAudience.OWNER: "employees.manage_privileged",
|
||||
}
|
||||
|
||||
|
||||
def audience_user_ids(
|
||||
*,
|
||||
organization_id: int,
|
||||
audience: str,
|
||||
group_id: int | None = None,
|
||||
recipient_user_id: int | None = None,
|
||||
) -> list[int]:
|
||||
"""Кого охватывает такая аудитория.
|
||||
|
||||
Отдельно от уведомления, потому что спросить это нужно и до него: очередь
|
||||
выясняет, есть ли вообще кому заметить ждущий диалог, ещё не решив, звать ли.
|
||||
"""
|
||||
if audience == NotificationAudience.USER:
|
||||
return [recipient_user_id] if recipient_user_id else []
|
||||
capability = AUDIENCE_CAPABILITY.get(audience)
|
||||
if capability is None:
|
||||
return []
|
||||
memberships = OrganizationMembership.objects.filter(
|
||||
organization_id=organization_id,
|
||||
role__in=roles_with_capability(capability),
|
||||
blocked_at__isnull=True,
|
||||
user__is_active=True,
|
||||
)
|
||||
if group_id is not None:
|
||||
# Та же граница, что у диалога: руководство видит всё, остальные — свою
|
||||
# группу (chatballs.identity.policy.conversation_visibility).
|
||||
memberships = memberships.filter(
|
||||
Q(role__in=MANAGEMENT_ROLES) | Q(group_links__group_id=group_id)
|
||||
).distinct()
|
||||
return list(memberships.values_list("user_id", flat=True))
|
||||
|
||||
|
||||
def recipient_user_ids(notification: Notification) -> list[int]:
|
||||
"""Идентификаторы сотрудников, которым это уведомление адресовано."""
|
||||
return audience_user_ids(
|
||||
organization_id=notification.organization_id,
|
||||
audience=notification.audience,
|
||||
group_id=notification.audience_group_id,
|
||||
recipient_user_id=notification.recipient_user_id,
|
||||
)
|
||||
@@ -1,7 +1,12 @@
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from chatballs.identity.policy import ResourceScope, authorize
|
||||
from chatballs.identity.policy import (
|
||||
ResourceScope,
|
||||
authorize,
|
||||
conversation_visibility,
|
||||
)
|
||||
from chatballs.notifications.models import Notification, NotificationAudience
|
||||
from chatballs.notifications.recipients import AUDIENCE_CAPABILITY
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -11,16 +16,25 @@ def visible_for(context: TenantContext) -> QuerySet[Notification]:
|
||||
return Notification.objects.none()
|
||||
user = context.actor_user
|
||||
organization_scope = ResourceScope(profile.organization_id)
|
||||
audiences = []
|
||||
if authorize(profile, "company.view", organization_scope):
|
||||
audiences.append(NotificationAudience.ALL)
|
||||
if authorize(profile, "conversations.view", organization_scope):
|
||||
audiences.append(NotificationAudience.OPERATORS)
|
||||
if authorize(profile, "employees.manage_privileged", organization_scope):
|
||||
audiences.append(NotificationAudience.OWNER)
|
||||
audiences = [
|
||||
audience
|
||||
for audience, capability in AUDIENCE_CAPABILITY.items()
|
||||
if authorize(profile, capability, organization_scope)
|
||||
]
|
||||
by_audience = Q(audience__in=audiences)
|
||||
scope = conversation_visibility(profile)
|
||||
if scope is not None:
|
||||
# Уведомление о диалоге группы читает тот же, кому виден сам диалог.
|
||||
# Предикат намеренно взят тот же (ADR-CHATBALLS-0043 §4): два разных
|
||||
# ответа на вопрос «кому это видно» — дефект, а не гибкость. Раньше
|
||||
# уведомления фильтровались только правом, и оператор чужой группы
|
||||
# получал оклик с именем клиента и куском переписки по диалогу, который
|
||||
# не может открыть.
|
||||
by_audience &= Q(audience_group__isnull=True) | Q(
|
||||
audience_group_id__in=scope.get("group_ids") or ()
|
||||
)
|
||||
return Notification.objects.filter(organization_id=profile.organization_id).filter(
|
||||
Q(audience__in=audiences)
|
||||
| Q(audience=NotificationAudience.USER, recipient_user=user)
|
||||
by_audience | Q(audience=NotificationAudience.USER, recipient_user=user)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,25 +20,35 @@ from chatballs.notifications.selectors import unread_for
|
||||
# каждый сотрудник читает его на своём языке. Ярлык TextChoices остаётся
|
||||
# английским/русским значением модели и до человека не доходит.
|
||||
TYPE_META: dict[str, dict] = {
|
||||
NotificationType.DIALOG_WAITING: {
|
||||
NotificationType.NEW_DIALOG: {
|
||||
"level": NotificationLevel.INFO,
|
||||
"route": "chat",
|
||||
"label": "notifications.type_new_dialog",
|
||||
},
|
||||
NotificationType.OPERATOR_REQUESTED: {
|
||||
"level": NotificationLevel.WARNING,
|
||||
"route": "chat",
|
||||
"label": "notifications.type_dialog_waiting",
|
||||
"label": "notifications.type_operator_requested",
|
||||
},
|
||||
NotificationType.DIALOG_NEW_MESSAGE: {
|
||||
"level": NotificationLevel.INFO,
|
||||
"route": "chat",
|
||||
"label": "notifications.type_dialog_new_message",
|
||||
},
|
||||
NotificationType.RELEASE_PUBLISHED: {
|
||||
"level": NotificationLevel.SUCCESS,
|
||||
"route": "agents",
|
||||
"label": "notifications.type_release_published",
|
||||
NotificationType.DIALOG_ASSIGNED: {
|
||||
"level": NotificationLevel.INFO,
|
||||
"route": "chat",
|
||||
"label": "notifications.type_dialog_assigned",
|
||||
},
|
||||
NotificationType.INTEGRATION_ERROR: {
|
||||
NotificationType.DIALOG_WAITING_LONG: {
|
||||
"level": NotificationLevel.WARNING,
|
||||
"route": "chat",
|
||||
"label": "notifications.type_dialog_waiting_long",
|
||||
},
|
||||
NotificationType.AI_STOPPED: {
|
||||
"level": NotificationLevel.CRITICAL,
|
||||
"route": "integrations",
|
||||
"label": "notifications.type_integration_error",
|
||||
"route": "agents",
|
||||
"label": "notifications.type_ai_stopped",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,6 +58,7 @@ def notify(
|
||||
context,
|
||||
type: str,
|
||||
audience: str,
|
||||
audience_group=None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
title_key: str = "",
|
||||
@@ -71,6 +82,7 @@ def notify(
|
||||
organization=organization,
|
||||
type=type,
|
||||
audience=audience,
|
||||
audience_group=audience_group,
|
||||
title=title,
|
||||
body=body,
|
||||
title_key=title_key,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Уведомление о диалоге видно тем же, кому виден сам диалог.
|
||||
|
||||
Регрессия: уведомления фильтровались только правом «видеть диалоги», а диалоги —
|
||||
ещё и группой (ADR-CHATBALLS-0043 §4). Оператор чужой группы получал оклик с
|
||||
именем клиента и куском переписки, а открыть диалог не мог.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.recipients import recipient_user_ids
|
||||
from chatballs.notifications.selectors import visible_for
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class NotificationGroupVisibilityTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="notify-groups")
|
||||
self.support = EmployeeGroup.objects.create(
|
||||
organization=self.organization, name="Поддержка"
|
||||
)
|
||||
self.sales = EmployeeGroup.objects.create(organization=self.organization, name="Продажи")
|
||||
self.owner = self._employee("owner@notify.test", EmployeeRole.OWNER)
|
||||
self.insider = self._employee("insider@notify.test", EmployeeRole.EMPLOYEE)
|
||||
self.outsider = self._employee("outsider@notify.test", EmployeeRole.EMPLOYEE)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=self.support, employee=self.insider
|
||||
)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=self.sales, employee=self.outsider
|
||||
)
|
||||
self.context = tenant_context_for(self.owner.user, self.organization)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _visible_ids(self, membership: OrganizationMembership) -> set[int]:
|
||||
context = tenant_context_for(membership.user, self.organization)
|
||||
return set(visible_for(context).values_list("id", flat=True))
|
||||
|
||||
def _waiting(self, group: EmployeeGroup | None):
|
||||
return notify(
|
||||
context=self.context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=group,
|
||||
title="Нужен оператор",
|
||||
)
|
||||
|
||||
def test_group_notification_reaches_only_that_group_and_management(self) -> None:
|
||||
notification = self._waiting(self.support)
|
||||
self.assertIn(notification.id, self._visible_ids(self.insider))
|
||||
self.assertNotIn(notification.id, self._visible_ids(self.outsider))
|
||||
self.assertIn(notification.id, self._visible_ids(self.owner))
|
||||
|
||||
def test_notification_without_group_stays_visible_to_everyone(self) -> None:
|
||||
notification = self._waiting(None)
|
||||
for membership in (self.insider, self.outsider, self.owner):
|
||||
self.assertIn(notification.id, self._visible_ids(membership))
|
||||
|
||||
def test_recipients_match_what_the_list_shows(self) -> None:
|
||||
"""Доставка и список обязаны отвечать одинаково.
|
||||
|
||||
Иначе уведомление уходит в мессенджер тому, кто потом не находит его в
|
||||
приложении, или наоборот.
|
||||
"""
|
||||
for group in (self.support, None):
|
||||
notification = self._waiting(group)
|
||||
delivered = set(recipient_user_ids(notification))
|
||||
listed = {
|
||||
membership.user_id
|
||||
for membership in (self.owner, self.insider, self.outsider)
|
||||
if notification.id in self._visible_ids(membership)
|
||||
}
|
||||
self.assertEqual(delivered, listed)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Уведомление доезжает до открытого приложения событием, а не опросом.
|
||||
|
||||
Событие адресное: в личную группу каждого получателя. Организация целиком не
|
||||
годится — сотрудник видит не все уведомления, и рассылка на всех означала бы,
|
||||
что о чужих он как минимум узнаёт.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.notifications.delivery import NOTIFICATION_CREATED
|
||||
from chatballs.notifications.models import NotificationAudience, NotificationType
|
||||
from chatballs.notifications.realtime import user_group
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class NotificationFanoutTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="Example", slug="notify-fanout")
|
||||
self.support = EmployeeGroup.objects.create(
|
||||
organization=self.organization, name="Поддержка"
|
||||
)
|
||||
self.owner = self._employee("owner@fanout.test", EmployeeRole.OWNER)
|
||||
self.insider = self._employee("insider@fanout.test", EmployeeRole.EMPLOYEE)
|
||||
self.outsider = self._employee("outsider@fanout.test", EmployeeRole.EMPLOYEE)
|
||||
EmployeeGroupMember.objects.create(
|
||||
organization=self.organization, group=self.support, employee=self.insider
|
||||
)
|
||||
self.context = tenant_context_for(self.owner.user, self.organization)
|
||||
|
||||
def _employee(self, email: str, role: str) -> OrganizationMembership:
|
||||
user = HumanUser.objects.create_user(email=email, password="Password-123")
|
||||
return OrganizationMembership.objects.create(
|
||||
user=user, organization=self.organization, role=role, position_title="Specialist"
|
||||
)
|
||||
|
||||
def _dispatch_last_event(self) -> list[str]:
|
||||
"""Разбирает событие из outbox и возвращает группы, в которые ушёл оклик."""
|
||||
event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at")
|
||||
with mock.patch("chatballs.notifications.realtime.publish") as publish:
|
||||
dispatch(event)
|
||||
return [call.args[0] for call in publish.call_args_list]
|
||||
|
||||
def test_event_goes_to_personal_groups_of_recipients_only(self) -> None:
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
audience_group=self.support,
|
||||
title="Нужен оператор",
|
||||
)
|
||||
groups = self._dispatch_last_event()
|
||||
self.assertEqual(
|
||||
set(groups),
|
||||
{user_group(self.owner.user_id), user_group(self.insider.user_id)},
|
||||
)
|
||||
self.assertNotIn(user_group(self.outsider.user_id), groups)
|
||||
|
||||
def test_event_carries_no_content(self) -> None:
|
||||
"""В канал уходит только повод перезапросить: ни текста, ни номера."""
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title="Нужен оператор",
|
||||
body="Клиент ждёт ответа",
|
||||
)
|
||||
event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at")
|
||||
with mock.patch("chatballs.notifications.realtime.publish") as publish:
|
||||
dispatch(event)
|
||||
for call in publish.call_args_list:
|
||||
self.assertEqual(call.args[1], {"type": "notifications.changed"})
|
||||
|
||||
def test_personal_notification_reaches_only_its_recipient(self) -> None:
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.DIALOG_NEW_MESSAGE,
|
||||
audience=NotificationAudience.USER,
|
||||
recipient_user=self.outsider.user,
|
||||
title="Новое сообщение",
|
||||
)
|
||||
self.assertEqual(self._dispatch_last_event(), [user_group(self.outsider.user_id)])
|
||||
@@ -97,7 +97,7 @@ class DeliveryTests(NotifierTestBase):
|
||||
with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title="Новый диалог · Acme — сайт",
|
||||
body="Гость · TELEGRAM: Привет",
|
||||
@@ -108,11 +108,11 @@ class DeliveryTests(NotifierTestBase):
|
||||
self.assertIn("Новый диалог", send.call_args.kwargs["text"])
|
||||
|
||||
def test_type_not_in_push_types_is_skipped(self) -> None:
|
||||
# INTEGRATION_ERROR не входит в дефолтные push_types привязки.
|
||||
# AI_STOPPED не входит в дефолтный набор типов.
|
||||
with mock.patch("chatballs.notifications.delivery.transports.send_reply", return_value=True) as send:
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
audience=NotificationAudience.OWNER,
|
||||
title="Ошибка интеграции",
|
||||
)
|
||||
@@ -136,13 +136,13 @@ class DeliveryTests(NotifierTestBase):
|
||||
operator = HumanUser.objects.get(email="staff.member@example.org")
|
||||
operators_notification = notify(
|
||||
context=self.context,
|
||||
type=NotificationType.DIALOG_WAITING,
|
||||
type=NotificationType.OPERATOR_REQUESTED,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title="Waiting dialog",
|
||||
)
|
||||
notify(
|
||||
context=self.context,
|
||||
type=NotificationType.INTEGRATION_ERROR,
|
||||
type=NotificationType.AI_STOPPED,
|
||||
audience=NotificationAudience.OWNER,
|
||||
title="Owner-only notice",
|
||||
)
|
||||
@@ -245,13 +245,13 @@ class BindingApiTests(NotifierTestBase):
|
||||
def test_push_types_patch_and_listing(self) -> None:
|
||||
MessengerBinding.objects.create(user=self.owner, integration=self.integration, external_chat_id="777")
|
||||
listed = self.client.get("/api/v1/notifications/messenger-bindings/").json()
|
||||
self.assertIn({"code": NotificationType.DIALOG_WAITING, "label": "Диалог ждёт оператора"}, listed["availableTypes"])
|
||||
self.assertIn(NotificationType.DIALOG_WAITING, listed["items"][0]["pushTypes"])
|
||||
self.assertIn({"code": NotificationType.OPERATOR_REQUESTED, "label": "Клиент запросил оператора"}, listed["availableTypes"])
|
||||
self.assertIn(NotificationType.OPERATOR_REQUESTED, listed["items"][0]["pushTypes"])
|
||||
|
||||
patched = self.client.patch(
|
||||
f"/api/v1/notifications/messenger-bindings/{self.integration.id}/",
|
||||
data={"pushTypes": [NotificationType.INTEGRATION_ERROR, "NOT_A_TYPE"]},
|
||||
data={"pushTypes": [NotificationType.AI_STOPPED, "NOT_A_TYPE"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 200)
|
||||
self.assertEqual(patched.json()["pushTypes"], [NotificationType.INTEGRATION_ERROR])
|
||||
self.assertEqual(patched.json()["pushTypes"], [NotificationType.AI_STOPPED])
|
||||
@@ -7,4 +7,5 @@ urlpatterns = [
|
||||
path("read/", views.NotificationReadView.as_view(), name="notification-read"),
|
||||
path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"),
|
||||
path("messenger-bindings/<int:integration_id>/", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"),
|
||||
path("preferences/<str:transport>/", views.NotificationPreferenceView.as_view(), name="notification-preference"),
|
||||
]
|
||||
@@ -5,7 +5,16 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.notifications.binding import deep_link, issue_binding_code, notifier_integrations
|
||||
from chatballs.notifications.models import MessengerBinding, NotificationRead, NotificationType
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
NotificationRead,
|
||||
NotificationTransport,
|
||||
)
|
||||
from chatballs.notifications.preferences import (
|
||||
preference_for,
|
||||
preference_types,
|
||||
update_preference,
|
||||
)
|
||||
from chatballs.notifications.selectors import unread_for, visible_for
|
||||
from chatballs.notifications.serializers import notification_payload
|
||||
from chatballs.notifications.services import TYPE_META, mark_read
|
||||
@@ -42,6 +51,11 @@ class MessengerBindingListView(APIView):
|
||||
integration__organization=request.tenant_context.organization,
|
||||
)
|
||||
}
|
||||
messenger_types = preference_types(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
user_id=request.user.id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
)
|
||||
items = []
|
||||
for integration in notifier_integrations(request.tenant_context):
|
||||
binding = bindings.get(integration.id)
|
||||
@@ -52,7 +66,7 @@ class MessengerBindingListView(APIView):
|
||||
"name": integration.name,
|
||||
"botUsername": integration.config.get("bot_username", ""),
|
||||
"bound": binding is not None,
|
||||
"pushTypes": binding.push_types if binding else [],
|
||||
"pushTypes": messenger_types if binding else [],
|
||||
}
|
||||
)
|
||||
# Реестр типов для чекбоксов в профиле (порядок — как в TYPE_META).
|
||||
@@ -91,12 +105,23 @@ class MessengerBindingDetailView(APIView):
|
||||
types = request.data.get("pushTypes")
|
||||
if not isinstance(types, list):
|
||||
return Response({"detail": t("notifications.push_types_list")}, status=400)
|
||||
binding.push_types = [code for code in types if code in NotificationType.values]
|
||||
binding.save(update_fields=["push_types"])
|
||||
return Response({"pushTypes": binding.push_types})
|
||||
preference = update_preference(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
user_id=request.user.id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
types=types,
|
||||
)
|
||||
return Response({"pushTypes": preference.types})
|
||||
|
||||
def delete(self, request: Request, integration_id: int) -> Response:
|
||||
MessengerBinding.objects.filter(user=request.user, integration_id=integration_id).delete()
|
||||
# Отвязали бота — звать этим транспортом больше некуда.
|
||||
update_preference(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
user_id=request.user.id,
|
||||
transport=NotificationTransport.MESSENGER,
|
||||
enabled=False,
|
||||
)
|
||||
return Response({"ok": True})
|
||||
|
||||
|
||||
@@ -112,3 +137,54 @@ class NotificationReadView(APIView):
|
||||
return Response({"detail": t("notifications.ids_list_or_all")}, status=400)
|
||||
mark_read(context=request.tenant_context, ids=[i for i in ids if isinstance(i, int)])
|
||||
return Response({"unreadCount": unread_for(request.tenant_context).count()})
|
||||
|
||||
|
||||
class NotificationPreferenceView(APIView):
|
||||
"""Настройка транспорта: включён ли он и о чём звать.
|
||||
|
||||
Мессенджер настраивается через свою привязку (там же, где бот), браузеру
|
||||
отдельной сущности не нужно — разрешение живёт в самом браузере, а сюда
|
||||
попадает только намерение человека.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request, transport: str) -> Response:
|
||||
if transport not in NotificationTransport.values:
|
||||
return Response({"detail": t("notifications.unknown_transport")}, status=404)
|
||||
preference = preference_for(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
user_id=request.user.id,
|
||||
transport=transport,
|
||||
)
|
||||
return Response(_preference_payload(preference))
|
||||
|
||||
def patch(self, request: Request, transport: str) -> Response:
|
||||
if transport not in NotificationTransport.values:
|
||||
return Response({"detail": t("notifications.unknown_transport")}, status=404)
|
||||
types = request.data.get("types")
|
||||
if types is not None and not isinstance(types, list):
|
||||
return Response({"detail": t("notifications.push_types_list")}, status=400)
|
||||
enabled = request.data.get("enabled")
|
||||
if enabled is not None and not isinstance(enabled, bool):
|
||||
return Response({"detail": t("notifications.enabled_bool")}, status=400)
|
||||
preference = update_preference(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
user_id=request.user.id,
|
||||
transport=transport,
|
||||
enabled=enabled,
|
||||
types=types,
|
||||
)
|
||||
return Response(_preference_payload(preference))
|
||||
|
||||
|
||||
def _preference_payload(preference) -> dict:
|
||||
return {
|
||||
"transport": preference.transport,
|
||||
"enabled": preference.enabled,
|
||||
"types": list(preference.types or []),
|
||||
# Реестр типов — тот же, что у мессенджера: вопрос один на все транспорты.
|
||||
"availableTypes": [
|
||||
{"code": code, "label": t(meta["label"])} for code, meta in TYPE_META.items()
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Кто из сотрудников сейчас за рабочим местом.
|
||||
|
||||
Присутствие берётся из открытого сокета, а не из отдельной подсистемы: сокет и
|
||||
есть признак того, что приложение открыто. Отдельная «доступность» со статусами,
|
||||
сменами и графиками — это другая, большая задача, и для того, ради чего
|
||||
присутствие понадобилось, она не нужна.
|
||||
|
||||
Хранится не флаг, а момент последней активности. Флага хватало очереди («есть ли
|
||||
кому заметить»), но выбору ответственного нужно ещё и «не в приложении · 25
|
||||
минут»: разница между «вышел налить кофе» и «ушёл вчера» — это разница между
|
||||
хорошим и плохим назначением. Отметка обновляется на подключении, на heartbeat
|
||||
раз в минуту и на обрыве, поэтому оборванное соединение само перестаёт быть
|
||||
присутствием, без уборщика.
|
||||
|
||||
Это подсказка, а не право. Ошибиться она может в обе стороны: ноутбук уснул, а
|
||||
отметка ещё свежая; сеть моргнула, и человек на секунду «исчез». Поэтому ни один
|
||||
доступ и ни одна доставка на присутствии не стоят — оно только помогает решить,
|
||||
сколько ещё ждать и кого предложить первым.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
# Клиент шлёт heartbeat раз в минуту; запас вдвое с небольшим покрывает
|
||||
# пропущенный тик и не держит призраков дольше пары минут.
|
||||
ONLINE_WITHIN_SECONDS = 150
|
||||
# Сколько помним ушедшего, чтобы сказать, как давно его нет. Дольше суток это
|
||||
# уже не «недавно вышел», а «здесь не работает», и точность не нужна.
|
||||
REMEMBER_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def _key(organization_id: int, user_id: int) -> str:
|
||||
return f"presence:{organization_id}:{user_id}"
|
||||
|
||||
|
||||
def touch(organization_id: int, user_id: int) -> None:
|
||||
"""Отметить, что человек сейчас в приложении."""
|
||||
cache.set(_key(organization_id, user_id), timezone.now().timestamp(), REMEMBER_SECONDS)
|
||||
|
||||
|
||||
def last_seen(organization_id: int, user_ids: Iterable[int]) -> dict[int, datetime]:
|
||||
"""Когда каждого из перечисленных видели в последний раз."""
|
||||
wanted = list(user_ids)
|
||||
if not wanted:
|
||||
return {}
|
||||
keys = {_key(organization_id, user_id): user_id for user_id in wanted}
|
||||
found = cache.get_many(list(keys))
|
||||
return {
|
||||
keys[key]: datetime.fromtimestamp(value, tz=timezone.get_current_timezone())
|
||||
for key, value in found.items()
|
||||
if isinstance(value, (int, float))
|
||||
}
|
||||
|
||||
|
||||
def online_user_ids(organization_id: int, user_ids: Iterable[int]) -> set[int]:
|
||||
"""Кто из перечисленных на месте прямо сейчас."""
|
||||
now = timezone.now()
|
||||
return {
|
||||
user_id
|
||||
for user_id, seen in last_seen(organization_id, user_ids).items()
|
||||
if (now - seen).total_seconds() <= ONLINE_WITHIN_SECONDS
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Публикация оповещений в канальный слой.
|
||||
|
||||
Транспорт и только транспорт. Какие бывают группы и какие события в них летят,
|
||||
знают доменные модули (conversations.realtime, notifications.realtime); здесь
|
||||
единственное в проекте знание про channels — и единственное место, где сбой
|
||||
канала гасится, чтобы не уронить уже сделанную запись.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def publish(group: str, payload: dict[str, object]) -> None:
|
||||
"""Оповещение — вспомогательный путь: его сбой не должен ронять запись.
|
||||
|
||||
Данные к моменту отправки уже сохранены; если канал недоступен, клиент
|
||||
узнает об изменении следующим опросом — он остаётся как запасной путь.
|
||||
"""
|
||||
layer = get_channel_layer()
|
||||
if layer is None:
|
||||
return
|
||||
try:
|
||||
async_to_sync(layer.group_send)(group, {"type": "fanout", "payload": payload})
|
||||
except Exception: # noqa: BLE001 — канал не должен ломать сохранение
|
||||
logger.warning("realtime fanout failed for %s", group, exc_info=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Настройки оклика (notifications_notificationpreference): tenant-таблица с
|
||||
# organization_id, RLS и гранты по образцу tenancy/0022. Настройка принадлежит
|
||||
# сотруднику в организации, читает и пишет её runtime-роль app.
|
||||
from django.db import migrations
|
||||
|
||||
TABLES = ("notifications_notificationpreference",)
|
||||
|
||||
FORWARD_RLS = """
|
||||
ALTER TABLE {table} OWNER TO chatballs_schema;
|
||||
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON {table} FROM PUBLIC;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
|
||||
GRANT ALL ON {table} TO chatballs_schema;
|
||||
GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq
|
||||
TO chatballs_runtime_app, chatballs_schema;
|
||||
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
|
||||
CREATE POLICY chatballs_tenant_isolation ON {table}
|
||||
FOR ALL TO chatballs_runtime_app
|
||||
USING (organization_id = chatballs.current_organization_id())
|
||||
WITH CHECK (organization_id = chatballs.current_organization_id());
|
||||
DROP POLICY IF EXISTS chatballs_schema_access ON {table};
|
||||
CREATE POLICY chatballs_schema_access ON {table}
|
||||
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
|
||||
"""
|
||||
|
||||
|
||||
def apply_guards(apps, schema_editor):
|
||||
for table in TABLES:
|
||||
schema_editor.execute(FORWARD_RLS.format(table=table))
|
||||
|
||||
|
||||
def remove_guards(apps, schema_editor):
|
||||
for table in TABLES:
|
||||
schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}")
|
||||
schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}")
|
||||
schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0035_organization_creation_and_invitation_directory"),
|
||||
("notifications", "0012_notification_preference"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(apply_guards, remove_guards)]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Пороги очереди (conversations_queueescalationpolicy): tenant-таблица с
|
||||
# organization_id, RLS и гранты по образцу tenancy/0022. Читает её воркер под
|
||||
# ролью app, когда обходит ждущие диалоги организации.
|
||||
from django.db import migrations
|
||||
|
||||
TABLES = ("conversations_queueescalationpolicy",)
|
||||
|
||||
FORWARD_RLS = """
|
||||
ALTER TABLE {table} OWNER TO chatballs_schema;
|
||||
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON {table} FROM PUBLIC;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
|
||||
GRANT ALL ON {table} TO chatballs_schema;
|
||||
GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq
|
||||
TO chatballs_runtime_app, chatballs_schema;
|
||||
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table};
|
||||
CREATE POLICY chatballs_tenant_isolation ON {table}
|
||||
FOR ALL TO chatballs_runtime_app
|
||||
USING (organization_id = chatballs.current_organization_id())
|
||||
WITH CHECK (organization_id = chatballs.current_organization_id());
|
||||
DROP POLICY IF EXISTS chatballs_schema_access ON {table};
|
||||
CREATE POLICY chatballs_schema_access ON {table}
|
||||
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
|
||||
"""
|
||||
|
||||
|
||||
def apply_guards(apps, schema_editor):
|
||||
for table in TABLES:
|
||||
schema_editor.execute(FORWARD_RLS.format(table=table))
|
||||
|
||||
|
||||
def remove_guards(apps, schema_editor):
|
||||
for table in TABLES:
|
||||
schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_tenant_isolation ON {table}")
|
||||
schema_editor.execute(f"DROP POLICY IF EXISTS chatballs_schema_access ON {table}")
|
||||
schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0036_notification_preference_guards"),
|
||||
("conversations", "0023_queue_escalation"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(apply_guards, remove_guards)]
|
||||
@@ -13,6 +13,7 @@ import { fetchAgentDirectory } from "./features/agents/model";
|
||||
import { canAccess, defaultRoute, isManager } from "./auth/access";
|
||||
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
|
||||
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
|
||||
import { RealtimeProvider } from "./features/realtime/RealtimeProvider";
|
||||
import { Shell } from "./layout/Shell";
|
||||
import { pathFromRoute, routeFromPath } from "./router";
|
||||
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
|
||||
@@ -246,7 +247,11 @@ export function App() {
|
||||
) : !canAccess(user, navigation.route) ? (
|
||||
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
|
||||
) : (
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
// Ключ организации — на провайдере: при переключении сокет обязан
|
||||
// переоткрыться на адрес новой, а не остаться на прежней.
|
||||
<RealtimeProvider key={user.organizationPublicId}>
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
</RealtimeProvider>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ChatPage({
|
||||
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
|
||||
: undefined}
|
||||
viewerId={user.id}
|
||||
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext }) => (
|
||||
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
|
||||
<ChatContextPanel
|
||||
rightTab={rightTab}
|
||||
setRightTab={setRightTab}
|
||||
@@ -70,6 +70,7 @@ export function ChatPage({
|
||||
startCall={startCall}
|
||||
closeContext={closeContext}
|
||||
viewerId={user.id}
|
||||
assignmentTimeoutMinutes={assignmentTimeoutMinutes}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -86,6 +87,7 @@ function ChatContextPanel({
|
||||
startCall,
|
||||
closeContext,
|
||||
viewerId,
|
||||
assignmentTimeoutMinutes,
|
||||
}: {
|
||||
rightTab: ChatRightTab;
|
||||
setRightTab: (tab: ChatRightTab) => void;
|
||||
@@ -96,6 +98,7 @@ function ChatContextPanel({
|
||||
startCall: ((kind: "AUDIO" | "VIDEO") => void) | null;
|
||||
closeContext: () => void;
|
||||
viewerId: number;
|
||||
assignmentTimeoutMinutes?: number;
|
||||
}) {
|
||||
return (
|
||||
<section className="sales-context">
|
||||
@@ -106,7 +109,7 @@ function ChatContextPanel({
|
||||
</div>
|
||||
<div className="sales-context-body">
|
||||
{rightTab === "client" && (
|
||||
<ClientContext dialog={dialog} detail={detail} groups={groups} applyConversation={applyConversation} startCall={startCall} viewerId={viewerId} />
|
||||
<ClientContext dialog={dialog} detail={detail} groups={groups} applyConversation={applyConversation} startCall={startCall} viewerId={viewerId} assignmentTimeoutMinutes={assignmentTimeoutMinutes} />
|
||||
)}
|
||||
{rightTab === "history" && <HistoryContext detail={detail} />}
|
||||
</div>
|
||||
|
||||
@@ -97,9 +97,16 @@ function MessageRow({ message, dialog, viewerId }: { message: ApiMessage; dialog
|
||||
// возврат — акцент. Тон выбирается по коду события, а не по словам в
|
||||
// тексте: текст приходит на языке читателя и на английском не совпал бы
|
||||
// ни с одной русской регуляркой.
|
||||
const tone = message.systemEvent === "ai_handed_over" || message.systemEvent === "ai_unavailable"
|
||||
// «Не взял — вернулся в очередь» окрашено предупреждением намеренно (макет
|
||||
// Q4): это не факт из истории, а сорванная договорённость.
|
||||
const tone = message.systemEvent === "ai_handed_over"
|
||||
|| message.systemEvent === "ai_unavailable"
|
||||
|| message.systemEvent === "assignment_expired"
|
||||
? "warning"
|
||||
: message.systemEvent === "operator_took" || message.systemEvent === "returned_to_ai" || message.systemEvent === "returned_to_queue"
|
||||
: message.systemEvent === "operator_took"
|
||||
|| message.systemEvent === "returned_to_ai"
|
||||
|| message.systemEvent === "returned_to_queue"
|
||||
|| message.systemEvent === "assigned_to"
|
||||
? "claimed"
|
||||
: "";
|
||||
return <div className={`sales-event-chip ${tone}`}><Icon name="clock" size={12} />{message.text}<span>·</span>{fmtTime(message.createdAt)}</div>;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
isOwner?: boolean;
|
||||
listTitle?: string;
|
||||
searchPlaceholder?: string;
|
||||
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void }) => ReactNode;
|
||||
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode;
|
||||
viewerId?: number | null;
|
||||
mobileHeader?: (info: { total: number }) => ReactNode;
|
||||
hint?: ReactNode;
|
||||
@@ -92,7 +92,8 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
const settledSearch = useDebounced(search.trim());
|
||||
const query = useMemo(() => ({
|
||||
...scopeFilters(scope),
|
||||
...(listTab === "wait" ? { waiting: true } : {}),
|
||||
...(listTab === "queue" ? { queue: true } : {}),
|
||||
...(listTab === "onMe" ? { waitingOnMe: true } : {}),
|
||||
...(listTab === "mine" ? { assigned: "me" as const } : {}),
|
||||
...(settledSearch ? { q: settledSearch } : {}),
|
||||
sort,
|
||||
@@ -154,7 +155,10 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
const callController = useConversationCall({ conversationId: selectedId, onConversationChanged });
|
||||
useIncomingMessageSound(list.conversations, list.loaded);
|
||||
|
||||
const dialogs = useMemo(() => list.conversations.map(toConversationListItem), [list.conversations]);
|
||||
const dialogs = useMemo(
|
||||
() => list.conversations.map((item) => toConversationListItem(item, { viewerId, assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes })),
|
||||
[list.conversations, viewerId, counters?.assignmentTimeoutMinutes],
|
||||
);
|
||||
useDialogKeyboardNav({
|
||||
dialogs,
|
||||
selectedId,
|
||||
@@ -280,6 +284,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
startCall: detail?.lifecycle === "OPEN" && (detail.connection?.audioCalls || detail.connection?.videoCalls) ? (kind) => void callController.start(kind) : null,
|
||||
// Кадр S2: выдвижная панель закрывается крестиком в её шапке.
|
||||
closeContext: () => setCtxOpen(false),
|
||||
assignmentTimeoutMinutes: counters?.assignmentTimeoutMinutes,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions";
|
||||
import { WaitingBlock } from "./WaitingBlock";
|
||||
import { PriorityBars } from "./DialogList";
|
||||
import { useEmployeeDirectory } from "./useEmployeeDirectory";
|
||||
import { statusFor } from "./data";
|
||||
@@ -42,21 +44,19 @@ const PRIORITY_TEXT: Record<ConversationPriority, string> = {
|
||||
NONE: "var(--n-4)",
|
||||
};
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
|
||||
export function DialogControls({
|
||||
detail,
|
||||
groups,
|
||||
applyConversation,
|
||||
viewerId = null,
|
||||
assignmentTimeoutMinutes,
|
||||
}: {
|
||||
detail: ApiConversation;
|
||||
groups: Array<EmployeeGroupRef & { color?: string }>;
|
||||
applyConversation: (updated: ApiConversation) => void;
|
||||
viewerId?: number | null;
|
||||
/** Срок личной очереди из счётчиков: по нему считается «вернётся через». */
|
||||
assignmentTimeoutMinutes?: number;
|
||||
}) {
|
||||
const directory = useEmployeeDirectory();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -111,6 +111,7 @@ export function DialogControls({
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
const assignee = detail.assignedOperator;
|
||||
const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false;
|
||||
const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned");
|
||||
const status = statusFor(controlModeOf(detail), assignee?.name);
|
||||
const priorityLabel = PRIORITY_OPTIONS.find(([value]) => value === detail.priority)?.[1] ?? t("conversations.not_set");
|
||||
@@ -126,6 +127,7 @@ export function DialogControls({
|
||||
{!collapsed && (
|
||||
<div className="ctx-fields">
|
||||
{errorText && <p className="ctx-error">{errorText}</p>}
|
||||
<WaitingBlock detail={detail} timeoutMinutes={assignmentTimeoutMinutes} />
|
||||
|
||||
<label className="ctx-label">{t("common.assignee")}</label>
|
||||
<Dropdown
|
||||
@@ -133,33 +135,16 @@ export function DialogControls({
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown ctx-menu"
|
||||
menu={{
|
||||
items: [
|
||||
// Строка поиска появляется, когда коллег больше, чем помещается
|
||||
// в выдачу справочника: маленькой команде она не нужна.
|
||||
...(directory.hasMore || directory.query
|
||||
? [{
|
||||
key: "search",
|
||||
type: "group" as const,
|
||||
label: (
|
||||
<SearchInput
|
||||
className="ctx-menu-search"
|
||||
placeholder={t("conversations.name_or_email")}
|
||||
value={directory.query}
|
||||
onChange={directory.setQuery}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
{ key: "none", label: <button type="button" className={assignee ? "" : "is-checked"} onClick={() => void run(() => setConversationAssignee(detail.id, null))}><span className="ctx-avatar-empty" /><span>{t("conversations.unassigned")}</span>{!assignee && <Icon name="check" size={15} />}</button> },
|
||||
...directory.employees.map((employee) => ({
|
||||
key: employee.id,
|
||||
label: <button type="button" className={assignee?.id === employee.id ? "is-checked" : ""} onClick={() => void run(() => setConversationAssignee(detail.id, employee.id))}><SmallAvatar name={employee.name} avatarUrl={employee.avatarUrl} /><span>{employee.name}{viewerId === employee.id ? t("common.you_suffix") : ""}</span>{assignee?.id === employee.id && <Icon name="check" size={15} />}</button>,
|
||||
})),
|
||||
],
|
||||
items: assigneeMenuItems({
|
||||
directory,
|
||||
viewerId,
|
||||
assigneeId: assignee?.id,
|
||||
onPick: (id) => void run(() => setConversationAssignee(detail.id, id)),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<button type="button" className={`ctx-select ${assignee ? "" : "is-empty"}`}>
|
||||
{assignee ? <SmallAvatar name={assignee.name} avatarUrl={assignee.avatarUrl} /> : <span className="ctx-avatar-empty" />}
|
||||
{assignee ? <SmallAvatar name={assignee.name} avatarUrl={assignee.avatarUrl} online={assigneeHere} /> : <span className="ctx-avatar-empty" />}
|
||||
<span>{assigneeLabel}</span>
|
||||
{canEdit && <Icon name="chevron" size={14} />}
|
||||
</button>
|
||||
@@ -272,7 +257,10 @@ export function DialogControls({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ctx-meta-row"><span>{t("conversations.started")}</span><span>{startedLabel(detail.createdAt)}</span></div>
|
||||
<div className="ctx-meta-row">
|
||||
<span>{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")}</span>
|
||||
<span>{startedLabel(detail.waitingSince ?? detail.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -341,7 +329,4 @@ export function archiveConversationAction(
|
||||
}
|
||||
|
||||
// Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы.
|
||||
function SmallAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string | null }) {
|
||||
if (avatarUrl) return <span className="ctx-avatar-small has-photo"><img src={avatarUrl} alt="" /></span>;
|
||||
return <span className="ctx-avatar-small">{initials(name)}</span>;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
|
||||
setSelectedId: (id: number) => void;
|
||||
}) {
|
||||
// Счётчик ждущих — из счётчиков охвата, а не из загруженного окна.
|
||||
const waitCount = counters?.waiting ?? 0;
|
||||
const queueCount = counters?.queue ?? 0;
|
||||
const onMeCount = counters?.waitingOnMe ?? 0;
|
||||
// Лента догружается прокруткой: следующее окно запрашивается на подходе к низу.
|
||||
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
if (!hasMore) return;
|
||||
@@ -83,9 +84,14 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
|
||||
<div className="sales-dialog-tabs">
|
||||
<DialogTab active={listTab === "all"} onClick={() => setListTab("all")}>{t("common.all")}</DialogTab>
|
||||
<DialogTab active={listTab === "mine"} onClick={() => setListTab("mine")}>{t("conversations.mine")}</DialogTab>
|
||||
<DialogTab active={listTab === "wait"} onClick={() => setListTab("wait")}>{t("conversations.waiting_for_operator")}{waitCount > 0 && <b>{waitCount}</b>}</DialogTab>
|
||||
<DialogTab active={listTab === "queue"} onClick={() => setListTab("queue")}>{t("conversations.common_queue")}{queueCount > 0 && <b>{queueCount}</b>}</DialogTab>
|
||||
<DialogTab active={listTab === "onMe"} onClick={() => setListTab("onMe")}>{t("conversations.on_me")}{onMeCount > 0 && <b>{onMeCount}</b>}</DialogTab>
|
||||
</div>
|
||||
{hint}
|
||||
{/* Что значат две вкладки ожидания — прямо там, где их выбирают. */}
|
||||
{(listTab === "queue" || listTab === "onMe") && (
|
||||
<div className="sales-wait-note">{t("conversations.queue_tabs_note")}</div>
|
||||
)}
|
||||
<div className="sales-dialog-list-body" onScroll={onScroll}>
|
||||
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
|
||||
{/* Кадр S1: пустой список без призыва к действию. Под фильтром и
|
||||
@@ -131,9 +137,10 @@ function DialogListItem({ dialog, active, setSelectedId }: { dialog: Conversatio
|
||||
<small className={dialog.unread ? "unread" : ""}>{dialog.preview}</small>
|
||||
{dialog.unread > 0 && <b>{dialog.unread}</b>}
|
||||
</span>
|
||||
{(dialog.waitLabel || dialog.groupName || dialog.labels.length > 0) && (
|
||||
{(dialog.waitLabel || dialog.mineLabel || dialog.groupName || dialog.labels.length > 0) && (
|
||||
<span className="sales-dialog-row-badges">
|
||||
{dialog.waitLabel && <b className="sales-dialog-badge is-wait"><Icon name="clock" size={11} />{dialog.waitLabel}</b>}
|
||||
{dialog.mineLabel && <b className="sales-dialog-badge is-mine"><Icon name="user" size={11} />{dialog.mineLabel}</b>}
|
||||
{dialog.groupName && <b className="sales-dialog-badge"><i className="is-round" style={{ background: dialog.groupColor }} />{dialog.groupName}</b>}
|
||||
{dialog.labels.map((label) => (
|
||||
<b className="sales-dialog-badge" key={label.id}><i style={{ background: label.color || "var(--n-5)" }} />{label.name}</b>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { t, tn } from "../../i18n";
|
||||
import { SmallAvatar } from "./assigneeOptions";
|
||||
import { waitLabelOf, type ApiConversation } from "./model";
|
||||
|
||||
// Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4).
|
||||
//
|
||||
// Отвечает на два вопроса, которые иначе оператору взять негде: сколько клиент
|
||||
// уже ждёт человека и почему диалог виден, но брать его не нужно. Второе —
|
||||
// единственное объяснение назначенному, но не взятому диалогу: без него он
|
||||
// выглядит как обычный ждущий, который почему-то не даёт себя взять.
|
||||
|
||||
/** «через 3 минуты» — сколько осталось личной очереди. */
|
||||
function leftFor(assignedAt: string, timeoutMinutes: number, now: Date): string | null {
|
||||
const deadline = new Date(assignedAt).getTime() + timeoutMinutes * 60000;
|
||||
const minutes = Math.round((deadline - now.getTime()) / 60000);
|
||||
return minutes > 0 ? tn("plural.minutes", minutes) : null;
|
||||
}
|
||||
|
||||
export function WaitingBlock({
|
||||
detail,
|
||||
timeoutMinutes,
|
||||
now = new Date(),
|
||||
}: {
|
||||
detail: ApiConversation;
|
||||
timeoutMinutes?: number;
|
||||
now?: Date;
|
||||
}) {
|
||||
if (detail.lifecycle !== "OPEN" || detail.controlMode !== "PAUSED" || !detail.waitingSince) {
|
||||
return null;
|
||||
}
|
||||
const assignee = detail.assignedOperator;
|
||||
const left = assignee && detail.assignedAt && timeoutMinutes
|
||||
? leftFor(detail.assignedAt, timeoutMinutes, now)
|
||||
: null;
|
||||
return (
|
||||
<div className="ctx-waiting">
|
||||
<div className="ctx-waiting-head">
|
||||
<Icon name="clock" size={15} strokeWidth={2} />
|
||||
<strong>{t("conversations.waiting_for_person", { duration: waitLabelOf(detail.waitingSince, now) })}</strong>
|
||||
</div>
|
||||
{assignee && (
|
||||
<>
|
||||
<div className="ctx-waiting-assignee">
|
||||
<SmallAvatar name={assignee.name} avatarUrl={assignee.avatarUrl} />
|
||||
<span>{t("conversations.assigned_not_taken", { name: assignee.name })}</span>
|
||||
</div>
|
||||
{left && <p>{t("conversations.returns_to_queue_in", { duration: left })}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ItemType } from "antd/es/menu/interface";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
import type { ChatDirectoryEmployee } from "./model";
|
||||
import type { EmployeeDirectory } from "./useEmployeeDirectory";
|
||||
|
||||
/** Инициалы: «Анна Ким» → «АК». */
|
||||
export function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
|
||||
/** Маленький аватар строки выбора: используется и в списке, и в закрытом поле. */
|
||||
export function SmallAvatar({ name, avatarUrl, online }: { name: string; avatarUrl?: string | null; online?: boolean }) {
|
||||
// Точка присутствия живёт внутри аватара: у него фиксированный размер, и
|
||||
// угол, к которому её прижимают, всегда там, где ожидается.
|
||||
const dot = online ? <i className="assignee-dot" /> : null;
|
||||
if (avatarUrl) {
|
||||
return <span className="ctx-avatar-small has-photo">{dot}<img src={avatarUrl} alt="" /></span>;
|
||||
}
|
||||
return <span className="ctx-avatar-small">{dot}{initials(name)}</span>;
|
||||
}
|
||||
|
||||
// Выбор ответственного (макет «Очередь и уведомления», кадр Q5): сначала те,
|
||||
// кто сейчас в приложении, потом остальные. Отсутствующих не скрываем и не
|
||||
// блокируем — признак приблизительный, назначить можно любого; они просто не
|
||||
// первыми попадаются на глаза.
|
||||
|
||||
/** «не в приложении · 25 мин» — сколько человека уже нет. */
|
||||
export function awayFor(lastSeenAt: string | null | undefined, now = new Date()): string {
|
||||
if (!lastSeenAt) return t("conversations.never_in_app");
|
||||
const seen = new Date(lastSeenAt);
|
||||
if (Number.isNaN(seen.getTime())) return t("conversations.never_in_app");
|
||||
const minutes = Math.max(1, Math.round((now.getTime() - seen.getTime()) / 60000));
|
||||
if (minutes < 60) return t("conversations.away_minutes", { count: minutes });
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return t("conversations.away_hours", { count: hours });
|
||||
return t("conversations.away_long");
|
||||
}
|
||||
|
||||
function subtitleOf(employee: ChatDirectoryEmployee): string {
|
||||
if (!employee.online) return awayFor(employee.lastSeenAt);
|
||||
return employee.role === "OWNER" ? t("conversations.here_owner") : t("conversations.here");
|
||||
}
|
||||
|
||||
function personItem(
|
||||
employee: ChatDirectoryEmployee,
|
||||
{ viewerId, assigneeId, onPick }: { viewerId: number | null; assigneeId?: number; onPick: (id: number) => void },
|
||||
): ItemType {
|
||||
const load = employee.openDialogs ?? 0;
|
||||
return {
|
||||
key: employee.id,
|
||||
label: (
|
||||
<button
|
||||
type="button"
|
||||
className={`assignee-option ${assigneeId === employee.id ? "is-checked" : ""}`}
|
||||
onClick={() => onPick(employee.id)}
|
||||
>
|
||||
<SmallAvatar name={employee.name} avatarUrl={employee.avatarUrl} online={employee.online} />
|
||||
<span className="assignee-who">
|
||||
<span>{employee.name}{viewerId === employee.id ? t("common.you_suffix") : ""}</span>
|
||||
<small className={employee.online ? "is-here" : ""}>{subtitleOf(employee)}</small>
|
||||
</span>
|
||||
{load > 0 && <small className="assignee-load">{t("conversations.open_dialogs", { count: load })}</small>}
|
||||
{assigneeId === employee.id && <Icon name="check" size={15} />}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function assigneeMenuItems({
|
||||
directory,
|
||||
viewerId,
|
||||
assigneeId,
|
||||
onPick,
|
||||
}: {
|
||||
directory: EmployeeDirectory;
|
||||
viewerId: number | null;
|
||||
assigneeId?: number;
|
||||
onPick: (id: number | null) => void;
|
||||
}): ItemType[] {
|
||||
const here = directory.employees.filter((employee) => employee.online);
|
||||
const away = directory.employees.filter((employee) => !employee.online);
|
||||
const pick = { viewerId, assigneeId, onPick: (id: number) => onPick(id) };
|
||||
return [
|
||||
// Строка поиска появляется, когда коллег больше, чем помещается в выдачу
|
||||
// справочника: маленькой команде она не нужна.
|
||||
...(directory.hasMore || directory.query
|
||||
? [{
|
||||
key: "search",
|
||||
type: "group" as const,
|
||||
label: (
|
||||
<SearchInput
|
||||
className="ctx-menu-search"
|
||||
placeholder={t("conversations.name_or_email")}
|
||||
value={directory.query}
|
||||
onChange={directory.setQuery}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: "none",
|
||||
label: (
|
||||
<button type="button" className={assigneeId ? "" : "is-checked"} onClick={() => onPick(null)}>
|
||||
<span className="ctx-avatar-empty" />
|
||||
<span>{t("conversations.unassigned")}</span>
|
||||
{!assigneeId && <Icon name="check" size={15} />}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
...(here.length > 0
|
||||
? [{ key: "here-head", type: "group" as const, label: t("conversations.presence_here") }]
|
||||
: []),
|
||||
...here.map((employee) => personItem(employee, pick)),
|
||||
...(away.length > 0
|
||||
? [{ key: "away-head", type: "group" as const, label: t("conversations.presence_away") }]
|
||||
: []),
|
||||
...away.map((employee) => personItem(employee, pick)),
|
||||
{
|
||||
key: "presence-note",
|
||||
type: "group" as const,
|
||||
className: "ctx-menu-note",
|
||||
label: t("conversations.presence_note"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -50,7 +50,13 @@ 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 }>;
|
||||
@@ -73,6 +79,10 @@ export type ApiConversation = {
|
||||
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;
|
||||
@@ -170,7 +180,23 @@ export function conversationName(conversation: ApiConversation): string {
|
||||
return t("conversations.guest");
|
||||
}
|
||||
|
||||
export function toConversationListItem(conversation: ApiConversation): ConversationListItem {
|
||||
/** «на вас · вернётся через 4 мин» — сколько осталось личной очереди. */
|
||||
export function assignedToMeLabel(
|
||||
conversation: ApiConversation,
|
||||
{ viewerId, timeoutMinutes }: { viewerId: number | null; timeoutMinutes?: number },
|
||||
now = new Date(),
|
||||
): string | null {
|
||||
if (!conversation.isAssignedToViewer || conversation.controlMode !== "PAUSED") return null;
|
||||
if (viewerId == null || !conversation.assignedAt || !timeoutMinutes) return t("conversations.on_you");
|
||||
const deadline = new Date(conversation.assignedAt).getTime() + timeoutMinutes * 60000;
|
||||
const left = Math.round((deadline - now.getTime()) / 60000);
|
||||
return left > 0 ? t("conversations.on_you_returns_in", { count: left }) : t("conversations.on_you");
|
||||
}
|
||||
|
||||
export function toConversationListItem(
|
||||
conversation: ApiConversation,
|
||||
options: { viewerId?: number | null; assignmentTimeoutMinutes?: number } = {},
|
||||
): ConversationListItem {
|
||||
const name = conversationName(conversation);
|
||||
// avatarBg: стабильно из id контакта.
|
||||
const seed = conversation.contact?.id ?? conversation.id;
|
||||
@@ -198,10 +224,16 @@ export function toConversationListItem(conversation: ApiConversation): Conversat
|
||||
agentColor: agentColorOf(conversation.channel.id),
|
||||
groupName: conversation.group?.name ?? null,
|
||||
groupColor: conversation.group ? groupColorOf(conversation.group.id, conversation.group.color) : "var(--n-5)",
|
||||
// Таймер считается с момента постановки в очередь: по последней активности
|
||||
// клиент, напомнивший о себе, «ждал» бы заново с нуля.
|
||||
waitLabel:
|
||||
conversation.lifecycle === "OPEN" && conversation.controlMode === "PAUSED"
|
||||
? waitLabelOf(conversation.lastActivityAt)
|
||||
? waitLabelOf(conversation.waitingSince ?? conversation.lastActivityAt)
|
||||
: null,
|
||||
mineLabel: assignedToMeLabel(conversation, {
|
||||
viewerId: options.viewerId ?? null,
|
||||
timeoutMinutes: options.assignmentTimeoutMinutes,
|
||||
}),
|
||||
lastIsOurs: conversation.lastMessage?.author === "OPERATOR" || conversation.lastMessage?.author === "AI",
|
||||
lastIsVoice: conversation.lastMessage?.kind === "voice",
|
||||
};
|
||||
@@ -214,6 +246,8 @@ export type ConversationListFilters = Partial<{
|
||||
agent: number; // id канала-агента
|
||||
assigned: "me" | number;
|
||||
waiting: boolean;
|
||||
queue: boolean;
|
||||
waitingOnMe: boolean;
|
||||
lifecycle: "OPEN" | "CLOSED" | "SPAM";
|
||||
archived: boolean;
|
||||
q: string;
|
||||
@@ -234,6 +268,8 @@ export const fetchConversations = (query: ConversationListQuery = {}) => {
|
||||
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);
|
||||
@@ -274,7 +310,17 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers
|
||||
|
||||
// Справочник блока «Диалог» (кадр G): все группы для переноса и коллеги для
|
||||
// назначения — доступен и сотруднику, у которого нет менеджерских списков.
|
||||
export type ChatDirectoryEmployee = { id: number; name: string; avatarUrl?: string | null };
|
||||
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 }>;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
export type DialogMode = "ai" | "closed" | "operator" | "wait";
|
||||
export type ControlMode = "ai" | "assigned" | "closed" | "human" | "waiting";
|
||||
export type ListTab = "all" | "mine" | "wait";
|
||||
// Два ожидания — разные списки (макет «Очередь и уведомления», кадр Q3):
|
||||
// «queue» — диалоги без ответственного, взять может любой; «onMe» —
|
||||
// назначенные лично и ждущие, пока их возьмут.
|
||||
export type ListTab = "all" | "mine" | "queue" | "onMe";
|
||||
// Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в
|
||||
// браузере было бы нечего.
|
||||
export type ListSort = "activity" | "waiting";
|
||||
@@ -33,6 +36,8 @@ export type ConversationListItem = {
|
||||
groupName: string | null;
|
||||
groupColor: string;
|
||||
waitLabel: string | null;
|
||||
/** «на вас · вернётся через 4 мин» — личная очередь (макет Q3). */
|
||||
mineLabel: string | null;
|
||||
lastIsOurs: boolean;
|
||||
// Последнее сообщение — голосовое: в превью иконка микрофона (макет).
|
||||
lastIsVoice: boolean;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { resolveWebSocketUrl } from "../../api/client";
|
||||
import { useRealtime, useRealtimeEvent } from "../realtime/RealtimeProvider";
|
||||
|
||||
// Оповещения о диалогах: сервер сообщает, что изменилось, клиент забирает
|
||||
// данные обычным запросом. Поллинг остаётся запасным путём и замедляется, пока
|
||||
// сокет жив, — при обрыве всё работает ровно как раньше.
|
||||
|
||||
const RECONNECT_MIN_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
// Поток событий в оживлённой организации плотнее прежнего опроса: несколько
|
||||
// сообщений подряд должны приводить к одному обновлению, а не к пяти.
|
||||
const COALESCE_MS = 700;
|
||||
//
|
||||
// Сам сокет живёт в оболочке (features/realtime): он общий с уведомлениями и
|
||||
// обязан работать на любом экране, а не только там, где открыт чат.
|
||||
|
||||
export type ConversationEvents = {
|
||||
/** Сокет открыт: поллинг можно замедлить. */
|
||||
@@ -26,84 +23,18 @@ export function useConversationEvents({
|
||||
onInboxChanged: () => void;
|
||||
onConversationChanged: (conversationId: number) => void;
|
||||
}): ConversationEvents {
|
||||
const [connected, setConnected] = useState(false);
|
||||
// Обработчики пересоздаются на каждый рендер — держим их в ref, чтобы сокет
|
||||
// не переоткрывался вместе с ними.
|
||||
const inboxRef = useRef(onInboxChanged);
|
||||
inboxRef.current = onInboxChanged;
|
||||
const conversationRef = useRef(onConversationChanged);
|
||||
conversationRef.current = onConversationChanged;
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const watchedRef = useRef<number | null>(null);
|
||||
const pendingRef = useRef<{ inbox: boolean; conversation: number | null }>({
|
||||
inbox: false,
|
||||
conversation: null,
|
||||
const { connected, watch } = useRealtime();
|
||||
|
||||
useRealtimeEvent("inbox.changed", () => onInboxChanged());
|
||||
useRealtimeEvent("conversation.changed", (message) => {
|
||||
if (typeof message.conversationId === "number") onConversationChanged(message.conversationId);
|
||||
});
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Открытый диалог сообщается сокету заново и после переподключения: подписка
|
||||
// на него живёт в соединении, а не в реестре.
|
||||
useEffect(() => {
|
||||
const url = resolveWebSocketUrl("/conversations/");
|
||||
if (!url) return;
|
||||
let closed = false;
|
||||
let retry = RECONNECT_MIN_MS;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function open() {
|
||||
if (closed) return;
|
||||
const socket = new WebSocket(url as string);
|
||||
socketRef.current = socket;
|
||||
socket.onopen = () => {
|
||||
retry = RECONNECT_MIN_MS;
|
||||
setConnected(true);
|
||||
// После обрыва подписка теряется вместе с сокетом — восстанавливаем её.
|
||||
if (watchedRef.current != null) {
|
||||
socket.send(JSON.stringify({ type: "watch", conversationId: watchedRef.current }));
|
||||
}
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
const payload = JSON.parse(String(event.data)) as { type?: string; conversationId?: number };
|
||||
if (payload.type === "inbox.changed") pendingRef.current.inbox = true;
|
||||
if (payload.type === "conversation.changed" && typeof payload.conversationId === "number") {
|
||||
pendingRef.current.conversation = payload.conversationId;
|
||||
}
|
||||
if (flushTimerRef.current !== null) return;
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
flushTimerRef.current = null;
|
||||
const pending = pendingRef.current;
|
||||
pendingRef.current = { inbox: false, conversation: null };
|
||||
if (pending.inbox) inboxRef.current();
|
||||
if (pending.conversation !== null) conversationRef.current(pending.conversation);
|
||||
}, COALESCE_MS);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
setConnected(false);
|
||||
socketRef.current = null;
|
||||
if (closed) return;
|
||||
// Отступ растёт до полуминуты: сервер мог уйти на перезапуск.
|
||||
reconnectTimer = setTimeout(open, retry);
|
||||
retry = Math.min(retry * 2, RECONNECT_MAX_MS);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
}
|
||||
|
||||
open();
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (flushTimerRef.current !== null) clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
watchedRef.current = conversationId;
|
||||
const socket = socketRef.current;
|
||||
if (socket && socket.readyState === WebSocket.OPEN && conversationId != null) {
|
||||
socket.send(JSON.stringify({ type: "watch", conversationId }));
|
||||
}
|
||||
}, [conversationId, connected]);
|
||||
watch(conversationId);
|
||||
}, [watch, conversationId, connected]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function useEmployeeDirectory(): EmployeeDirectory {
|
||||
fetchChatDirectory(settledQuery)
|
||||
.then((payload) => {
|
||||
if (!active) return;
|
||||
setEmployees(payload.employees);
|
||||
setEmployees(payload.employees ?? []);
|
||||
setHasMore(Boolean(payload.hasMoreEmployees));
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AppNotification } from "./model";
|
||||
|
||||
// Системное уведомление браузера — то, что видно, когда вкладка свёрнута или
|
||||
// закрыта другим окном. Работает без service worker и без Web Push: пока хоть
|
||||
// одна вкладка приложения открыта, этого достаточно, а «браузер закрыт» в
|
||||
// продукте закрывают боты в мессенджере. Web Push потребовал бы исходящего
|
||||
// доступа установки к чужому push-сервису — на self-hosted это лишняя внешняя
|
||||
// зависимость ради случая, который уже закрыт.
|
||||
|
||||
export type BrowserNotificationState = "unsupported" | "default" | "granted" | "denied";
|
||||
|
||||
export function browserNotificationState(): BrowserNotificationState {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
||||
return Notification.permission as BrowserNotificationState;
|
||||
}
|
||||
|
||||
/** Спрашивать разрешение можно только в ответ на действие человека: браузеры
|
||||
* наказывают за запрос на загрузке, а отказ потом почти не отменить. */
|
||||
export async function requestBrowserNotifications(): Promise<BrowserNotificationState> {
|
||||
if (browserNotificationState() === "unsupported") return "unsupported";
|
||||
try {
|
||||
return (await Notification.requestPermission()) as BrowserNotificationState;
|
||||
} catch {
|
||||
return browserNotificationState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Человек смотрит на приложение: системное уведомление было бы лишним, ему
|
||||
* хватит тоста в самом интерфейсе. */
|
||||
function userIsLooking(): boolean {
|
||||
return document.visibilityState === "visible" && document.hasFocus();
|
||||
}
|
||||
|
||||
export function showBrowserNotification(
|
||||
notification: AppNotification,
|
||||
onOpen: (notification: AppNotification) => void,
|
||||
): void {
|
||||
if (browserNotificationState() !== "granted" || userIsLooking()) return;
|
||||
try {
|
||||
const shown = new Notification(notification.title, {
|
||||
body: notification.body,
|
||||
// Одинаковый тег схлопывает уведомление во всех вкладках сразу: у
|
||||
// оператора их обычно несколько, и три одинаковых оклика об одном
|
||||
// диалоге — повод выключить уведомления совсем.
|
||||
tag: `chatballs-notification-${notification.id}`,
|
||||
renotify: false,
|
||||
} as NotificationOptions);
|
||||
shown.onclick = () => {
|
||||
window.focus();
|
||||
onOpen(notification);
|
||||
shown.close();
|
||||
};
|
||||
} catch {
|
||||
/* уведомление — вспомогательный путь: его сбой ничего не ломает */
|
||||
}
|
||||
}
|
||||
|
||||
/** «Chrome · macOS» — чтобы человек понимал, о каком именно устройстве речь:
|
||||
* разрешение своё в каждом браузере и на каждой машине. */
|
||||
export function browserAndOs(): string {
|
||||
if (typeof navigator === "undefined") return "";
|
||||
const ua = navigator.userAgent;
|
||||
const browser = /Edg\//.test(ua)
|
||||
? "Edge"
|
||||
: /OPR\//.test(ua)
|
||||
? "Opera"
|
||||
: /YaBrowser/.test(ua)
|
||||
? "Yandex"
|
||||
: /Chrome\//.test(ua)
|
||||
? "Chrome"
|
||||
: /Firefox\//.test(ua)
|
||||
? "Firefox"
|
||||
: /Safari\//.test(ua)
|
||||
? "Safari"
|
||||
: "";
|
||||
const os = /Windows/.test(ua)
|
||||
? "Windows"
|
||||
: /Mac OS X/.test(ua)
|
||||
? "macOS"
|
||||
: /Android/.test(ua)
|
||||
? "Android"
|
||||
: /iPhone|iPad/.test(ua)
|
||||
? "iOS"
|
||||
: /Linux/.test(ua)
|
||||
? "Linux"
|
||||
: "";
|
||||
return [browser, os].filter(Boolean).join(" · ");
|
||||
}
|
||||
@@ -24,3 +24,21 @@ export const LEVEL_META: Record<NotificationLevel, { color: string; icon: "bell"
|
||||
export const fetchNotifications = () => api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/");
|
||||
export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) });
|
||||
export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) });
|
||||
|
||||
export type NotificationTransport = "BROWSER" | "MESSENGER";
|
||||
|
||||
export type NotificationPreference = {
|
||||
transport: NotificationTransport;
|
||||
enabled: boolean;
|
||||
types: string[];
|
||||
availableTypes: Array<{ code: string; label: string }>;
|
||||
};
|
||||
|
||||
export const fetchPreference = (transport: NotificationTransport) =>
|
||||
api<NotificationPreference>(`/api/v1/notifications/preferences/${transport}/`);
|
||||
|
||||
export const savePreference = (transport: NotificationTransport, patch: { enabled?: boolean; types?: string[] }) =>
|
||||
api<NotificationPreference>(`/api/v1/notifications/preferences/${transport}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { notification as antToast } from "antd";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { fetchPreference, type AppNotification, type NotificationPreference } from "./model";
|
||||
import { showBrowserNotification } from "./browserNotifications";
|
||||
|
||||
// Оклик о новом уведомлении: тост внутри вкладки и системное уведомление, если
|
||||
// человек смотрит в другое место.
|
||||
//
|
||||
// Раньше тост показывался по росту счётчика непрочитанных. Счётчик — плохой
|
||||
// признак события: три уведомления между опросами давали один тост, а
|
||||
// прочтение в соседней вкладке роняло счётчик и глушило следующий. Считаем по
|
||||
// идентификаторам: что человеку ещё не показывали, то и показываем.
|
||||
|
||||
export function useNotificationAlerts({
|
||||
items,
|
||||
onOpen,
|
||||
}: {
|
||||
items: AppNotification[];
|
||||
onOpen: (notification: AppNotification) => void;
|
||||
}) {
|
||||
const [preference, setPreference] = useState<NotificationPreference | null>(null);
|
||||
const shown = useRef<Set<number> | null>(null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
onOpenRef.current = onOpen;
|
||||
const preferenceRef = useRef(preference);
|
||||
preferenceRef.current = preference;
|
||||
|
||||
const reloadPreference = useCallback(async () => {
|
||||
try {
|
||||
setPreference(await fetchPreference("BROWSER"));
|
||||
} catch {
|
||||
/* без настройки системных уведомлений просто не будет */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadPreference();
|
||||
}, [reloadPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
// Первая загрузка только запоминает: накопленное за ночь не вываливают
|
||||
// стопкой тостов в лицо тому, кто открыл приложение утром.
|
||||
if (shown.current === null) {
|
||||
shown.current = new Set(items.map((item) => item.id));
|
||||
return;
|
||||
}
|
||||
const seen = shown.current;
|
||||
const fresh = items.filter((item) => item.unread && !seen.has(item.id));
|
||||
for (const item of items) seen.add(item.id);
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
const newest = fresh[0];
|
||||
antToast.open({ message: newest.title, description: newest.body, placement: "bottomRight" });
|
||||
|
||||
const allowed = preferenceRef.current;
|
||||
if (!allowed?.enabled) return;
|
||||
for (const item of fresh) {
|
||||
if (!allowed.types?.includes(item.type)) continue;
|
||||
showBrowserNotification(item, (opened) => onOpenRef.current(opened));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
return { preference, reloadPreference };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
|
||||
// Галочки типов событий. Один и тот же список у каждого транспорта: вопрос
|
||||
// «о чём меня звать» один, и выглядеть он обязан одинаково — что у бота в
|
||||
// мессенджере, что у браузера.
|
||||
|
||||
export type NotificationTypeOption = { code: string; label: string };
|
||||
|
||||
export function NotificationTypeChecks({
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
options: NotificationTypeOption[];
|
||||
selected: string[];
|
||||
onToggle: (code: string) => void;
|
||||
}) {
|
||||
// Список приходит из ответа сервера: недостающее поле не повод уронить
|
||||
// экран, на котором эта строка — лишь часть карточки.
|
||||
if (!options || options.length === 0) return null;
|
||||
return (
|
||||
<div className="profile-notifications-types">
|
||||
{options.map((option) => {
|
||||
const on = selected.includes(option.code);
|
||||
return (
|
||||
<label key={option.code}>
|
||||
<input type="checkbox" checked={on} onChange={() => onToggle(option.code)} />
|
||||
<span className={`profile-check ${on ? "is-on" : ""}`}>
|
||||
{on && <Icon name="check" size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
browserAndOs,
|
||||
browserNotificationState,
|
||||
requestBrowserNotifications,
|
||||
type BrowserNotificationState,
|
||||
} from "../notifications/browserNotifications";
|
||||
import { fetchPreference, savePreference, type NotificationPreference } from "../notifications/model";
|
||||
import { Icon, LogoSpinner } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
import { NotificationTypeChecks } from "./NotificationTypeChecks";
|
||||
|
||||
// Строка «Этот браузер» в карточке уведомлений (макет «Очередь и уведомления»,
|
||||
// кадры Q1 и Q1b) — того же вида, что строки ботов, и первой в списке: она
|
||||
// включается одним нажатием и без внешнего аккаунта.
|
||||
//
|
||||
// Состояний три, и они не сводятся к «включено/выключено»: разрешение живёт в
|
||||
// браузере и своё на каждом устройстве, а желание получать — на сервере.
|
||||
// Отозвать выданное разрешение сайт не может, поэтому «Отключить» выключает
|
||||
// именно желание; отказ снимается только руками в настройках браузера.
|
||||
|
||||
export function ProfileBrowserNotificationsRow() {
|
||||
// Состояние разрешения известно сразу: если узнавать его в эффекте, строка
|
||||
// на первый кадр возвращает null и мигает на каждой перерисовке страницы.
|
||||
const [permission, setPermission] = useState<BrowserNotificationState>(browserNotificationState);
|
||||
const [preference, setPreference] = useState<NotificationPreference | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreference("BROWSER").then(setPreference).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(async (patch: { enabled?: boolean; types?: string[] }) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreference(await savePreference("BROWSER", patch));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function enable() {
|
||||
// Разрешение спрашиваем ровно здесь — в ответ на клик: на загрузке страницы
|
||||
// браузеры такой запрос наказывают, а отказ потом почти не отменить.
|
||||
const granted = await requestBrowserNotifications();
|
||||
setPermission(granted);
|
||||
if (granted === "granted") await apply({ enabled: true });
|
||||
}
|
||||
|
||||
function toggleType(code: string) {
|
||||
if (!preference) return;
|
||||
const next = preference.types.includes(code)
|
||||
? preference.types.filter((item) => item !== code)
|
||||
: [...preference.types, code];
|
||||
setPreference({ ...preference, types: next });
|
||||
void apply({ types: next });
|
||||
}
|
||||
|
||||
if (permission === "unsupported") return null;
|
||||
|
||||
const denied = permission === "denied";
|
||||
const on = permission === "granted" && Boolean(preference?.enabled);
|
||||
const device = browserAndOs();
|
||||
const tile = denied
|
||||
? "integration-tile--blocked"
|
||||
: on
|
||||
? "integration-tile--web"
|
||||
: "integration-tile--muted";
|
||||
return (
|
||||
<div className="profile-notifications-block">
|
||||
<div className="profile-notifications-row">
|
||||
<span className={`profile-notifications-tile integration-tile ${tile}`}>
|
||||
<Icon name={denied ? "bellOff" : "bell"} size={19} />
|
||||
</span>
|
||||
<div className="profile-notifications-name">
|
||||
<strong>{t("profile.this_browser")}</strong>
|
||||
<small>
|
||||
{denied
|
||||
? device
|
||||
: on
|
||||
? t("profile.browser_visible_when_minimized", { device })
|
||||
: t("profile.browser_not_enabled_yet")}
|
||||
</small>
|
||||
</div>
|
||||
{denied ? (
|
||||
<b className="profile-notifications-status is-blocked">
|
||||
<Icon name="xCircle" size={11} strokeWidth={2.4} />
|
||||
{t("profile.browser_blocked")}
|
||||
</b>
|
||||
) : !preference ? (
|
||||
<span className="profile-notifications-wait"><LogoSpinner size={16} /></span>
|
||||
) : on ? (
|
||||
<>
|
||||
<b className="profile-notifications-status">
|
||||
<Icon name="check" size={11} strokeWidth={2.6} />
|
||||
{t("profile.browser_enabled")}
|
||||
</b>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => void apply({ enabled: false })}>
|
||||
{t("profile.disconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" disabled={busy} onClick={() => void enable()}>{t("profile.enable")}</Button>
|
||||
)}
|
||||
</div>
|
||||
{on && preference && (
|
||||
<>
|
||||
<NotificationTypeChecks
|
||||
options={preference.availableTypes ?? []}
|
||||
selected={preference.types ?? []}
|
||||
onToggle={toggleType}
|
||||
/>
|
||||
<p className="profile-notifications-aside">{t("profile.browser_disable_note")}</p>
|
||||
</>
|
||||
)}
|
||||
{denied && (
|
||||
<div className="profile-notifications-hint">
|
||||
<span><Icon name="lock" size={15} /></span>
|
||||
<div>
|
||||
<strong>{t("profile.browser_blocked_title")}</strong>
|
||||
<p>{t("profile.browser_blocked_steps")}</p>
|
||||
{/* Разрешение меняют в браузере; приложению остаётся перечитать его. */}
|
||||
<Button variant="secondary" onClick={() => setPermission(browserNotificationState())}>
|
||||
{t("profile.check_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { Icon, LogoSpinner, MaxLogo, TelegramLogo } from "../../shared/icons";
|
||||
import { NotificationTypeChecks } from "./NotificationTypeChecks";
|
||||
import { ProfileBrowserNotificationsRow } from "./ProfileBrowserNotificationsRow";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
@@ -31,8 +33,8 @@ export function ProfileNotificationsCard() {
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchBindings();
|
||||
setItems(data.items);
|
||||
setTypes(data.availableTypes);
|
||||
setItems(data.items ?? []);
|
||||
setTypes(data.availableTypes ?? []);
|
||||
for (const item of data.items) {
|
||||
if (item.bound || issuedFor.current.has(item.integrationId)) continue;
|
||||
issuedFor.current.add(item.integrationId);
|
||||
@@ -56,7 +58,7 @@ export function ProfileNotificationsCard() {
|
||||
useEffect(() => {
|
||||
if (!hasUnbound) return;
|
||||
const timer = window.setInterval(() => {
|
||||
fetchBindings().then((data) => { setItems(data.items); setTypes(data.availableTypes); }).catch(() => undefined);
|
||||
fetchBindings().then((data) => { setItems(data.items ?? []); setTypes(data.availableTypes ?? []); }).catch(() => undefined);
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasUnbound]);
|
||||
@@ -82,8 +84,9 @@ export function ProfileNotificationsCard() {
|
||||
|
||||
return (
|
||||
<section className="profile-card">
|
||||
<h3>{t("profile.messenger_notifications")}</h3>
|
||||
<p className="profile-card-lead">{t("profile.work_events_reach_even_with")}</p>
|
||||
<h3>{t("profile.work_event_notifications")}</h3>
|
||||
<p className="profile-card-lead">{t("profile.choose_where_to_call_you")}</p>
|
||||
<ProfileBrowserNotificationsRow />
|
||||
{loaded && items.length === 0 && (
|
||||
<p className="profile-notifications-note">{t("profile.no_notification_bots_configured_owner")}</p>
|
||||
)}
|
||||
@@ -109,19 +112,12 @@ export function ProfileNotificationsCard() {
|
||||
<span className="profile-notifications-wait">{loaded ? t("profile.preparing_link") : <LogoSpinner size={16} />}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.bound && types.length > 0 && (
|
||||
<div className="profile-notifications-types">
|
||||
{types.map((type) => {
|
||||
const on = item.pushTypes.includes(type.code);
|
||||
return (
|
||||
<label key={type.code}>
|
||||
<input type="checkbox" checked={on} onChange={() => void toggleType(item, type.code)} />
|
||||
<span className={`profile-check ${on ? "is-on" : ""}`}>{on && <Icon name="check" size={11} strokeWidth={3} />}</span>
|
||||
{type.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{item.bound && (
|
||||
<NotificationTypeChecks
|
||||
options={types}
|
||||
selected={item.pushTypes}
|
||||
onToggle={(code) => void toggleType(item, code)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from "../../i18n";
|
||||
type MobileSection = "notifications" | "password" | "totp" | "sessions";
|
||||
|
||||
const MOBILE_TITLE: Record<MobileSection, string> = {
|
||||
notifications: t("profile.messenger_notifications"),
|
||||
notifications: t("profile.work_event_notifications"),
|
||||
password: t("profile.change_password"),
|
||||
totp: t("profile.two_factor_authentication"),
|
||||
sessions: t("common.active_sessions"),
|
||||
|
||||
@@ -14,8 +14,11 @@ export type ProfileSession = {
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
// Ответ без items — это сломанный ответ, а не пустой список, но карточка не то
|
||||
// место, где об этом узнают: undefined вместо массива роняет её на items.length
|
||||
// и уносит вместе с ней всю страницу профиля — пароль, двухфакторку и сессии.
|
||||
export const fetchProfileSessions = () =>
|
||||
api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items);
|
||||
api<{ items: ProfileSession[] }>("/api/v1/auth/profile/sessions/").then((payload) => payload.items ?? []);
|
||||
|
||||
// «сейчас» · «2 ч назад» · «3 дня назад» — подпись справа в строке сессии.
|
||||
export function lastSeenLabel(value: string | null, now = new Date()): string {
|
||||
|
||||
@@ -920,3 +920,65 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Строка транспорта по состоянию (макет «Очередь и уведомления», кадр Q1b):
|
||||
плитка и статус меняют цвет вместе со смыслом — серое «не включено»,
|
||||
акцентное «работает», красное «браузер запретил». */
|
||||
.integration-tile--muted {
|
||||
color: var(--n-4);
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.integration-tile--blocked {
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.profile-notifications-status.is-blocked {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Отказ браузера снимается только руками, поэтому рядом с ним не кнопка
|
||||
«включить», а шаги: где нажать и что искать. */
|
||||
.profile-notifications-hint {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 12px 0 0 46px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: var(--n-10, var(--surface-card));
|
||||
border: 1px solid var(--n-9);
|
||||
}
|
||||
|
||||
.profile-notifications-hint > span {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
.profile-notifications-hint strong {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--n-2);
|
||||
}
|
||||
|
||||
.profile-notifications-hint p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--n-4);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-notifications-hint button {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
/* Примечание под включённой строкой: «Отключить» гасит показ, а не разрешение. */
|
||||
.profile-notifications-aside {
|
||||
margin: 10px 0 0 46px;
|
||||
font-size: 11.5px;
|
||||
color: var(--n-4);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { resolveWebSocketUrl } from "../../api/client";
|
||||
import { RealtimeConnection } from "./connection";
|
||||
import { RealtimeSubscribers, type RealtimeHandler } from "./subscribers";
|
||||
|
||||
// Сокет живёт на уровне оболочки, а не страницы чата. Раньше он принадлежал
|
||||
// рабочей области диалогов, и вне чата живых событий не было вовсе: на любом
|
||||
// другом экране оператор узнавал о новом уведомлении только следующим опросом.
|
||||
|
||||
type Realtime = {
|
||||
connected: boolean;
|
||||
subscribe: (type: string, handler: RealtimeHandler) => () => void;
|
||||
watch: (conversationId: number | null) => void;
|
||||
};
|
||||
|
||||
const RealtimeContext = createContext<Realtime | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
// Реестр создаётся раз и навсегда: подписки оформляются раньше, чем откроется
|
||||
// сокет, и переживают его переподключения.
|
||||
const subscribersRef = useRef<RealtimeSubscribers | null>(null);
|
||||
if (subscribersRef.current === null) subscribersRef.current = new RealtimeSubscribers();
|
||||
const subscribers = subscribersRef.current;
|
||||
const connectionRef = useRef<RealtimeConnection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = resolveWebSocketUrl("/conversations/");
|
||||
if (!url) return;
|
||||
const connection = new RealtimeConnection(url, (message) => subscribers.dispatch(message), setConnected);
|
||||
connectionRef.current = connection;
|
||||
connection.open();
|
||||
return () => {
|
||||
connection.close();
|
||||
connectionRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [subscribers]);
|
||||
|
||||
// subscribe и watch не зависят от состояния соединения: иначе каждый обрыв
|
||||
// переподписывал бы всех подписчиков заново.
|
||||
const subscribe = useCallback(
|
||||
(type: string, handler: RealtimeHandler) => subscribers.subscribe(type, handler),
|
||||
[subscribers],
|
||||
);
|
||||
const watch = useCallback((conversationId: number | null) => connectionRef.current?.watch(conversationId), []);
|
||||
const value = useMemo<Realtime>(
|
||||
() => ({ connected, subscribe, watch }),
|
||||
[connected, subscribe, watch],
|
||||
);
|
||||
|
||||
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRealtime(): Realtime {
|
||||
const value = useContext(RealtimeContext);
|
||||
if (value === null) throw new Error("useRealtime is only available inside RealtimeProvider");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Подписка на один тип события. Обработчик держится в ref: пересоздание
|
||||
* функции на каждом рендере не должно переподписывать сокет. */
|
||||
export function useRealtimeEvent(type: string, handler: RealtimeHandler): void {
|
||||
const { subscribe } = useRealtime();
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
useEffect(() => subscribe(type, (message) => handlerRef.current(message)), [subscribe, type]);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Сокет рабочего места: один на сессию, по нему идут события и диалогов, и
|
||||
// уведомлений. Второй сокет ради второго источника означал бы второе
|
||||
// переподключение и вторую точку отказа на ровном месте.
|
||||
//
|
||||
// Здесь только провод: подключение, переподключение с отступом и склейка частых
|
||||
// событий. Кто на что подписан — знает реестр (subscribers.ts), и знает
|
||||
// независимо от того, открыт сокет или нет.
|
||||
|
||||
const RECONNECT_MIN_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
// Поток событий в оживлённой организации плотнее прежнего опроса: несколько
|
||||
// сообщений подряд должны приводить к одному обновлению, а не к пяти.
|
||||
const COALESCE_MS = 700;
|
||||
// Раз в минуту сервер узнаёт, что рабочее место живо: по этому признаку
|
||||
// очередь решает, есть ли кому заметить ждущий диалог. Без heartbeat
|
||||
// оборванное соединение считалось бы присутствием до истечения ключа.
|
||||
const HEARTBEAT_MS = 60000;
|
||||
|
||||
export type RealtimeMessage = { type: string; conversationId?: number };
|
||||
|
||||
export class RealtimeConnection {
|
||||
private socket: WebSocket | null = null;
|
||||
private closed = false;
|
||||
private retry = RECONNECT_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** Последнее событие каждого типа: за окно склейки важно только оно. */
|
||||
private pending = new Map<string, RealtimeMessage>();
|
||||
private watched: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly onMessage: (message: RealtimeMessage) => void,
|
||||
private readonly onConnectedChange: (connected: boolean) => void,
|
||||
) {}
|
||||
|
||||
open(): void {
|
||||
if (this.closed) return;
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.retry = RECONNECT_MIN_MS;
|
||||
this.onConnectedChange(true);
|
||||
this.startHeartbeat();
|
||||
// После обрыва подписка теряется вместе с сокетом — восстанавливаем её.
|
||||
if (this.watched !== null) this.send({ type: "watch", conversationId: this.watched });
|
||||
};
|
||||
socket.onmessage = (event) => this.receive(String(event.data));
|
||||
socket.onclose = () => {
|
||||
this.onConnectedChange(false);
|
||||
this.stopHeartbeat();
|
||||
this.socket = null;
|
||||
if (this.closed) return;
|
||||
// Отступ растёт до полуминуты: сервер мог уйти на перезапуск.
|
||||
this.reconnectTimer = setTimeout(() => this.open(), this.retry);
|
||||
this.retry = Math.min(this.retry * 2, RECONNECT_MAX_MS);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.stopHeartbeat();
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
/** Какой диалог открыт: событий по нему клиент и ждёт. */
|
||||
watch(conversationId: number | null): void {
|
||||
this.watched = conversationId;
|
||||
if (conversationId !== null) this.send({ type: "watch", conversationId });
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
|
||||
private send(payload: object): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
private receive(data: string): void {
|
||||
const message = JSON.parse(data) as RealtimeMessage;
|
||||
if (!message.type) return;
|
||||
this.pending.set(message.type, message);
|
||||
if (this.flushTimer !== null) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
const flushed = [...this.pending.values()];
|
||||
this.pending.clear();
|
||||
for (const item of flushed) this.onMessage(item);
|
||||
}, COALESCE_MS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RealtimeMessage } from "./connection";
|
||||
|
||||
// Реестр подписчиков живёт отдельно от сокета и переживает его обрывы: подписка
|
||||
// оформляется в момент монтирования компонента, а сокет к этому моменту может
|
||||
// быть ещё не открыт или уже переподключаться. Связывать одно с другим — значит
|
||||
// терять события на каждом обрыве.
|
||||
|
||||
export type RealtimeHandler = (message: RealtimeMessage) => void;
|
||||
|
||||
export class RealtimeSubscribers {
|
||||
private handlers = new Map<string, Set<RealtimeHandler>>();
|
||||
|
||||
subscribe(type: string, handler: RealtimeHandler): () => void {
|
||||
const forType = this.handlers.get(type) ?? new Set<RealtimeHandler>();
|
||||
forType.add(handler);
|
||||
this.handlers.set(type, forType);
|
||||
return () => {
|
||||
forType.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(message: RealtimeMessage): void {
|
||||
for (const handler of this.handlers.get(message.type) ?? []) handler(message);
|
||||
}
|
||||
}
|
||||
@@ -1367,3 +1367,114 @@
|
||||
width: 100%;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
|
||||
/* Выбор ответственного с присутствием (макет «Очередь и уведомления», кадр Q5).
|
||||
Заголовки групп и сноска — существующие элементы меню; здесь только то, чего
|
||||
в нём ещё не было: точка присутствия, подпись под именем и загрузка справа.
|
||||
Точка — подсказка, а не запрет: отсутствующий выбирается так же, как все. */
|
||||
.ctx-avatar-small {
|
||||
position: relative;
|
||||
/* Точка висит на краю кружка, поэтому обрезать содержимое нельзя. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ctx-avatar-small.has-photo img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.assignee-dot {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #52c41a;
|
||||
border: 2px solid var(--surface-card);
|
||||
}
|
||||
|
||||
.ctx-select .assignee-dot {
|
||||
position: static;
|
||||
flex: none;
|
||||
border: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assignee-who > span,
|
||||
.assignee-who small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assignee-who small {
|
||||
font-size: 11px;
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
.assignee-who small.is-here {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-menu .ant-dropdown-menu-item button.assignee-option > .assignee-load {
|
||||
flex: none;
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--n-5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Блок «Ожидание» в карточке диалога (макет «Очередь и уведомления», кадр Q4):
|
||||
сколько клиент ждёт и почему диалог виден, но брать его не нужно. */
|
||||
.ctx-waiting {
|
||||
padding: 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 9px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.ctx-waiting-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.ctx-waiting-head strong {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ctx-waiting-assignee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--warning-border);
|
||||
font-size: 12.5px;
|
||||
color: var(--n-2);
|
||||
}
|
||||
|
||||
.ctx-waiting p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--warning-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function ClientContext({
|
||||
applyConversation,
|
||||
startCall,
|
||||
viewerId = null,
|
||||
assignmentTimeoutMinutes,
|
||||
}: {
|
||||
dialog: ConversationListItem | null;
|
||||
detail: ApiConversation | null;
|
||||
@@ -29,6 +30,7 @@ export function ClientContext({
|
||||
applyConversation?: (updated: ApiConversation) => void;
|
||||
startCall?: ((kind: "AUDIO" | "VIDEO") => void) | null;
|
||||
viewerId?: number | null;
|
||||
assignmentTimeoutMinutes?: number;
|
||||
}) {
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const [justRequested, setJustRequested] = useState(false);
|
||||
@@ -130,7 +132,7 @@ export function ClientContext({
|
||||
</div>
|
||||
|
||||
{detail && applyConversation && (
|
||||
<DialogControls detail={detail} groups={groups} applyConversation={applyConversation} viewerId={viewerId} />
|
||||
<DialogControls detail={detail} groups={groups} applyConversation={applyConversation} viewerId={viewerId} assignmentTimeoutMinutes={assignmentTimeoutMinutes} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -515,3 +515,12 @@ img.sales-dialog-avatar {
|
||||
.hub-hint button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* «на вас · вернётся через N мин» (макет «Очередь и уведомления», кадр Q3):
|
||||
личная очередь — не то же, что общая, и цвет у неё акцентный, а не тревожный:
|
||||
диалог не брошен, он ждёт конкретного человека. */
|
||||
.sales-dialog-badge.is-mine {
|
||||
background: var(--primary-bg);
|
||||
border-color: var(--primary-border, var(--primary-bg));
|
||||
color: var(--primary-text);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { fmt, t } from "../../i18n";
|
||||
import {
|
||||
QUEUE_POLICY_FIELDS,
|
||||
fetchQueuePolicy,
|
||||
saveQueuePolicy,
|
||||
type QueuePolicy,
|
||||
type QueuePolicyField,
|
||||
} from "./queuePolicy";
|
||||
|
||||
// Раздел «Когда звать на помощь» (макет «Очередь и уведомления», кадр Q2).
|
||||
// Сроки действуют на всю организацию и считаются с момента, когда диалог встал
|
||||
// в очередь. Правит их тот, кто управляет настройками; остальным сервер
|
||||
// отвечает отказом, поэтому карточка просто не покажется.
|
||||
|
||||
type Draft = Record<QueuePolicyField, string>;
|
||||
|
||||
const draftOf = (policy: QueuePolicy): Draft =>
|
||||
Object.fromEntries(QUEUE_POLICY_FIELDS.map(({ key }) => [key, String(policy[key])])) as Draft;
|
||||
|
||||
export function QueuePolicyCard({ canManage }: { canManage: boolean }) {
|
||||
const [policy, setPolicy] = useState<QueuePolicy | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueuePolicy()
|
||||
.then((loaded) => {
|
||||
setPolicy(loaded);
|
||||
setDraft(draftOf(loaded));
|
||||
})
|
||||
.catch(() => setError(t("settings.queue_policy_unavailable")));
|
||||
}, []);
|
||||
|
||||
async function save(values: Partial<Record<QueuePolicyField, number>>) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await saveQueuePolicy(values);
|
||||
setPolicy(saved);
|
||||
setDraft(draftOf(saved));
|
||||
} catch (problem) {
|
||||
setError(problem instanceof Error ? problem.message : t("settings.queue_policy_unavailable"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !policy) return <p className="settings-note">{error}</p>;
|
||||
if (!policy || !draft) return null;
|
||||
|
||||
const changed = QUEUE_POLICY_FIELDS.some(({ key }) => draft[key] !== String(policy[key]));
|
||||
const isDefault = QUEUE_POLICY_FIELDS.every(({ key }) => policy[key] === policy.defaults[key]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="queue-policy-card">
|
||||
{QUEUE_POLICY_FIELDS.map(({ key, title, hint }) => (
|
||||
<div className="queue-policy-row" key={key}>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<small>{hint}</small>
|
||||
</div>
|
||||
<label className="queue-policy-value">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={draft[key]}
|
||||
disabled={!canManage || busy}
|
||||
onChange={(event) => setDraft({ ...draft, [key]: event.target.value })}
|
||||
aria-label={title}
|
||||
/>
|
||||
<span>{t("settings.queue_minutes_unit")}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="queue-policy-footer">
|
||||
<small>
|
||||
{policy.updatedAt
|
||||
? t("settings.queue_saved_by", {
|
||||
when: `${fmt.shortDate(new Date(policy.updatedAt))}, ${fmt.time(new Date(policy.updatedAt))}`,
|
||||
who: policy.updatedBy,
|
||||
})
|
||||
: t("settings.queue_never_changed")}
|
||||
</small>
|
||||
{canManage && (
|
||||
<div className="queue-policy-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy || isDefault}
|
||||
onClick={() => void save(policy.defaults)}
|
||||
>
|
||||
{t("settings.queue_restore_defaults")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !changed}
|
||||
onClick={() =>
|
||||
void save(
|
||||
Object.fromEntries(
|
||||
QUEUE_POLICY_FIELDS.map(({ key }) => [key, Number(draft[key])]),
|
||||
) as Record<QueuePolicyField, number>,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="settings-note">{error}</p>}
|
||||
<div className="queue-policy-warning">
|
||||
<span><Icon name="alert" size={16} strokeWidth={2} /></span>
|
||||
<p>{t("settings.queue_delivery_warning")}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
|
||||
import { PlatformSettingsCard } from "./PlatformSettingsCard";
|
||||
import { StorageSettingsCard } from "./StorageSettingsCard";
|
||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||
import { QueuePolicyCard } from "./QueuePolicyCard";
|
||||
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
|
||||
import { useIntegrations } from "./useIntegrations";
|
||||
import { useOnboarding } from "../onboarding/useOnboarding";
|
||||
@@ -179,6 +180,7 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
|
||||
}): ReactNode {
|
||||
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
|
||||
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
|
||||
if (section === "queue") return <QueuePolicyCard canManage={canManageSettings(user)} />;
|
||||
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} canManageRelay={user.isInstanceAdmin} />;
|
||||
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api } from "../../api/client";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Сроки очереди (макет «Очередь и уведомления», кадр Q2). Четыре числа в
|
||||
// минутах: через сколько напомнить группе, позвать всех, сообщить руководителю
|
||||
// и сколько ждём назначенного сотрудника.
|
||||
|
||||
export type QueuePolicyField =
|
||||
| "remind_after_minutes"
|
||||
| "widen_after_minutes"
|
||||
| "escalate_after_minutes"
|
||||
| "assignment_timeout_minutes";
|
||||
|
||||
export type QueuePolicy = Record<QueuePolicyField, number> & {
|
||||
defaults: Record<QueuePolicyField, number>;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
/** Порядок — как в макете: от первого напоминания к последнему средству. */
|
||||
export const QUEUE_POLICY_FIELDS: Array<{ key: QueuePolicyField; title: string; hint: string }> = [
|
||||
{
|
||||
key: "remind_after_minutes",
|
||||
title: t("settings.queue_remind_group"),
|
||||
hint: t("settings.queue_remind_group_hint"),
|
||||
},
|
||||
{
|
||||
key: "widen_after_minutes",
|
||||
title: t("settings.queue_call_everyone"),
|
||||
hint: t("settings.queue_call_everyone_hint"),
|
||||
},
|
||||
{
|
||||
key: "escalate_after_minutes",
|
||||
title: t("settings.queue_tell_manager"),
|
||||
hint: t("settings.queue_tell_manager_hint"),
|
||||
},
|
||||
{
|
||||
key: "assignment_timeout_minutes",
|
||||
title: t("settings.queue_wait_for_assignee"),
|
||||
hint: t("settings.queue_wait_for_assignee_hint"),
|
||||
},
|
||||
];
|
||||
|
||||
export const fetchQueuePolicy = () => api<QueuePolicy>("/api/v1/conversations/queue-policy/");
|
||||
|
||||
export const saveQueuePolicy = (patch: Partial<Record<QueuePolicyField, number>>) =>
|
||||
api<QueuePolicy>("/api/v1/conversations/queue-policy/", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { t } from "../../i18n";
|
||||
export type SettingsSectionKey =
|
||||
| "organization"
|
||||
| "groups"
|
||||
| "queue"
|
||||
| "ai"
|
||||
| "integrations"
|
||||
| "communication"
|
||||
@@ -43,6 +44,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
heading: t("common.groups"),
|
||||
lead: t("settings.they_split_conversations_between_operators"),
|
||||
},
|
||||
{
|
||||
key: "queue",
|
||||
label: t("settings.when_to_call_for_help"),
|
||||
icon: "clock",
|
||||
heading: t("settings.when_to_call_for_help"),
|
||||
lead: t("settings.queue_section_lead"),
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
label: t("settings.ai_provider"),
|
||||
|
||||
@@ -937,3 +937,110 @@
|
||||
grid-template-columns: minmax(0, 1fr) 72px 72px 72px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Сроки очереди (макет «Очередь и уведомления», кадр Q2): строка — правило,
|
||||
поле — число в минутах, единица вынесена из поля. */
|
||||
.queue-policy-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-8);
|
||||
border-radius: 12px;
|
||||
padding: 6px 24px 20px;
|
||||
}
|
||||
|
||||
.queue-policy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--n-9);
|
||||
}
|
||||
|
||||
.queue-policy-row > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-policy-row strong {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--n-1);
|
||||
}
|
||||
|
||||
.queue-policy-row small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--n-4);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.queue-policy-value {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.queue-policy-value input {
|
||||
height: 36px;
|
||||
width: 86px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
background: var(--n-10, var(--surface-card));
|
||||
color: var(--n-1);
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.queue-policy-value span {
|
||||
width: 40px;
|
||||
font-size: 12.5px;
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
.queue-policy-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.queue-policy-footer small {
|
||||
font-size: 12px;
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
.queue-policy-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Сроки бесполезны, если сотруднику некуда прийти уведомлению. */
|
||||
.queue-policy-warning {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border, var(--warning-bg));
|
||||
}
|
||||
|
||||
.queue-policy-warning span {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.queue-policy-warning p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--warning-text);
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -529,6 +529,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"common.connected": "Connected",
|
||||
"common.connections": "Connections",
|
||||
"common.contact": "Contact",
|
||||
"common.enabled": "Enabled",
|
||||
"common.contacts": "Contacts",
|
||||
"common.contains_digit": "Contains a digit",
|
||||
"common.conversations": "Conversations",
|
||||
@@ -747,6 +748,25 @@ export const en: Record<MessageKey, Message> = {
|
||||
"conversations.transcribe": "Transcribe",
|
||||
"conversations.transcribing": "Transcribing…",
|
||||
"conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template",
|
||||
"conversations.common_queue": "Common queue",
|
||||
"conversations.on_me": "On me",
|
||||
"conversations.queue_tabs_note": "“Common queue” holds conversations with no assignee: anyone can take them. “On me” are assigned to you personally and wait until you take them.",
|
||||
"conversations.on_you_returns_in": "on you · returns in {count} min",
|
||||
"conversations.on_you": "on you",
|
||||
"conversations.waiting_for_person": "Waiting for a person {duration}",
|
||||
"conversations.assigned_not_taken": "Assigned to {name}, not taken",
|
||||
"conversations.returns_to_queue_in": "If they do not take it, the conversation returns to the common queue in {duration}.",
|
||||
"conversations.in_queue_since": "In the queue since",
|
||||
"conversations.presence_here": "Here now",
|
||||
"conversations.presence_away": "Away",
|
||||
"conversations.presence_note": "“Here now” means the app is open. The sign is approximate: anyone can be assigned.",
|
||||
"conversations.here": "here now",
|
||||
"conversations.here_owner": "here now · owner",
|
||||
"conversations.away_minutes": "away · {count} min",
|
||||
"conversations.away_hours": "away · {count} h",
|
||||
"conversations.away_long": "away for over a day",
|
||||
"conversations.never_in_app": "has not signed in yet",
|
||||
"conversations.open_dialogs": "{count} conv.",
|
||||
"conversations.unassigned": "Unassigned",
|
||||
"conversations.update_browser_or_open_link": "Update your browser or open the link in Chrome, Safari or Edge.",
|
||||
"conversations.video_call": "video call",
|
||||
@@ -763,6 +783,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"plural.categories": { one: "{count} category", other: "{count} categories" },
|
||||
"plural.chunks": { one: "{count} chunk", other: "{count} chunks" },
|
||||
"plural.conversations": { one: "{count} conversation", other: "{count} conversations" },
|
||||
"plural.minutes": { one: "{count} minute", other: "{count} minutes" },
|
||||
"plural.devices": { one: "{count} device", other: "{count} devices" },
|
||||
"plural.documents": { one: "{count} document", other: "{count} documents" },
|
||||
"plural.events": { one: "{count} event", other: "{count} events" },
|
||||
@@ -877,8 +898,8 @@ export const en: Record<MessageKey, Message> = {
|
||||
"portals.import_2": "Import",
|
||||
"portals.import_articles_from_yaml": "Import articles from YAML",
|
||||
"portals.importing": "Importing…",
|
||||
"portals.insert_image": "Insert an image",
|
||||
"portals.installation_domain_missing": "The installation address has no domain — there is nowhere to host a portal. Set the installation domain in Settings, then come back.",
|
||||
"portals.insert_image": "Insert an image",
|
||||
"portals.insert_into_text": "Insert into the text",
|
||||
"portals.inserted_into_text": "· inserted into the text",
|
||||
"portals.invalid_yaml": "Invalid YAML",
|
||||
@@ -1228,6 +1249,15 @@ export const en: Record<MessageKey, Message> = {
|
||||
"profile.current": "current",
|
||||
"profile.current_password": "Current password",
|
||||
"profile.custom_colour_hex_value_like": "A custom colour is a HEX value like #0f9b8e",
|
||||
"profile.browser_blocked": "Blocked by the browser",
|
||||
"profile.browser_blocked_steps": "Click the lock icon left of the address → “Notifications” → “Allow”, then reload the page. The site cannot lift the block itself.",
|
||||
"profile.browser_blocked_title": "Only the browser can lift this block",
|
||||
"profile.browser_disable_note": "“Turn off” stops notifications in this browser. The permission itself stays granted — only your browser settings can revoke it.",
|
||||
"profile.browser_enabled": "On",
|
||||
"profile.browser_not_enabled_yet": "Off. The browser will ask for permission when you click.",
|
||||
"profile.browser_visible_when_minimized": "{device} — visible while the tab is minimised",
|
||||
"profile.check_again": "Check again",
|
||||
"profile.enable": "Enable",
|
||||
"profile.dark": "Dark",
|
||||
"profile.disconnect": "Disconnect",
|
||||
"profile.drag_resize_double_click_reset": "Drag to resize, double-click to reset",
|
||||
@@ -1285,7 +1315,8 @@ export const en: Record<MessageKey, Message> = {
|
||||
"profile.link_bot": "Link a bot",
|
||||
"profile.match_system": "Match the system",
|
||||
"profile.menu": "Menu",
|
||||
"profile.messenger_notifications": "Messenger notifications",
|
||||
"profile.this_browser": "This browser",
|
||||
"profile.work_event_notifications": "Work event notifications",
|
||||
"profile.min_10_characters": "min. 10 characters",
|
||||
"profile.no_notification_bots_configured_owner": "No notification bots are configured. The owner can add one under Settings → Integrations (the “Notification bot for operators” checkbox).",
|
||||
"profile.off_strongly_recommended_owner_administrators": "Off. Strongly recommended for the owner and administrators.",
|
||||
@@ -1314,7 +1345,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"profile.updating": "Updating",
|
||||
"profile.user_menu": "User menu",
|
||||
"profile.without_second_factor_access_organization": "Without a second factor, access to the organization rests on the password alone",
|
||||
"profile.work_events_reach_even_with": "Work events reach you even with the browser closed. One messenger at a time: linking a new one replaces the current.",
|
||||
"profile.choose_where_to_call_you": "Choose where to reach you. The browser — on this device, while the app is open or minimised. A messenger — anywhere, including with the browser closed; one messenger at a time: linking a new one replaces the current.",
|
||||
"sales.all_agents": "All agents",
|
||||
"sales.all_channels": "All channels",
|
||||
"sales.anonymous_session": "{channel} · {connection}, anonymous session",
|
||||
@@ -1407,6 +1438,22 @@ export const en: Record<MessageKey, Message> = {
|
||||
"settings.address_hint": "Until you have a domain, this holds the server address — the one you ran the first-start wizard on. Once you have a domain and put TLS in front of the installation, enter it here and switch to https. Links will look like",
|
||||
"settings.address_placeholder": "address",
|
||||
"settings.addressing": "Addressing",
|
||||
"settings.when_to_call_for_help": "When to call for help",
|
||||
"settings.queue_section_lead": "When a customer is waiting for a person, we remind about the conversation — the group first, then everyone, then a manager. The delays count from the moment the conversation joined the queue and apply to the whole organisation.",
|
||||
"settings.queue_remind_group": "Remind the group the conversation is waiting",
|
||||
"settings.queue_remind_group_hint": "The notification goes to the group the conversation belongs to.",
|
||||
"settings.queue_call_everyone": "Call everyone if it is still waiting",
|
||||
"settings.queue_call_everyone_hint": "The notification goes to every employee, not just the group.",
|
||||
"settings.queue_tell_manager": "Tell a manager",
|
||||
"settings.queue_tell_manager_hint": "The owner and admins — it means the usual people did not pick it up.",
|
||||
"settings.queue_wait_for_assignee": "How long we wait for the assigned employee",
|
||||
"settings.queue_wait_for_assignee_hint": "If they do not take it in time, the conversation goes back to the common queue and is visible to everyone again.",
|
||||
"settings.queue_minutes_unit": "min",
|
||||
"settings.queue_saved_by": "Saved {when} · {who}",
|
||||
"settings.queue_never_changed": "The delays have never been changed",
|
||||
"settings.queue_restore_defaults": "Restore the usual delays",
|
||||
"settings.queue_policy_unavailable": "Could not load the delays",
|
||||
"settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.",
|
||||
"settings.ai_provider": "AI provider",
|
||||
"settings.allowed_domains": "Allowed domains",
|
||||
"settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.",
|
||||
|
||||
@@ -530,6 +530,7 @@ export const ru = {
|
||||
"common.connected": "Подключено",
|
||||
"common.connections": "Подключения",
|
||||
"common.contact": "Контакт",
|
||||
"common.enabled": "Включено",
|
||||
"common.contacts": "Контакты",
|
||||
"common.contains_digit": "Содержит цифру",
|
||||
"common.conversations": "Диалоги",
|
||||
@@ -748,6 +749,25 @@ export const ru = {
|
||||
"conversations.transcribe": "Расшифровать",
|
||||
"conversations.transcribing": "Расшифровываем…",
|
||||
"conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа",
|
||||
"conversations.common_queue": "Общая очередь",
|
||||
"conversations.on_me": "На мне",
|
||||
"conversations.queue_tabs_note": "«Общая очередь» — диалоги без ответственного: взять может любой. «На мне» — назначены лично вам и ждут, пока вы их возьмёте.",
|
||||
"conversations.on_you_returns_in": "на вас · вернётся через {count} мин",
|
||||
"conversations.on_you": "на вас",
|
||||
"conversations.waiting_for_person": "Ждёт человека {duration}",
|
||||
"conversations.assigned_not_taken": "Назначен на {name}, не взят",
|
||||
"conversations.returns_to_queue_in": "Если не возьмёт — через {duration} диалог вернётся в общую очередь.",
|
||||
"conversations.in_queue_since": "В очереди с",
|
||||
"conversations.presence_here": "На месте",
|
||||
"conversations.presence_away": "Сейчас нет",
|
||||
"conversations.presence_note": "«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.",
|
||||
"conversations.here": "на месте",
|
||||
"conversations.here_owner": "на месте · владелец",
|
||||
"conversations.away_minutes": "не в приложении · {count} мин",
|
||||
"conversations.away_hours": "не в приложении · {count} ч",
|
||||
"conversations.away_long": "не в приложении больше суток",
|
||||
"conversations.never_in_app": "ещё не заходил",
|
||||
"conversations.open_dialogs": "{count} диал.",
|
||||
"conversations.unassigned": "Не назначен",
|
||||
"conversations.update_browser_or_open_link": "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.",
|
||||
"conversations.video_call": "видеозвонок",
|
||||
@@ -764,6 +784,7 @@ export const ru = {
|
||||
"plural.categories": { one: "{count} категория", few: "{count} категории", many: "{count} категорий", other: "{count} категорий" },
|
||||
"plural.chunks": { one: "{count} фрагмент", few: "{count} фрагмента", many: "{count} фрагментов", other: "{count} фрагментов" },
|
||||
"plural.conversations": { one: "{count} диалог", few: "{count} диалога", many: "{count} диалогов", other: "{count} диалогов" },
|
||||
"plural.minutes": { one: "{count} минуту", few: "{count} минуты", many: "{count} минут", other: "{count} минут" },
|
||||
"plural.devices": { one: "{count} устройство", few: "{count} устройства", many: "{count} устройств", other: "{count} устройств" },
|
||||
"plural.documents": { one: "{count} документ", few: "{count} документа", many: "{count} документов", other: "{count} документов" },
|
||||
"plural.events": { one: "{count} событие", few: "{count} события", many: "{count} событий", other: "{count} событий" },
|
||||
@@ -878,8 +899,8 @@ export const ru = {
|
||||
"portals.import_2": "Импорт",
|
||||
"portals.import_articles_from_yaml": "Импорт статей из YAML",
|
||||
"portals.importing": "Импорт…",
|
||||
"portals.insert_image": "Вставить изображение",
|
||||
"portals.installation_domain_missing": "Адрес установки задан без домена — размещать портал негде. Укажите домен установки в «Настройках», затем вернитесь сюда.",
|
||||
"portals.insert_image": "Вставить изображение",
|
||||
"portals.insert_into_text": "Вставить в текст",
|
||||
"portals.inserted_into_text": "· вставлен в текст",
|
||||
"portals.invalid_yaml": "Невалидный YAML",
|
||||
@@ -1229,6 +1250,15 @@ export const ru = {
|
||||
"profile.current": "текущая",
|
||||
"profile.current_password": "Текущий пароль",
|
||||
"profile.custom_colour_hex_value_like": "Свой цвет — HEX вида #0f9b8e",
|
||||
"profile.browser_blocked": "Браузер запретил",
|
||||
"profile.browser_blocked_steps": "Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.",
|
||||
"profile.browser_blocked_title": "Снять запрет можно только в браузере",
|
||||
"profile.browser_disable_note": "«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.",
|
||||
"profile.browser_enabled": "Включены",
|
||||
"profile.browser_not_enabled_yet": "Не включены. Браузер спросит разрешение по нажатию.",
|
||||
"profile.browser_visible_when_minimized": "{device} — видно, когда вкладка свёрнута",
|
||||
"profile.check_again": "Проверить снова",
|
||||
"profile.enable": "Включить",
|
||||
"profile.dark": "Тёмная",
|
||||
"profile.disconnect": "Отключить",
|
||||
"profile.drag_resize_double_click_reset": "Потяните, двойной клик — сбросить",
|
||||
@@ -1286,7 +1316,8 @@ export const ru = {
|
||||
"profile.link_bot": "Привязать бота",
|
||||
"profile.match_system": "Как в системе",
|
||||
"profile.menu": "Меню",
|
||||
"profile.messenger_notifications": "Уведомления в мессенджер",
|
||||
"profile.this_browser": "Этот браузер",
|
||||
"profile.work_event_notifications": "Уведомления о рабочих событиях",
|
||||
"profile.min_10_characters": "мин. 10 символов",
|
||||
"profile.no_notification_bots_configured_owner": "Боты уведомлений не настроены. Владелец может добавить бота в «Настройках» → «Интеграции» (флажок «Бот уведомлений для сотрудников»).",
|
||||
"profile.off_strongly_recommended_owner_administrators": "Отключена. Для владельца и администраторов настоятельно рекомендуется включить.",
|
||||
@@ -1315,7 +1346,7 @@ export const ru = {
|
||||
"profile.updating": "Обновление",
|
||||
"profile.user_menu": "Меню пользователя",
|
||||
"profile.without_second_factor_access_organization": "Без второго фактора доступ к организации защищён только паролем",
|
||||
"profile.work_events_reach_even_with": "Рабочие события приходят и при закрытом браузере. Один мессенджер: привязка нового заменяет текущий.",
|
||||
"profile.choose_where_to_call_you": "Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.",
|
||||
"sales.all_agents": "Все агенты",
|
||||
"sales.all_channels": "Все каналы",
|
||||
"sales.anonymous_session": "{channel} · {connection}, анонимная сессия",
|
||||
@@ -1408,6 +1439,22 @@ export const ru = {
|
||||
"settings.address_hint": "Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск. Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https. Ссылки будут вида",
|
||||
"settings.address_placeholder": "адрес",
|
||||
"settings.addressing": "Адресация",
|
||||
"settings.when_to_call_for_help": "Когда звать на помощь",
|
||||
"settings.queue_section_lead": "Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.",
|
||||
"settings.queue_remind_group": "Напомнить группе, что диалог ждёт",
|
||||
"settings.queue_remind_group_hint": "Уведомление получат сотрудники группы, за которой закреплён диалог.",
|
||||
"settings.queue_call_everyone": "Позвать всех, если диалог всё ещё ждёт",
|
||||
"settings.queue_call_everyone_hint": "Уведомление уйдёт всем сотрудникам, а не только группе.",
|
||||
"settings.queue_tell_manager": "Сообщить руководителю",
|
||||
"settings.queue_tell_manager_hint": "Владельцу и администраторам — значит, обычными силами диалог не разобрали.",
|
||||
"settings.queue_wait_for_assignee": "Сколько ждём сотрудника, на которого назначен диалог",
|
||||
"settings.queue_wait_for_assignee_hint": "Не взял за это время — диалог возвращается в общую очередь и снова виден всем.",
|
||||
"settings.queue_minutes_unit": "мин",
|
||||
"settings.queue_saved_by": "Сохранено {when} · {who}",
|
||||
"settings.queue_never_changed": "Сроки ни разу не меняли",
|
||||
"settings.queue_restore_defaults": "Вернуть обычные сроки",
|
||||
"settings.queue_policy_unavailable": "Не удалось загрузить сроки",
|
||||
"settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.",
|
||||
"settings.ai_provider": "AI-провайдер",
|
||||
"settings.allowed_domains": "Разрешённые домены",
|
||||
"settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { notification as antToast } from "antd";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { AppData, AuthenticatedUser, Employee, RouteKey, SessionUser } from "../types";
|
||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||||
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
|
||||
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
|
||||
import { useNotificationAlerts } from "../features/notifications/useNotificationAlerts";
|
||||
import { fetchWaitingCount } from "../features/conversations/model";
|
||||
import { useRealtime, useRealtimeEvent } from "../features/realtime/RealtimeProvider";
|
||||
import { useChatScope } from "../features/chat/useChatScope";
|
||||
import { isManager } from "../auth/access";
|
||||
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
|
||||
@@ -26,7 +27,6 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
const [waitingCount, setWaitingCount] = useState(0);
|
||||
// Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента.
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||||
const prevUnread = useRef<number | null>(null);
|
||||
const manager = isManager(user);
|
||||
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
|
||||
// состояние (дизайн-базлайн v2 §4.1).
|
||||
@@ -47,28 +47,41 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
||||
const loadNotifications = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchNotifications();
|
||||
setNotifications(payload.items);
|
||||
setUnreadCount(payload.unreadCount);
|
||||
// Тост при появлении новых непрочитанных (после первой загрузки).
|
||||
if (prevUnread.current !== null && payload.unreadCount > prevUnread.current) {
|
||||
const latest = payload.items.find((item) => item.unread);
|
||||
if (latest) antToast.open({ message: latest.title, description: latest.body, placement: "bottomRight" });
|
||||
}
|
||||
prevUnread.current = payload.unreadCount;
|
||||
setNotifications(payload.items ?? []);
|
||||
setUnreadCount(payload.unreadCount ?? 0);
|
||||
} catch {
|
||||
/* ignore transient errors */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Уведомление доезжает событием, а не следующим опросом: раньше оператор
|
||||
// узнавал о ждущем диалоге в среднем через восемь секунд после того, как
|
||||
// клиент написал, и только если вкладка была открыта на чате.
|
||||
const realtime = useRealtime();
|
||||
useRealtimeEvent("notifications.changed", () => void loadNotifications());
|
||||
useRealtimeEvent("inbox.changed", () => void loadWaitingCount());
|
||||
|
||||
useEffect(() => {
|
||||
void loadNotifications();
|
||||
void loadWaitingCount();
|
||||
}, [loadNotifications, loadWaitingCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// Опрос остаётся страховкой на случай обрыва сокета и потому разрежается,
|
||||
// пока тот жив. Смена состояния соединения меняет только интервал: если
|
||||
// перезапускать вместе с ним и загрузку, недоступный сокет с его
|
||||
// переподключениями превращается в поток лишних запросов.
|
||||
const tick = () => {
|
||||
void loadNotifications();
|
||||
void loadWaitingCount();
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 15000);
|
||||
const timer = setInterval(tick, realtime.connected ? 60000 : 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadNotifications, loadWaitingCount]);
|
||||
}, [loadNotifications, loadWaitingCount, realtime.connected]);
|
||||
|
||||
// Тост и системное уведомление — по идентификаторам пришедшего, а не по
|
||||
// росту счётчика непрочитанных.
|
||||
useNotificationAlerts({ items: notifications, onOpen: (item) => void onNotificationClick(item) });
|
||||
|
||||
async function onNotificationClick(notification: AppNotification) {
|
||||
setNotifOpen(false);
|
||||
|
||||
@@ -57,7 +57,7 @@ export function MaxLogo({ size = 24 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
|
||||
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
|
||||
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||
const paths: Record<typeof name, ReactNode> = {
|
||||
thumbUp: <><path d="M7 10v11H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3z" /><path d="M7 10 11 3a2.2 2.2 0 0 1 4 1.8L14.5 8H20a2 2 0 0 1 2 2.4l-1.4 7A4 4 0 0 1 16.7 21H7" /></>,
|
||||
@@ -74,6 +74,8 @@ export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "b
|
||||
plug: <path d="M12 22v-5M9 8V2M15 8V2M18 8v5a6 6 0 0 1-12 0V8z" />,
|
||||
settings: <><line x1="21" y1="6" x2="9" y2="6" /><line x1="3" y1="6" x2="5" y2="6" /><circle cx="7" cy="6" r="2" /><line x1="21" y1="12" x2="13" y2="12" /><line x1="3" y1="12" x2="9" y2="12" /><circle cx="11" cy="12" r="2" /><line x1="21" y1="18" x2="15" y2="18" /><line x1="3" y1="18" x2="11" y2="18" /><circle cx="13" cy="18" r="2" /></>,
|
||||
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /></>,
|
||||
// Перечёркнутый колокол: браузер запретил уведомления (макет Q1b).
|
||||
bellOff: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /><line x1="3" y1="3" x2="21" y2="21" /></>,
|
||||
message: <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />,
|
||||
cart: <><path d="M2 3h2l2.6 12.4a2 2 0 0 0 2 1.6h9.7a2 2 0 0 0 2-1.6L23 6H6" /><circle cx="9" cy="20" r="1" /><circle cx="18" cy="20" r="1" /></>,
|
||||
globe: <path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Очередь, ожидание и уведомления — решения по макету
|
||||
|
||||
Файл: `Очередь и уведомления Baseline.dc.html` (тема/акцент — в Tweaks, как у остальных baseline-файлов). Фреймы Q1–Q5 соответствуют пяти пунктам задачи. Тексты — без слов «эскалация», «порог», «SLA».
|
||||
|
||||
## Q1 · Профиль → карточка уведомлений
|
||||
|
||||
- **Заголовок:** «Уведомления о рабочих событиях».
|
||||
- **Подводка:** «Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.» Первая фраза честна для обоих способов, ограничение «один мессенджер» отнесено к мессенджеру.
|
||||
- **Порядок:** браузер **выше** ботов. Он включается одним нажатием и без внешнего аккаунта — это первый разумный шаг; боты ниже как «доставка наружу».
|
||||
- **Три состояния строки (Q1b):**
|
||||
1. *не запрашивали* — серая плитка, текст «Не включены. Браузер спросит разрешение по нажатию», primary «Включить»;
|
||||
2. *выдано и включено* — зелёная плашка «Включены», «Отключить», галочки типов событий;
|
||||
3. *браузер отказал* — красная плашка «Браузер запретил» + подсказка «Снять запрет можно только в браузере» с шагами (замок в адресной строке → «Уведомления» → «Разрешать» → обновить) и кнопкой «Проверить снова». Кнопки «Включить» в этом состоянии нет — она бы ничего не сделала.
|
||||
- Под включённой строкой — примечание, что «Отключить» выключает показ, а не разрешение.
|
||||
- В типах событий добавлен пункт «Диалог назначили на меня» — иначе новый вид ожидания никак не доходит до сотрудника.
|
||||
- **Дополнено после хендоффа (решение владельца):** первым пунктом вернулся «Новый диалог». В присланном наборе такого события не было, а в продукте оклик о новом диалоге уходит и тогда, когда диалог ведёт агент; называть его «клиент запросил оператора» — неправда. По умолчанию пункт выключен: новый диалог, которым занимается робот, лично никого не касается. Тем, у кого он был включён раньше, он сохранён при обновлении.
|
||||
|
||||
## Q2 · Настройки → «Когда звать на помощь»
|
||||
|
||||
- **Новый раздел субменю** сразу после «Групп» (сроки касаются групп), не внутри «Организации»: там реквизиты организации, а здесь правила работы очереди — смешивать их значит спрятать сроки.
|
||||
- Название раздела — по сути: «Когда звать на помощь».
|
||||
- Четыре поля, каждое с пояснением: напомнить группе (5), позвать всех (15), сообщить руководителю (30), сколько ждём назначенного сотрудника (10). Единица «мин» вынесена из поля.
|
||||
- Футер: дата и автор сохранения, «Вернуть обычные сроки», primary «Сохранить».
|
||||
- Предупреждение внизу: если у группы не настроен ни один способ доставки, останется только счётчик в списке.
|
||||
|
||||
## Q3 · Чат → два вида ожидания
|
||||
|
||||
Показаны два варианта, **рекомендуем А**:
|
||||
|
||||
- **А · две вкладки.** «Ждут оператора» разделена на «Общая очередь» и «На мне». Разные действия (взять чужое / не потерять своё) — разные списки; счётчик «На мне» становится личной нормой, а не частью общего числа.
|
||||
- **Б · одна вкладка с метками.** Меньше вкладок, но два вида ожидания перемешаны, и своё приходится выцеплять глазами.
|
||||
|
||||
В обоих вариантах строка получила синюю метку «на вас · вернётся через N мин».
|
||||
|
||||
## Q4 · Строка и карточка диалога
|
||||
|
||||
- **Время ожидания — и там и там.** В строке это уже существующий тёплый бейдж с таймером (без слова «ждёт»), в карточке — новый блок «Ожидание» сверху: «Ждёт человека 7 минут».
|
||||
- **«Назначен, но не взят» показываем явно** — с аватаром и оставшимся временем: «Назначен на Анну Ким, не взят» + «Если не возьмёт — через 3 минуты диалог вернётся в общую очередь». Это единственное, что объясняет, почему диалог виден, но брать его не нужно.
|
||||
- Поле «Начат» в карточке заменено на «В очереди с» — момент постановки в очередь информативнее для оператора.
|
||||
- **Системные события** — обычный вид. Единственное исключение: «не взял — вернулся в очередь» окрашен как предупреждение (тёплая плашка): это не факт из истории, а сорванная договорённость.
|
||||
|
||||
## Q5 · Ответственный → присутствие
|
||||
|
||||
- В списке две группы: «На месте» и «Сейчас нет», внутри — сотрудники с точкой присутствия (зелёная / пустая обводка) и подписью «на месте» / «не в приложении · 25 мин».
|
||||
- Отсутствующие **не блокируются и не скрываются** — только уходят в конец списка; в футере списка честная оговорка: «„На месте“ — приложение открыто. Признак приблизительный: назначить можно любого».
|
||||
- Справа у строки — текущая загрузка («4 диалога»): при выборе ответственного это второй по важности признак после присутствия.
|
||||
- В закрытом поле «Ответственный» присутствие показано той же точкой справа от имени.
|
||||
|
||||
## Что требует бэкенда
|
||||
|
||||
- Разрешение браузера и «желание получать» — два разных флага: `browser_push_enabled` на устройстве/сессии + типы событий, как у ботов.
|
||||
- Четыре срока организации — в API настроек (сейчас только служебная админка).
|
||||
- В списке диалогов нужны `assignee`, `assigned_at` и момент постановки в очередь, чтобы считать «вернётся через N мин».
|
||||
- Признак присутствия сотрудника (последняя активность) в выборе ответственного.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;}
|
||||
a{color:inherit;text-decoration:none;}
|
||||
a:hover{opacity:0.85;}
|
||||
*{scrollbar-width:none;}
|
||||
*::-webkit-scrollbar{display:none;width:0;height:0;}
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
|
||||
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
|
||||
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatballs · Очередь, ожидание и уведомления</h1>
|
||||
<span style="font-size:13px;color:var(--c-n4);">дополнение к design baseline · пять мест</span>
|
||||
</div>
|
||||
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:1000px;line-height:1.55;">Дорисованы места, которых не было в макете: уведомления браузера в профиле, сроки ожидания в настройках, разделение двух видов ожидания в списке диалогов, время ожидания в строке и карточке, присутствие сотрудника в выборе ответственного. Служебные слова («эскалация», «порог», «SLA») в текстах не используются.</p>
|
||||
|
||||
<div style="display:flex;flex-wrap:wrap;gap:36px;align-items:flex-start;">
|
||||
|
||||
<!-- ================= Q1 · профиль · уведомления ================= -->
|
||||
<div id="q1" style="flex:none;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q1</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Профиль · карточка уведомлений</strong>
|
||||
<span style="font-size:12px;color:var(--c-n4);">новый заголовок и подводка; строка браузера выше ботов</span>
|
||||
</div>
|
||||
|
||||
<div style="width:660px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
|
||||
<h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Уведомления о рабочих событиях</h3>
|
||||
<p style="margin:0 0 14px;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Выберите, где вас звать. Браузер — на этом устройстве, пока приложение открыто или свёрнуто. Мессенджер — где угодно, в том числе при закрытом браузере; мессенджер один: привязка нового заменяет текущий.</p>
|
||||
|
||||
<!-- строка: браузер, разрешение выдано -->
|
||||
<div style="border-top:1px solid var(--c-n9);padding:14px 0 12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:var(--c-accent-bg);color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Этот браузер</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Chrome · macOS — видно, когда вкладка свёрнута</small></div>
|
||||
<b style="display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Включены</b>
|
||||
<button style="height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Отключить</button>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px 18px;margin:12px 0 0 46px;">
|
||||
<sc-for list="{{ typesOn }}" as="p" hint-placeholder-count="6"><label style="display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--c-n2);cursor:pointer;"><span style="width:16px;height:16px;border-radius:4px;border:1px solid {{ p.border }};background:{{ p.bg }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ p.on }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></span>{{ p.label }}</label></sc-for>
|
||||
</div>
|
||||
<p style="margin:10px 0 0 46px;font-size:11.5px;color:var(--c-n4);line-height:1.45;">«Отключить» выключает показ уведомлений в этом браузере. Само разрешение остаётся выданным — снять его можно только в настройках браузера.</p>
|
||||
</div>
|
||||
|
||||
<!-- строка: Telegram -->
|
||||
<div style="border-top:1px solid var(--c-n9);padding:14px 0 12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:color-mix(in srgb,#229ED9 14%,var(--c-surface));color:#229ED9;display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 512 512" width="19" height="19" fill="currentColor"><path d="{{ tg }}"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Telegram</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">@nord_notify_bot · сервисный бот уведомлений</small></div>
|
||||
<b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;">Подключено</b>
|
||||
<button style="height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Отключить</button>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px 18px;margin:12px 0 0 46px;">
|
||||
<sc-for list="{{ typesTg }}" as="p" hint-placeholder-count="6"><label style="display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--c-n2);cursor:pointer;"><span style="width:16px;height:16px;border-radius:4px;border:1px solid {{ p.border }};background:{{ p.bg }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ p.on }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></span>{{ p.label }}</label></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- строка: MAX -->
|
||||
<div style="border-top:1px solid var(--c-n9);padding:14px 0 12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:color-mix(in srgb,#5b5bd6 14%,var(--c-surface));color:#5b5bd6;display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 720 720" width="19" height="19" fill="currentColor"><path d="{{ max }}"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">MAX</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Привязка заменит Telegram</small></div>
|
||||
<a href="#q1" style="height:30px;padding:0 12px;border-radius:7px;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:6px;">Привязать бота<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin:6px 0 0;font-size:12px;color:var(--c-n4);line-height:1.5;">Нажмите «Привязать бота» и в открывшемся чате — «Начать»: привязка подтвердится сама.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Q1b · три состояния строки браузера ================= -->
|
||||
<div id="q1b" style="flex:none;width:520px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q1b</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Строка браузера · три состояния</strong>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
<!-- 1 · не запрашивали -->
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:14px 20px;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:0.04em;color:var(--c-n5);margin-bottom:10px;">1 · РАЗРЕШЕНИЕ НЕ ЗАПРАШИВАЛИ</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:var(--c-n9);color:var(--c-n4);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Этот браузер</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Не включены. Браузер спросит разрешение по нажатию.</small></div>
|
||||
<button style="height:30px;padding:0 12px;border-radius:7px;border:none;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Включить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 · выдано и включено -->
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:14px 20px;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:0.04em;color:var(--c-n5);margin-bottom:10px;">2 · РАЗРЕШЕНИЕ ВЫДАНО, ВКЛЮЧЕНО</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:var(--c-accent-bg);color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Этот браузер</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Chrome · macOS — видно, когда вкладка свёрнута</small></div>
|
||||
<b style="display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Включены</b>
|
||||
<button style="height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Отключить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3 · отказ -->
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:14px 20px;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:0.04em;color:var(--c-n5);margin-bottom:10px;">3 · БРАУЗЕР ОТКАЗАЛ</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:var(--c-err-bg);color:var(--c-err-text);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/><line x1="3" y1="3" x2="21" y2="21"/></svg></span>
|
||||
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Этот браузер</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Chrome · macOS</small></div>
|
||||
<b style="display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:6px;background:var(--c-err-bg);border:1px solid var(--c-err-border);color:var(--c-err-text);font-size:11.5px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>Браузер запретил</b>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin:12px 0 0 46px;padding:10px 12px;border-radius:9px;background:var(--c-n10);border:1px solid var(--c-n9);">
|
||||
<span style="flex:none;color:var(--c-n4);margin-top:1px;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></span>
|
||||
<div style="min-width:0;">
|
||||
<strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n2);">Снять запрет можно только в браузере</strong>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:var(--c-n4);line-height:1.5;">Нажмите значок замка слева от адреса → «Уведомления» → «Разрешать», затем обновите страницу. Сайт снять запрет не может.</p>
|
||||
<button style="margin-top:9px;height:28px;padding:0 11px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);color:var(--c-accent);">Проверить снова</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Q2 · настройки · сроки ================= -->
|
||||
<div id="q2" style="flex:none;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q2</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Настройки · новый раздел «Когда звать на помощь»</strong>
|
||||
<span style="font-size:12px;color:var(--c-n4);">отдельный пункт субменю сразу после «Групп»</span>
|
||||
</div>
|
||||
|
||||
<div style="width:1180px;height:660px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
||||
<nav style="width:240px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);padding:18px 12px;display:flex;flex-direction:column;gap:2px;">
|
||||
<div style="padding:0 10px 12px;font-size:15px;font-weight:700;color:var(--c-n1);">Настройки</div>
|
||||
<sc-for list="{{ settingsNav }}" as="s" hint-placeholder-count="9">
|
||||
<button style="display:flex;align-items:center;gap:8px;height:34px;padding:0 10px;border:none;border-radius:8px;background:{{ s.bg }};color:{{ s.color }};font-size:13.5px;font-weight:{{ s.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="flex:1;">{{ s.label }}</span><sc-if value="{{ s.isNew }}" hint-placeholder-val="{{ false }}"><b style="height:18px;padding:0 6px;border-radius:5px;background:var(--c-accent);color:#fff;font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;">новый</b></sc-if></button>
|
||||
</sc-for>
|
||||
</nav>
|
||||
|
||||
<div style="flex:1;min-width:0;overflow-y:auto;background:var(--c-feed);padding:28px 40px 40px;">
|
||||
<div style="max-width:820px;">
|
||||
<h2 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">Когда звать на помощь</h2>
|
||||
<p style="margin:7px 0 22px;font-size:13px;color:var(--c-n3);line-height:1.55;max-width:700px;">Если клиент ждёт человека, мы напоминаем о диалоге — сначала группе, потом всем, потом руководителю. Сроки считаются с момента, когда диалог встал в очередь. Действуют на всю организацию.</p>
|
||||
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:6px 24px 20px;">
|
||||
<sc-for list="{{ delays }}" as="d" hint-placeholder-count="4">
|
||||
<div style="display:flex;align-items:center;gap:20px;padding:16px 0;border-bottom:1px solid var(--c-n9);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ d.title }}</strong>
|
||||
<small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.5;">{{ d.hint }}</small>
|
||||
</div>
|
||||
<div style="flex:none;display:flex;align-items:center;gap:8px;">
|
||||
<span style="display:flex;align-items:center;height:36px;width:86px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;font-weight:600;color:var(--c-n1);font-variant-numeric:tabular-nums;justify-content:flex-end;">{{ d.value }}</span>
|
||||
<span style="font-size:12.5px;color:var(--c-n4);width:40px;">мин</span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:18px;">
|
||||
<small style="font-size:12px;color:var(--c-n4);">Сохранено 12 сентября, 14:20 · Елена Кузнецова</small>
|
||||
<div style="display:flex;gap:10px;">
|
||||
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n3);font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Вернуть обычные сроки</button>
|
||||
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:11px;margin-top:18px;padding:14px 16px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);">
|
||||
<span style="flex:none;color:var(--c-warn-text);margin-top:1px;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg></span>
|
||||
<p style="margin:0;font-size:12.5px;color:var(--c-warn-text);line-height:1.55;">Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Q3 · список диалогов ================= -->
|
||||
<div id="q3" style="flex:none;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q3</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Чат · два вида ожидания в списке</strong>
|
||||
<span style="font-size:12px;color:var(--c-n4);">вариант А — две вкладки (рекомендуем) · вариант Б — одна вкладка с метками</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:28px;align-items:flex-start;">
|
||||
<sc-for list="{{ listVariants }}" as="v" hint-placeholder-count="2">
|
||||
<div style="flex:none;">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;"><strong style="font-size:12.5px;color:var(--c-n1);">{{ v.title }}</strong><span style="font-size:11.5px;color:var(--c-n4);">{{ v.note }}</span></div>
|
||||
<div style="width:400px;height:600px;display:flex;flex-direction:column;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:8px;overflow:hidden;">
|
||||
<div style="flex:none;padding:12px 14px 10px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;"><strong style="font-size:15px;font-weight:700;color:var(--c-n1);">Все диалоги</strong><span style="font-size:11.5px;color:var(--c-n4);">{{ v.total }}</span></div>
|
||||
</div>
|
||||
<div style="flex:none;display:flex;gap:14px;padding:0 14px;border-bottom:1px solid var(--c-n8);overflow:hidden;">
|
||||
<sc-for list="{{ v.tabs }}" as="t" hint-placeholder-count="4">
|
||||
<button style="flex:none;display:inline-flex;align-items:center;gap:5px;padding:9px 2px 8px;margin-bottom:-1px;border:none;border-bottom:2px solid {{ t.line }};background:transparent;color:{{ t.color }};font-size:12.5px;font-weight:{{ t.weight }};cursor:pointer;white-space:nowrap;font-family:inherit;">{{ t.label }}<sc-if value="{{ t.count }}" hint-placeholder-val="{{ false }}"><b style="padding:0 5px;border-radius:8px;background:{{ t.countBg }};color:{{ t.countColor }};font-size:11px;font-weight:700;">{{ t.count }}</b></sc-if></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;overflow-y:auto;">
|
||||
<sc-for list="{{ v.rows }}" as="d" hint-placeholder-count="5">
|
||||
<button style="position:relative;width:100%;display:flex;gap:11px;padding:11px 14px 11px 16px;border:none;border-bottom:1px solid var(--c-n9);background:{{ d.bg }};text-align:left;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n10);">
|
||||
<span style="position:relative;flex:none;display:flex;align-self:flex-start;margin-top:20px;"><span style="width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ d.avatarBg }};color:#fff;font-size:13px;font-weight:600;">{{ d.initials }}</span><i style="position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border:2px solid var(--c-surface);border-radius:50%;background:{{ d.dot }};"></i></span>
|
||||
<span style="min-width:0;flex:1;display:flex;flex-direction:column;gap:3px;">
|
||||
<small style="display:inline-flex;align-items:center;gap:6px;color:var(--c-n4);font-size:11.5px;font-weight:500;overflow:hidden;white-space:nowrap;"><svg viewBox="0 0 512 512" width="13" height="13" fill="currentColor" style="flex:none;"><path d="{{ tg }}"/></svg><span style="overflow:hidden;text-overflow:ellipsis;">{{ d.agent }}</span></small>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><strong style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-n1);font-size:14px;font-weight:600;">{{ d.name }}</strong><em style="flex:none;color:var(--c-n5);font-size:11px;font-style:normal;">{{ d.time }}</em></span>
|
||||
<span style="display:flex;align-items:center;gap:6px;"><small style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-n3);font-size:12.5px;">{{ d.preview }}</small><sc-if value="{{ d.unread }}" hint-placeholder-val="{{ false }}"><b style="min-width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;padding:0 5px;border-radius:9px;background:var(--c-accent);color:#fff;font-size:11px;font-weight:700;flex:none;">{{ d.unread }}</b></sc-if></span>
|
||||
<span style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-top:3px;">
|
||||
<sc-if value="{{ d.wait }}" hint-placeholder-val="{{ true }}"><b style="display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>{{ d.wait }}</b></sc-if>
|
||||
<sc-if value="{{ d.mine }}" hint-placeholder-val="{{ false }}"><b style="display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-accent-bg);border:1px solid var(--c-accent-border);color:var(--c-accent-text);font-size:11px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>{{ d.mine }}</b></sc-if>
|
||||
<sc-if value="{{ d.group }}" hint-placeholder-val="{{ true }}"><b style="display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-surface);border:1px solid var(--c-n8);color:var(--c-n3);font-size:11px;font-weight:500;"><i style="width:7px;height:7px;border-radius:50%;background:{{ d.groupDot }};"></i>{{ d.group }}</b></sc-if>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
<div style="padding:14px 16px;font-size:11.5px;color:var(--c-n4);line-height:1.5;">{{ v.footer }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Q4 · ожидание в карточке и лента ================= -->
|
||||
<div id="q4" style="flex:none;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q4</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Карточка диалога · блок «Ожидание» и системные события</strong>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:28px;align-items:flex-start;">
|
||||
<!-- карточка диалога -->
|
||||
<div style="width:340px;flex:none;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:8px;padding:16px 18px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"><h4 style="margin:0;font-size:13px;font-weight:700;color:var(--c-n1);">Диалог</h4><span style="font-size:11.5px;color:var(--c-n4);">фрагмент контекст-панели</span></div>
|
||||
|
||||
<div style="padding:12px;border-radius:9px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--c-warn-text);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><strong style="font-size:12.5px;font-weight:700;">Ждёт человека 7 минут</strong></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--c-warn-border);">
|
||||
<span style="width:22px;height:22px;flex:none;border-radius:50%;background:#7d5ba6;color:#fff;font-size:9.5px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;">АК</span>
|
||||
<span style="flex:1;min-width:0;font-size:12.5px;color:var(--c-n2);">Назначен на Анну Ким, не взят</span>
|
||||
</div>
|
||||
<p style="margin:6px 0 0;font-size:11.5px;color:var(--c-warn-text);line-height:1.45;">Если не возьмёт — через 3 минуты диалог вернётся в общую очередь.</p>
|
||||
</div>
|
||||
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Ответственный</label>
|
||||
<button style="width:100%;display:flex;align-items:center;gap:8px;height:36px;padding:0 10px 0 8px;margin-bottom:12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);"><span style="width:22px;height:22px;border-radius:50%;background:#7d5ba6;color:#fff;font-size:9.5px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;flex:none;">АК</span><span style="flex:1;text-align:left;">Анна Ким</span><i style="width:7px;height:7px;border-radius:50%;background:#52c41a;flex:none;"></i><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Группа</label>
|
||||
<button style="width:100%;display:flex;align-items:center;gap:8px;height:36px;padding:0 10px;margin-bottom:12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);"><i style="width:8px;height:8px;border-radius:50%;background:#1677ff;flex:none;"></i><span style="flex:1;text-align:left;">Поддержка</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:14px;font-size:12px;color:var(--c-n4);"><span>В очереди с</span><span style="color:var(--c-n3);">14:12</span></div>
|
||||
</div>
|
||||
|
||||
<!-- лента: системные события -->
|
||||
<div style="width:620px;flex:none;background:var(--c-feed);border:1px solid var(--c-n8);border-radius:8px;padding:22px 24px;">
|
||||
<div style="font-size:11.5px;color:var(--c-n4);margin-bottom:16px;">Системные события в истории диалога — тот же вид, что у остальных; возврат в очередь окрашен как предупреждение.</div>
|
||||
<sc-for list="{{ sysEvents }}" as="m" hint-placeholder-count="4">
|
||||
<div style="width:fit-content;display:flex;align-items:center;gap:6px;margin:0 auto 14px;padding:4px 12px;border-radius:20px;background:{{ m.bg }};border:1px solid {{ m.border }};color:{{ m.color }};font-size:11.5px;font-weight:500;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="{{ m.icon }}"/></svg>{{ m.text }} · {{ m.time }}</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Q5 · ответственный: присутствие ================= -->
|
||||
<div id="q5" style="flex:none;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;">Q5</span>
|
||||
<strong style="font-size:13.5px;color:var(--c-n1);">Ответственный · кто сейчас на месте</strong>
|
||||
<span style="font-size:12px;color:var(--c-n4);">подсказка, а не запрет: выбрать отсутствующего можно</span>
|
||||
</div>
|
||||
|
||||
<div style="width:400px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:8px;padding:16px 18px 22px;">
|
||||
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Ответственный</label>
|
||||
<button style="width:100%;display:flex;align-items:center;gap:8px;height:36px;padding:0 10px 0 8px;border-radius:8px;border:1px solid var(--c-accent);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;"><span style="width:22px;height:22px;border-radius:50%;border:1px dashed var(--c-n6);display:inline-flex;flex:none;"></span><span style="flex:1;text-align:left;color:var(--c-n4);">Не назначен</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>
|
||||
|
||||
<div style="margin-top:6px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,0.12);padding:5px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:4px 4px 6px;padding:0 8px;height:32px;border-radius:7px;background:var(--c-n10);border:1px solid var(--c-n9);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg><span style="font-size:12.5px;color:var(--c-n5);">Поиск сотрудника</span></div>
|
||||
<sc-for list="{{ assignees }}" as="a" hint-placeholder-count="6">
|
||||
<sc-if value="{{ a.head }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;gap:7px;padding:8px 10px 4px;font-size:11px;font-weight:700;letter-spacing:0.04em;color:var(--c-n5);">{{ a.head }}<span style="flex:1;height:1px;background:var(--c-n9);"></span></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ a.person }}" hint-placeholder-val="{{ true }}">
|
||||
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:7px 10px;border:none;border-radius:7px;background:transparent;color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);">
|
||||
<span style="position:relative;flex:none;display:flex;"><span style="width:26px;height:26px;border-radius:50%;background:{{ a.avBg }};color:#fff;font-size:10px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;">{{ a.initials }}</span><i style="position:absolute;right:-1px;bottom:-1px;width:9px;height:9px;border:2px solid var(--c-surface);border-radius:50%;background:{{ a.dotBg }};box-shadow:{{ a.dotRing }};"></i></span>
|
||||
<span style="flex:1;min-width:0;line-height:1.25;"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ a.nameColor }};">{{ a.name }}</span><small style="display:block;font-size:11px;color:{{ a.subColor }};">{{ a.sub }}</small></span>
|
||||
<sc-if value="{{ a.load }}" hint-placeholder-val="{{ true }}"><small style="flex:none;font-size:11px;color:var(--c-n5);">{{ a.load }}</small></sc-if>
|
||||
</button>
|
||||
</sc-if>
|
||||
</sc-for>
|
||||
<div style="padding:9px 10px 5px;margin-top:4px;border-top:1px solid var(--c-n9);font-size:11.5px;color:var(--c-n4);line-height:1.45;">«На месте» — приложение открыто. Признак приблизительный: назначить можно любого.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"'light' | 'dark'","section":"Тема"},"accent":{"editor":"color","options":["#1677ff","#6d5dfc","#0f9b8e","#e8590c"],"default":"#1677ff","tsType":"string","section":"Тема"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const dark = (this.props.theme ?? "light") === "dark";
|
||||
const accent = this.props.accent ?? "#1677ff";
|
||||
const T = dark
|
||||
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", errText: "#f07178", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
|
||||
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
|
||||
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-accent-border:color-mix(in srgb,${accent} ${T.accentBgPct + 25}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-border:color-mix(in srgb,#52c41a ${T.tintPct + 20}%,${T.surface});--c-ok-text:${T.okText};--c-err-bg:color-mix(in srgb,#f5222d ${T.tintPct}%,${T.surface});--c-err-border:color-mix(in srgb,#f5222d ${T.tintPct + 25}%,${T.surface});--c-err-text:${T.errText};`;
|
||||
|
||||
const tg = "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z";
|
||||
const max = "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z";
|
||||
|
||||
const box = (on) => ({ on, bg: on ? "var(--c-accent)" : "var(--c-surface)", border: on ? "var(--c-accent)" : "var(--c-n6)" });
|
||||
// «Новый диалог» — отдельное событие, добавлено по решению владельца:
|
||||
// в диалоге, который ведёт агент, никто оператора не просил, и называть
|
||||
// такой оклик «клиент запросил оператора» было бы неправдой.
|
||||
const types = (flags) => [["Новый диалог", flags[0]], ["Клиент запросил оператора", flags[1]], ["Новое сообщение в моём диалоге", flags[2]], ["Диалог назначили на меня", flags[3]], ["Диалог долго ждёт человека", flags[4]], ["AI остановлен ошибкой или лимитом", flags[5]]].map(([label, on]) => ({ label, ...box(on) }));
|
||||
|
||||
const settingsNav = [
|
||||
["Организация"], ["Группы"], ["Когда звать на помощь", true, true], ["Агенты"], ["Интеграции"], ["Голосовые и звонки"], ["Хранилище файлов"], ["Платформа"], ["Демо-данные"],
|
||||
].map(([label, active, isNew]) => ({ label, isNew: !!isNew, bg: active ? "var(--c-accent-bg)" : "transparent", color: active ? "var(--c-accent-text)" : "var(--c-n2)", weight: active ? 600 : 500 }));
|
||||
|
||||
const delays = [
|
||||
{ title: "Напомнить группе, что диалог ждёт", hint: "Уведомление получат сотрудники группы, за которой закреплён диалог.", value: "5" },
|
||||
{ title: "Позвать всех, если диалог всё ещё ждёт", hint: "Уведомление уйдёт всем сотрудникам, а не только группе.", value: "15" },
|
||||
{ title: "Сообщить руководителю", hint: "Владельцу и администраторам — значит, обычными силами диалог не разобрали.", value: "30" },
|
||||
{ title: "Сколько ждём сотрудника, на которого назначен диалог", hint: "Не взял за это время — диалог возвращается в общую очередь и снова виден всем.", value: "10" },
|
||||
];
|
||||
|
||||
const tab = (label, count, active, warm) => ({ label, count, line: active ? "var(--c-accent)" : "transparent", color: active ? "var(--c-n1)" : "var(--c-n4)", weight: active ? 700 : 500, countBg: warm ? "var(--c-warn-bg)" : active ? "var(--c-accent)" : "var(--c-n9)", countColor: warm ? "var(--c-warn-text)" : active ? "#fff" : "var(--c-n3)" });
|
||||
|
||||
const rowA = (o) => ({ time: "14:19", unread: 0, groupDot: "#1677ff", dot: "var(--c-warn-text)", bg: "transparent", ...o });
|
||||
const queueRows = [
|
||||
rowA({ initials: "МВ", avatarBg: "#c2703f", agent: "Консультант", name: "Марина Вихрева", preview: "Нужен живой человек, бот не понимает", wait: "7 мин", group: "Поддержка", unread: 2 }),
|
||||
rowA({ initials: "ИП", avatarBg: "#3f7ec2", agent: "Поддержка сайта", name: "Игорь Петров", preview: "Когда приедет замерщик?", wait: "4 мин", group: "Поддержка" }),
|
||||
rowA({ initials: "СК", avatarBg: "#4a9d7f", agent: "Консультант", name: "Светлана Клюева", preview: "Здравствуйте, по заказу 41-208", wait: "1 мин", group: "" }),
|
||||
];
|
||||
const mineRows = [
|
||||
rowA({ initials: "ЛГ", avatarBg: "#8a6fbf", agent: "Консультант", name: "Лариса Гущина", preview: "Хочу уточнить сроки пошива", wait: "6 мин", mine: "на вас · вернётся через 4 мин", group: "Поддержка", unread: 1, bg: "var(--c-n10)" }),
|
||||
rowA({ initials: "ВД", avatarBg: "#b9553f", agent: "Поддержка сайта", name: "Вадим Дроздов", preview: "Не приходит счёт на почту", wait: "2 мин", mine: "на вас · вернётся через 8 мин", group: "" }),
|
||||
];
|
||||
|
||||
const listVariants = [
|
||||
{ title: "А · две вкладки", note: "рекомендуем", total: "128 диалогов",
|
||||
tabs: [tab("Все"), tab("Мои", 3), tab("Общая очередь", 3, true, true), tab("На мне", 2, false, true)],
|
||||
rows: queueRows, footer: "«Общая очередь» — диалоги без ответственного: взять может любой. «На мне» — назначены лично вам и ждут, пока вы их возьмёте." },
|
||||
{ title: "Б · одна вкладка, метки в строке", note: "меньше вкладок, но ожидания перемешаны", total: "128 диалогов",
|
||||
tabs: [tab("Все"), tab("Мои", 3), tab("Ждут оператора", 5, true, true)],
|
||||
rows: [mineRows[0], ...queueRows.slice(0, 2), mineRows[1]], footer: "Назначенные лично видны по синей метке «на вас». Сортировка — по времени ожидания, свои сверху." },
|
||||
];
|
||||
|
||||
const CLOCK = "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2";
|
||||
const PERSON = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z";
|
||||
const BACK = "M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3";
|
||||
const sys = (text, time, icon, warm) => ({ text, time, icon, bg: warm ? "var(--c-warn-bg)" : "var(--c-n9)", border: warm ? "var(--c-warn-border)" : "var(--c-n8)", color: warm ? "var(--c-warn-text)" : "var(--c-n4)" });
|
||||
const sysEvents = [
|
||||
sys("AI передал диалог человеку", "14:12", CLOCK),
|
||||
sys("Диалог назначен на Анну Ким", "14:14", PERSON),
|
||||
sys("Анна Ким не взяла диалог — он вернулся в очередь", "14:24", BACK, true),
|
||||
sys("Диалог взял Дмитрий Орлов", "14:26", PERSON),
|
||||
];
|
||||
|
||||
const person = (name, initials, avBg, here, sub, load) => ({
|
||||
person: true, name, initials, avBg, sub, load,
|
||||
dotBg: here ? "#52c41a" : "transparent",
|
||||
dotRing: here ? "none" : `inset 0 0 0 1.5px ${dark ? "#5b5d66" : "#bfbfbf"}`,
|
||||
nameColor: "var(--c-n1)", subColor: here ? "var(--c-ok-text)" : "var(--c-n4)",
|
||||
});
|
||||
const assignees = [
|
||||
{ head: "НА МЕСТЕ" },
|
||||
person("Анна Ким", "АК", "#7d5ba6", true, "на месте", "4 диалога"),
|
||||
person("Дмитрий Орлов", "ДО", "#3f7ec2", true, "на месте", "2 диалога"),
|
||||
person("Елена Кузнецова", "ЕК", "#b9553f", true, "на месте · владелец", ""),
|
||||
{ head: "СЕЙЧАС НЕТ" },
|
||||
person("Сергей Крылов", "СК", "#4a9d7f", false, "не в приложении · 25 мин", "1 диалог"),
|
||||
person("Ольга Тимина", "ОТ", "#c2703f", false, "не в приложении · 3 ч", ""),
|
||||
];
|
||||
|
||||
return { themeVars, tg, max, typesOn: types([false, true, true, true, false, false]), typesTg: types([false, true, true, true, true, false]), settingsNav, delays, listVariants, sysEvents, assignees };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
// Страница профиля: карточка уведомлений со строкой «Браузер» и устойчивость
|
||||
// страницы к неполному ответу соседней карточки.
|
||||
//
|
||||
// Разрешение браузера в тестах подменяется: выдать его сайт не может, а
|
||||
// состояние иначе зависело бы от того, что headless-Chromium ответит сегодня.
|
||||
|
||||
const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
const OWNER = {
|
||||
id: 1,
|
||||
email: "owner@example.com",
|
||||
fullName: "Елена Кузнецова",
|
||||
avatarUrl: null,
|
||||
mustChangePassword: false,
|
||||
totpEnabled: false,
|
||||
totpLastUsedAt: null,
|
||||
deliveryMode: "SELF_HOSTED",
|
||||
isInstanceAdmin: true,
|
||||
language: "ru",
|
||||
uiLanguage: "",
|
||||
uiTheme: "LIGHT",
|
||||
uiAccent: "#1677ff",
|
||||
memberships: [
|
||||
{
|
||||
id: 1,
|
||||
organizationPublicId: ORGANIZATION_PUBLIC_ID,
|
||||
organization: "atelier-nord",
|
||||
organizationName: "Ателье Норд",
|
||||
organizationLogoUrl: null,
|
||||
role: "OWNER",
|
||||
positionTitle: "Владелец",
|
||||
totpRequired: false,
|
||||
capabilities: ["company.view", "conversations.view", "employees.manage_privileged", "settings.view"],
|
||||
groups: [],
|
||||
joinedAt: "2026-02-02T10:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TYPES = [
|
||||
{ code: "DIALOG_WAITING", label: "Диалог ждёт оператора" },
|
||||
{ code: "DIALOG_NEW_MESSAGE", label: "Новое сообщение в диалоге" },
|
||||
];
|
||||
|
||||
// Сценарий дважды загружает SPA (вход формой, затем адрес профиля) — штатных
|
||||
// тридцати секунд на это впритык.
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "internal-ui", "internal-ui only");
|
||||
});
|
||||
|
||||
/** Разрешение браузера задаём сами: выдать его сайт не может, а тест обязан
|
||||
* быть воспроизводимым. */
|
||||
async function pretendPermission(page: Page, value: "default" | "granted" | "denied") {
|
||||
await page.addInitScript((permission) => {
|
||||
class FakeNotification {
|
||||
static permission = permission;
|
||||
static requestPermission = async () => permission;
|
||||
onclick: (() => void) | null = null;
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, "Notification", { value: FakeNotification, configurable: true });
|
||||
}, value);
|
||||
}
|
||||
|
||||
async function openProfile(
|
||||
page: Page,
|
||||
preference: { enabled: boolean; types: string[] },
|
||||
sessions: object = { items: [] },
|
||||
) {
|
||||
// Порядок важен: в Playwright выигрывает маршрут, зарегистрированный
|
||||
// последним, поэтому общие заглушки идут первыми, частные — за ними.
|
||||
await page.route("**/api/v1/**", (route) => route.fulfill({ json: {} }));
|
||||
await page.route("**/api/v1/organizations/*/conversations/**", (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/conversations/counters/")) {
|
||||
return route.fulfill({
|
||||
json: { all: 0, waiting: 0, mine: 0, ungrouped: 0, groups: [], agents: [], assignees: [] },
|
||||
});
|
||||
}
|
||||
if (path.endsWith("/conversations/directory/")) return route.fulfill({ json: { groups: [], employees: [] } });
|
||||
if (path.endsWith("/conversations/stats/")) return route.fulfill({ json: { waiting: 0 } });
|
||||
return route.fulfill({ json: { items: [] } });
|
||||
});
|
||||
await page.route("**/api/v1/organizations/*/company/**", (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/company/onboarding/")) {
|
||||
return route.fulfill({
|
||||
json: { steps: {}, dismissedAt: "2026-01-01T00:00:00Z", completedAt: "2026-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: { items: [] } });
|
||||
});
|
||||
await page.route("**/api/v1/organizations/*/notifications/**", (route) =>
|
||||
route.fulfill({ json: { items: [], unreadCount: 0 } }),
|
||||
);
|
||||
await page.route("**/api/v1/organizations/*/notifications/messenger-bindings/", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
integrationId: 1,
|
||||
provider: "TELEGRAM",
|
||||
name: "notify",
|
||||
botUsername: "chatballs_notify_bot",
|
||||
bound: true,
|
||||
pushTypes: ["DIALOG_WAITING"],
|
||||
},
|
||||
],
|
||||
availableTypes: TYPES,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/organizations/*/notifications/preferences/BROWSER/", (route) =>
|
||||
route.fulfill({ json: { transport: "BROWSER", ...preference, availableTypes: TYPES } }),
|
||||
);
|
||||
// Соседние карточки профиля тоже ходят за данными: без их ответов страница
|
||||
// падает целиком и проверять было бы нечего.
|
||||
await page.route("**/api/v1/auth/profile/sessions/", (route) => route.fulfill({ json: sessions }));
|
||||
await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } }));
|
||||
|
||||
// Вход формой: после него сессия обязана быть живой, иначе переход на адрес
|
||||
// профиля выкинет обратно на логин.
|
||||
let signedIn = false;
|
||||
await page.route("**/api/v1/auth/session/", (route) =>
|
||||
route.fulfill({
|
||||
json: signedIn ? { authenticated: true, user: OWNER } : { authenticated: false, language: "ru" },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/login/", (route) => {
|
||||
signedIn = true;
|
||||
return route.fulfill({ json: { authenticated: true, user: OWNER } });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByPlaceholder("you@domain.ru").fill("owner@example.com");
|
||||
await page.getByPlaceholder("Пароль").fill("Password-123");
|
||||
await page.getByRole("button", { name: "Войти" }).click();
|
||||
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/profile`);
|
||||
|
||||
const card = page.locator(".profile-card", { hasText: "Уведомления о рабочих событиях" });
|
||||
return { card, browserRow: card.locator(".profile-notifications-block").first() };
|
||||
}
|
||||
|
||||
test("разрешение не запрошено — строку браузера можно включить", async ({ page }) => {
|
||||
await pretendPermission(page, "default");
|
||||
const { card, browserRow } = await openProfile(page, { enabled: false, types: [] });
|
||||
|
||||
await expect(browserRow).toContainText("Этот браузер");
|
||||
await expect(browserRow).toContainText("Браузер спросит разрешение по нажатию");
|
||||
await expect(browserRow.getByRole("button", { name: "Включить" })).toBeVisible();
|
||||
// Галочки типов — только у включённого транспорта.
|
||||
await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(0);
|
||||
// Строка бота осталась рядом и того же вида: шаблон у них один.
|
||||
await expect(card).toContainText("Telegram");
|
||||
});
|
||||
|
||||
test("разрешение выдано и включено — видны те же галочки типов", async ({ page }) => {
|
||||
await pretendPermission(page, "granted");
|
||||
const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] });
|
||||
|
||||
await expect(browserRow).toContainText("Включены");
|
||||
await expect(browserRow.getByRole("button", { name: "Отключить" })).toBeVisible();
|
||||
await expect(browserRow.locator("input[type=checkbox]")).toHaveCount(TYPES.length);
|
||||
// Под включённой строкой — примечание, что «Отключить» гасит показ, а не разрешение.
|
||||
await expect(browserRow).toContainText("Само разрешение остаётся выданным");
|
||||
});
|
||||
|
||||
test("браузер отказал — вместо кнопки подсказка, как это снять", async ({ page }) => {
|
||||
await pretendPermission(page, "denied");
|
||||
const { browserRow } = await openProfile(page, { enabled: true, types: ["DIALOG_WAITING"] });
|
||||
|
||||
await expect(browserRow).toContainText("Браузер запретил");
|
||||
// Кнопки «Включить» нет — она ничего не сделала бы; есть «Проверить снова».
|
||||
await expect(browserRow.getByRole("button", { name: "Включить" })).toHaveCount(0);
|
||||
await expect(browserRow).toContainText("Снять запрет можно только в браузере");
|
||||
await expect(browserRow.getByRole("button", { name: "Проверить снова" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("неполный ответ одной карточки не гасит всю страницу профиля", async ({ page }) => {
|
||||
// Регрессия: список сессий приходил без items, карточка падала на
|
||||
// items.length, и вместе с ней исчезала вся страница — пароль, двухфакторка
|
||||
// и уведомления в том числе.
|
||||
await pretendPermission(page, "default");
|
||||
const { card } = await openProfile(page, { enabled: false, types: [] }, {});
|
||||
|
||||
await expect(card).toBeVisible();
|
||||
await expect(page.locator(".profile-card")).toHaveCount(7);
|
||||
await expect(page.locator(".sessions-card")).toContainText("Активные сессии");
|
||||
});
|
||||
Reference in new issue
Block a user