mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbc062b1e | ||
|
|
ee8367e205 | ||
|
|
839a1fe815 | ||
|
|
c62cb903ab | ||
|
|
0bd76042d2 | ||
|
|
c286ba7903 | ||
|
|
234a3e43da | ||
|
|
17b0c9387f | ||
|
|
27091c0234 | ||
|
|
3c01486aa3 | ||
|
|
15ff9bf2c3 | ||
|
|
5d8362e8f6 | ||
|
|
eeed0ed51a | ||
|
|
42a468ec97 | ||
|
|
44718142b3 | ||
|
|
4cba376c0e | ||
|
|
a4c6b960a8 | ||
|
|
0098e147ac |
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.
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
import surfaces
|
||||
}
|
||||
|
||||
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
|
||||
import /config/sites/*.caddy
|
||||
|
||||
# Любой хост, которому ask-эндпоинт разрешил сертификат.
|
||||
https:// {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
|
||||
|
||||
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key and set your own budget.
|
||||
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,7 +101,7 @@ Everything else is done in **Settings**.
|
||||
|---|---|
|
||||
| **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. |
|
||||
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. |
|
||||
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model and a daily budget. Attach articles from the knowledge base. |
|
||||
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. |
|
||||
| **Employees** | Invite your team by email, assign roles and groups. |
|
||||
|
||||
The home screen shows a launch checklist: create an agent, connect an entry point, invite employees.
|
||||
@@ -192,7 +192,7 @@ Waiting conversations and new messages reach the employee in Telegram or MAX. Li
|
||||
|
||||
### Your own server and your own AI model
|
||||
|
||||
Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model. A daily budget per agent in dollars, token and cost accounting for every call.
|
||||
Installs with one command, data stays with you. Connect any AI model provider with your own key: OpenRouter, an OpenAI-compatible service, a local model.
|
||||
|
||||
### Customer data protection
|
||||
|
||||
@@ -244,8 +244,7 @@ Check in order:
|
||||
1. The agent status is **Active**, not **Draft**.
|
||||
2. The agent has an AI model provider selected. Without it no answer is possible.
|
||||
3. The provider in **Integrations** has the **Connected** status. Run the check to refresh it.
|
||||
4. The agent's daily budget is not exhausted. Blocked calls are visible in the AI usage log.
|
||||
5. The conversation is not switched to **Operator** or **Paused** mode.
|
||||
4. The conversation is not switched to **Operator** or **Paused** mode.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
+4
-5
@@ -45,7 +45,7 @@
|
||||
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу и сами задаёте бюджет.
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,7 +101,7 @@ docker compose up -d --wait
|
||||
|---|---|
|
||||
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель и дневной бюджет. Прикрепите статьи из базы знаний. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
|
||||
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
|
||||
|
||||
На главном экране есть чек-лист запуска: создать агента, подключить точку входа, пригласить сотрудников.
|
||||
@@ -192,7 +192,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Свой сервер и своя ИИ-модель
|
||||
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель. Дневной бюджет на агента в долларах, учёт токенов и стоимости по каждому вызову.
|
||||
Ставится одной командой, данные остаются у вас. Подключаете любого провайдера ИИ-моделей по своему ключу: OpenRouter, OpenAI-совместимый сервис, локальная модель.
|
||||
|
||||
### Защита данных клиентов
|
||||
|
||||
@@ -244,8 +244,7 @@ docker compose logs gateway
|
||||
1. Агент в статусе **Активен**, а не **Черновик**.
|
||||
2. У агента выбран провайдер ИИ-моделей. Без него ответ невозможен.
|
||||
3. Провайдер в **Интеграциях** имеет статус **Подключено**. Нажмите проверку, чтобы обновить статус.
|
||||
4. Не исчерпан дневной бюджет агента. Заблокированные вызовы видны в учёте ИИ.
|
||||
5. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
4. Диалог не переведён в режим **Оператор** или **Пауза**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
|
||||
for raw_name in raw_path:
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
|
||||
try:
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
|
||||
category, _ = KnowledgeCategory.objects.get_or_create(
|
||||
organization_id=context.organization_id,
|
||||
parent_id=parent_id,
|
||||
name=raw_name.strip(),
|
||||
)
|
||||
parent_id = category.id
|
||||
assert category is not None
|
||||
return category
|
||||
|
||||
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
knowledge = Knowledge.objects.get(title="Acme support")
|
||||
self.assertEqual(knowledge.category_id, self.app.id)
|
||||
|
||||
def test_unknown_category_path_fails_per_document(self) -> None:
|
||||
def test_missing_category_is_created_under_existing_parent(self) -> None:
|
||||
response = self._import(
|
||||
[
|
||||
{
|
||||
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["created"], 1)
|
||||
self.assertEqual(len(payload["failed"]), 1)
|
||||
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
|
||||
self.assertFalse(
|
||||
KnowledgeCategory.objects.filter(
|
||||
organization=self.organization,
|
||||
name="Missing",
|
||||
).exists()
|
||||
self.assertEqual(payload["created"], 2)
|
||||
self.assertEqual(payload["failed"], [])
|
||||
category = KnowledgeCategory.objects.get(
|
||||
organization=self.organization, parent=self.products, name="Missing"
|
||||
)
|
||||
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
|
||||
|
||||
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
|
||||
documents = [
|
||||
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
|
||||
for title in ["First", "Second"]
|
||||
]
|
||||
before = KnowledgeCategory.objects.count()
|
||||
self.assertEqual(self._import(documents).json()["created"], 2)
|
||||
self.assertEqual(self._import(documents).json()["unchanged"], 2)
|
||||
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
|
||||
first = Knowledge.objects.get(title="First")
|
||||
second = Knowledge.objects.get(title="Second")
|
||||
self.assertEqual(first.category_id, second.category_id)
|
||||
self.assertEqual(first.category.parent.parent.name, "New root")
|
||||
|
||||
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
|
||||
response = self._import([
|
||||
{"title": "Invalid", "content": "Text", "description": [],
|
||||
"categoryPath": ["Rollback root", "Child"]},
|
||||
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
|
||||
])
|
||||
self.assertEqual(response.json()["created"], 1)
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
|
||||
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
|
||||
|
||||
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
|
||||
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
|
||||
with self.subTest(path=path):
|
||||
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
|
||||
self.assertEqual(len(response.json()["failed"]), 1)
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
|
||||
|
||||
def test_existing_document_moves_to_new_category(self) -> None:
|
||||
self._import([{"title": "Moving", "content": "Text"}])
|
||||
response = self._import([
|
||||
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
|
||||
])
|
||||
self.assertEqual(response.json()["updated"], 1)
|
||||
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
|
||||
|
||||
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
|
||||
self._import(
|
||||
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
{
|
||||
"title": self.support_only.title,
|
||||
"content": "Attempted overwrite",
|
||||
"categoryPath": ["Forbidden category"],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.support_only.refresh_from_db()
|
||||
self.assertEqual(self.support_only.content, "")
|
||||
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
|
||||
@@ -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",
|
||||
@@ -345,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",
|
||||
@@ -477,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",
|
||||
"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": "Некорректная категория",
|
||||
@@ -349,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": "Текущий пароль неверен",
|
||||
@@ -481,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)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -35,8 +36,60 @@ def _installation_hosts() -> tuple[str, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
def _is_ip_literal(value: str) -> bool:
|
||||
"""IPv4 проходит проверку домена по буквам меток, а доменом не является."""
|
||||
|
||||
try:
|
||||
IPv4Address(value)
|
||||
except ValueError:
|
||||
return ":" in value
|
||||
return True
|
||||
|
||||
|
||||
def base_domain_for(installation_host: str) -> str:
|
||||
"""Базовый домен при таком адресе установки; пусто — домена ещё нет.
|
||||
|
||||
Отдельно от ``help_base_domain`` ради миграции, которая читает адрес через
|
||||
историческую модель настроек, а не через рантайм-модель.
|
||||
"""
|
||||
|
||||
configured = normalize_domain(
|
||||
str(getattr(settings, "CHATBALLS_HELP_BASE_DOMAIN", "") or "")
|
||||
)
|
||||
if configured:
|
||||
return configured
|
||||
host = normalize_domain(installation_host)
|
||||
if not host or _is_ip_literal(host):
|
||||
return ""
|
||||
try:
|
||||
return validate_domain(host)
|
||||
except ValidationError:
|
||||
return ""
|
||||
|
||||
|
||||
def help_base_domain() -> str:
|
||||
"""Базовый домен порталов помощи; пусто — установка ещё не знает своего домена.
|
||||
|
||||
Штатный источник — адрес самой установки: у коробки нет .env, и переменную
|
||||
окружения задать негде, а её прежнее значение по умолчанию (``localhost``)
|
||||
превращало адрес портала на живом домене в ``help.localhost``. Переменная
|
||||
остаётся переопределением для контуров, которые ведут конфигурацию сами
|
||||
(dev-стек, staging) — там же, где она задаётся у IPv4 (``public_address``).
|
||||
"""
|
||||
|
||||
from chatballs.identity.instance_settings import public_host
|
||||
|
||||
try:
|
||||
host = public_host()
|
||||
except Exception: # таблицы ещё нет (ранние миграции)
|
||||
host = ""
|
||||
return base_domain_for(host)
|
||||
|
||||
|
||||
def hosted_domain(portal_key: str) -> str:
|
||||
base_domain = normalize_domain(settings.CHATBALLS_HELP_BASE_DOMAIN)
|
||||
base_domain = help_base_domain()
|
||||
if not base_domain:
|
||||
raise ValidationError(t("portals.installation_domain_missing"))
|
||||
return validate_domain(f"{portal_key}.{base_domain}")
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ from django.db.models import Q
|
||||
|
||||
def fill_hosted_domains(apps, schema_editor):
|
||||
SupportPortal = apps.get_model("support_portals", "SupportPortal")
|
||||
base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".")
|
||||
# Историческая засыпка: до этой миграции базовый домен приходил только из
|
||||
# переменной окружения, и её умолчанием был localhost. Оно и остаётся здесь,
|
||||
# чтобы бэкфилл старых строк не зависел от того, знает ли установка свой
|
||||
# домен сейчас; актуальные адреса считает addressing.help_base_domain.
|
||||
base_domain = settings.CHATBALLS_HELP_BASE_DOMAIN.strip().lower().rstrip(".") or "localhost"
|
||||
for portal in SupportPortal.objects.all().only("id", "slug"):
|
||||
SupportPortal.objects.filter(id=portal.id).update(
|
||||
hosted_domain=f"{portal.slug}.{base_domain}"
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Порталы, оставшиеся на `.localhost`, переезжают на домен установки.
|
||||
|
||||
До появления `addressing.help_base_domain` базовый домен приходил только из
|
||||
переменной окружения, а задать её в коробке негде: у продукта нет .env. Поэтому
|
||||
на реальной установке адрес портала складывался как `help.localhost` — снаружи
|
||||
он не вёл никуда и сертификата не получал. Теперь домен известен из мастера
|
||||
первого запуска, и такие адреса пора привести к нему.
|
||||
|
||||
Переписываются только суффиксы `.localhost`: ссылки, которые всё равно не
|
||||
работали. Всё остальное — в том числе dev-стек и staging с явной переменной —
|
||||
остаётся как есть: там смена адреса ломала бы живые ссылки на статьи.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def move_hosted_domains_to_installation(apps, schema_editor):
|
||||
from chatballs.support_portals.addressing import base_domain_for
|
||||
|
||||
InstanceSettings = apps.get_model("identity", "InstanceSettings")
|
||||
host = (
|
||||
InstanceSettings.objects.filter(pk=1)
|
||||
.values_list("public_host", flat=True)
|
||||
.first()
|
||||
or ""
|
||||
)
|
||||
base_domain = base_domain_for(host)
|
||||
if not base_domain or base_domain == "localhost":
|
||||
return
|
||||
|
||||
SupportPortal = apps.get_model("support_portals", "SupportPortal")
|
||||
portals = SupportPortal.objects.filter(hosted_domain__endswith=".localhost")
|
||||
for portal in portals.only("id", "slug", "hosted_domain"):
|
||||
moved = f"{portal.slug}.{base_domain}"
|
||||
taken = SupportPortal.objects.exclude(id=portal.id).filter(hosted_domain=moved)
|
||||
if taken.exists():
|
||||
continue
|
||||
SupportPortal.objects.filter(id=portal.id).update(hosted_domain=moved)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("support_portals", "0010_article_files"),
|
||||
# Домен установки живёт в настройках инстанса: без этой таблицы его
|
||||
# неоткуда прочитать.
|
||||
("identity", "0033_instance_previous_public_host"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
move_hosted_domains_to_installation,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,7 @@ from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from chatballs.support_portals.addressing import help_base_domain
|
||||
from chatballs.support_portals.api import validation_response
|
||||
from chatballs.support_portals.domain_services import (
|
||||
set_custom_domain,
|
||||
@@ -147,7 +148,7 @@ class PortalListView(PortalBaseView):
|
||||
},
|
||||
"address": {
|
||||
"scheme": settings.CHATBALLS_HELP_PUBLIC_SCHEME,
|
||||
"baseDomain": settings.CHATBALLS_HELP_BASE_DOMAIN,
|
||||
"baseDomain": help_base_domain(),
|
||||
"port": settings.CHATBALLS_HELP_PUBLIC_PORT or None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Базовый домен порталов берётся из адреса установки.
|
||||
|
||||
Переменную окружения в коробке задавать негде (у продукта нет .env), а её
|
||||
прежнее умолчание — localhost — оставляло портал на help.localhost даже там,
|
||||
где установка работает на живом домене: в диалоге создания владелец видел
|
||||
суффикс, которого у него нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps as django_apps
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.identity.instance_settings import InstanceSettings, invalidate_cache
|
||||
from chatballs.support_portals.addressing import help_base_domain, hosted_domain
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.support_portals.tests.base import SupportPortalTestCase
|
||||
|
||||
|
||||
class HelpBaseDomainTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_installation_domain_becomes_base_domain(self) -> None:
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
self.assertEqual(help_base_domain(), "crm.example.test")
|
||||
self.assertEqual(hosted_domain("help"), "help.crm.example.test")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="help.example.test")
|
||||
def test_environment_variable_overrides_installation_address(self) -> None:
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
self.assertEqual(help_base_domain(), "help.example.test")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_installation_known_only_by_ip_has_no_base_domain(self) -> None:
|
||||
self._set_host("203.0.113.10")
|
||||
|
||||
self.assertEqual(help_base_domain(), "")
|
||||
with self.assertRaises(ValidationError):
|
||||
hosted_domain("help")
|
||||
|
||||
|
||||
class PortalCreationWithoutDomainTests(SupportPortalTestCase):
|
||||
"""Пока установка известна только по IP, размещать портал негде."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "203.0.113.10"
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_list_reports_empty_base_domain(self) -> None:
|
||||
response = self.client.get("/api/v1/support/portals/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["address"]["baseDomain"], "")
|
||||
|
||||
@override_settings(CHATBALLS_HELP_BASE_DOMAIN="")
|
||||
def test_creation_is_rejected(self) -> None:
|
||||
response = self.create_portal()
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class HostedDomainMigrationTests(SupportPortalTestCase):
|
||||
"""Порталы, созданные до появления домена, переезжают на него миграцией."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
self.migration = import_module(
|
||||
"chatballs.support_portals.migrations"
|
||||
".0011_hosted_domains_follow_installation"
|
||||
)
|
||||
|
||||
def _set_host(self, host: str) -> None:
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = host
|
||||
row.save(update_fields=["public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
|
||||
def test_localhost_address_moves_to_the_installation_domain(self) -> None:
|
||||
self.assertEqual(self.create_portal().status_code, 201)
|
||||
portal = SupportPortal.objects.get(slug="app-help")
|
||||
self.assertEqual(portal.hosted_domain, "app-help.localhost")
|
||||
self._set_host("crm.example.test")
|
||||
|
||||
with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""):
|
||||
self.migration.move_hosted_domains_to_installation(django_apps, None)
|
||||
|
||||
portal.refresh_from_db()
|
||||
self.assertEqual(portal.hosted_domain, "app-help.crm.example.test")
|
||||
|
||||
def test_installation_without_domain_leaves_addresses_alone(self) -> None:
|
||||
self.assertEqual(self.create_portal().status_code, 201)
|
||||
self._set_host("203.0.113.10")
|
||||
|
||||
with override_settings(CHATBALLS_HELP_BASE_DOMAIN=""):
|
||||
self.migration.move_hosted_domains_to_installation(django_apps, None)
|
||||
|
||||
portal = SupportPortal.objects.get(slug="app-help")
|
||||
self.assertEqual(portal.hosted_domain, "app-help.localhost")
|
||||
@@ -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)]
|
||||
@@ -15,9 +15,14 @@ CHATBALLS_APP_PRIMARY_HOSTS = env_list(
|
||||
"CHATBALLS_APP_ALLOWED_HOSTS",
|
||||
env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
|
||||
)
|
||||
_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
|
||||
# Wildcard базового домена — только когда его задали переменной окружения. При
|
||||
# штатном источнике (адрес установки) домен известен лишь в рантайме, а хосты
|
||||
# порталов и без списка проходят: их проверяет каталог порталов в
|
||||
# SupportPortalHostBoundaryMiddleware.
|
||||
if CHATBALLS_HELP_BASE_DOMAIN:
|
||||
_help_host_pattern = f".{CHATBALLS_HELP_BASE_DOMAIN}"
|
||||
if _help_host_pattern not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append(_help_host_pattern)
|
||||
if TESTING and "testserver" not in CHATBALLS_APP_PRIMARY_HOSTS:
|
||||
CHATBALLS_APP_PRIMARY_HOSTS.append("testserver")
|
||||
# Custom portal domains are checked against the published ingress directory by
|
||||
|
||||
@@ -261,12 +261,18 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
|
||||
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах.
|
||||
# Базовый домен порталов помощи задаёт установщик (CHATBALLS_HELP_BASE_DOMAIN);
|
||||
# по умолчанию — localhost, никаких зашитых доменов.
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
|
||||
# берётся из адреса самой установки (support_portals.addressing.help_base_domain):
|
||||
# у коробки нет .env, задать переменную окружения негде, и её прежнее значение
|
||||
# по умолчанию — localhost — оставляло порталы на help.localhost даже на живом
|
||||
# домене. Пустое значение здесь — не «localhost», а «источник штатный»;
|
||||
# переменная остаётся переопределением для контуров с собственной конфигурацией.
|
||||
# В тестах адреса установки нет (мастер не проходили), а порталы нужны почти
|
||||
# каждому набору: там суффикс остаётся localhost, как в dev-стеке. Сам штатный
|
||||
# путь проверяется отдельно (support_portals/tests/test_base_domain.py).
|
||||
CHATBALLS_HELP_BASE_DOMAIN = os.environ.get(
|
||||
"CHATBALLS_HELP_BASE_DOMAIN",
|
||||
"localhost",
|
||||
"localhost" if TESTING else "",
|
||||
).strip().lower().rstrip(".")
|
||||
CHATBALLS_HELP_PUBLIC_SCHEME = os.environ.get("CHATBALLS_HELP_PUBLIC_SCHEME", "https").strip().lower()
|
||||
CHATBALLS_HELP_PUBLIC_PORT = os.environ.get("CHATBALLS_HELP_PUBLIC_PORT", "").strip()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -16,79 +16,8 @@ import {
|
||||
} from "./model";
|
||||
import { t, tn } from "../../../i18n";
|
||||
import { readableSize } from "../../../shared/utils";
|
||||
|
||||
// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения —
|
||||
// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт
|
||||
// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно.
|
||||
|
||||
type ImportAction = "create" | "update" | "error";
|
||||
|
||||
type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return { ok: false, missing: name };
|
||||
parentId = found.id;
|
||||
}
|
||||
return { ok: true, missing: "" };
|
||||
}
|
||||
|
||||
function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
if (document.categoryPath) {
|
||||
const { ok, missing } = resolvePath(categories, document.categoryPath);
|
||||
if (!ok) {
|
||||
return {
|
||||
document,
|
||||
action: "error",
|
||||
path: document.categoryPath.join(" / "),
|
||||
note: t("ai.missing_category_path", { name: missing }),
|
||||
};
|
||||
}
|
||||
}
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? "" : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = document.categoryPath
|
||||
&& document.categoryPath[document.categoryPath.length - 1] === existing.category.name;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced"),
|
||||
};
|
||||
});
|
||||
}
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { KnowledgeImportPreview } from "./KnowledgeImportPreview";
|
||||
|
||||
export function KnowledgeImportPage({
|
||||
canManage,
|
||||
@@ -218,44 +147,11 @@ export function KnowledgeImportPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{parsed && <KnowledgeImportPreview rows={rows} counts={counts} />}
|
||||
|
||||
{parsed && (
|
||||
<div className="knowledge-import-actions">
|
||||
<span>{t("ai.import_never_creates_categories_implicitly")}<code>categoryPath</code>{" "}
|
||||
{t("ai.import_path_tail")}
|
||||
</span>
|
||||
<span>{t("ai.import_category_policy")}</span>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setRoute("knowledge")}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={busy || !canManage || importable.length === 0} onClick={() => void submit()}>
|
||||
{t("ai.import_documents", { count: tn("plural.documents", importable.length) })}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "../../../i18n";
|
||||
import { ACTION_LABEL, type ImportAction, type ImportRow } from "./knowledgeImportPlan";
|
||||
|
||||
export function KnowledgeImportPreview({ rows, counts }: {
|
||||
rows: ImportRow[];
|
||||
counts: Record<ImportAction, number>;
|
||||
}) {
|
||||
return (
|
||||
<div className="knowledge-import-plan">
|
||||
<div className="knowledge-import-plan-head">
|
||||
<strong>{t("ai.what_will_happen")}</strong>
|
||||
<span>
|
||||
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
|
||||
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
|
||||
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="knowledge-import-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ai.document")}</th>
|
||||
<th>CATEGORYPATH</th>
|
||||
<th>{t("ai.action")}</th>
|
||||
<th>{t("ai.note")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
|
||||
<td><code>{row.document.title}</code></td>
|
||||
<td className="knowledge-import-path">{row.path}</td>
|
||||
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
|
||||
<td className="knowledge-import-note">{row.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "../../../i18n";
|
||||
import { planRows } from "./knowledgeImportPlan";
|
||||
import { parseKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./types";
|
||||
|
||||
const categories: KnowledgeCategory[] = [
|
||||
{ id: 1, name: "Root", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 2, name: "Child", parentId: 1, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 3, name: "Other", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
{ id: 4, name: "Child", parentId: 3, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
|
||||
];
|
||||
const existing: KnowledgeItem = {
|
||||
id: 10, title: "Existing", description: "", category: categories[1],
|
||||
isEnabled: true, attachments: [], agentsCount: 0, fragmentsCount: 0,
|
||||
createdAt: "", updatedAt: "",
|
||||
};
|
||||
|
||||
describe("knowledge import preview", () => {
|
||||
it("allows an entirely new category tree from YAML", () => {
|
||||
const parsed = parseKnowledgeYaml("documents:\n - title: New\n content: Text\n categoryPath: [New root, New child]");
|
||||
const [row] = planRows(parsed, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.path).toBe("New root / New child");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("allows missing descendants and updates existing documents", () => {
|
||||
const [row] = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "New child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(row.action).toBe("update");
|
||||
expect(row.note).toBe(t("ai.import_category_creation"));
|
||||
});
|
||||
|
||||
it("compares the full path instead of only the leaf name", () => {
|
||||
const rows = planRows({ documents: [
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Other", "Child"] },
|
||||
{ title: existing.title, content: "Text", categoryPath: ["Root", "Child"] },
|
||||
] }, categories, [existing]);
|
||||
expect(rows[0].note).toBe(t("ai.title_matched_text_will_replaced"));
|
||||
expect(rows[1].note).toBe(t("ai.category_file_matches_current_one"));
|
||||
});
|
||||
|
||||
it("keeps documents without a path importable", () => {
|
||||
const [row] = planRows({ documents: [{ title: "New", content: "Text" }] }, [], []);
|
||||
expect(row.action).toBe("create");
|
||||
expect(row.note).toBe(t("ai.will_land_no_category"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from "../../../i18n";
|
||||
import type { KnowledgeCategory, KnowledgeItem } from "./model";
|
||||
import type { ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
|
||||
|
||||
export type ImportAction = "create" | "update" | "error";
|
||||
|
||||
export type ImportRow = {
|
||||
document: ParsedKnowledgeYaml["documents"][number];
|
||||
action: ImportAction;
|
||||
path: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export const ACTION_LABEL: Record<ImportAction, string> = {
|
||||
create: t("ai.create"),
|
||||
update: t("ai.update"),
|
||||
error: t("ai.error"),
|
||||
};
|
||||
|
||||
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
|
||||
function resolvePath(categories: KnowledgeCategory[], path: string[]): number | null {
|
||||
let parentId: number | null = null;
|
||||
for (const name of path) {
|
||||
const found: KnowledgeCategory | undefined = categories.find(
|
||||
(category) => category.parentId === parentId && category.name === name,
|
||||
);
|
||||
if (!found) return null;
|
||||
parentId = found.id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
export function planRows(
|
||||
parsed: ParsedKnowledgeYaml,
|
||||
categories: KnowledgeCategory[],
|
||||
items: KnowledgeItem[],
|
||||
): ImportRow[] {
|
||||
const byTitle = new Map(items.map((item) => [item.title, item]));
|
||||
return parsed.documents.map((document) => {
|
||||
const existing = byTitle.get(document.title) ?? null;
|
||||
const categoryId = document.categoryPath ? resolvePath(categories, document.categoryPath) : null;
|
||||
const categoryNote = document.categoryPath && categoryId === null ? t("ai.import_category_creation") : "";
|
||||
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
|
||||
if (!existing) {
|
||||
return {
|
||||
document,
|
||||
action: "create",
|
||||
path,
|
||||
note: document.categoryPath ? categoryNote : t("ai.will_land_no_category"),
|
||||
};
|
||||
}
|
||||
const samePlace = categoryId !== null && categoryId === existing.category.id;
|
||||
return {
|
||||
document,
|
||||
action: "update",
|
||||
path,
|
||||
note: categoryNote || (samePlace
|
||||
? t("ai.category_file_matches_current_one")
|
||||
: t("ai.title_matched_text_will_replaced")),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,7 @@ export type ParsedKnowledgeYaml = {
|
||||
// Парсит YAML импорта знаний (SPEC-CHATBALLS-0012, плоский формат ADR-CHATBALLS-0023):
|
||||
// { documents: [{ title, description?, content, categoryPath? }] }. Бросает
|
||||
// Error с человекочитаемым сообщением при невалидном YAML или структуре.
|
||||
// Путь категории разбирается, но не проверяется: несуществующий путь — это
|
||||
// замечание в предпросмотре импорта (кадр KB8), а не поломка файла.
|
||||
// Недостающие уровни пути сервер создаёт при применении импорта.
|
||||
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
|
||||
let data: unknown;
|
||||
try {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -2,24 +2,23 @@ import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { DialogLabels } from "./DialogLabels";
|
||||
import { NoteSection } from "./DialogNoteSection";
|
||||
import { assigneeMenuItems, SmallAvatar } from "./assigneeOptions";
|
||||
import { WaitingBlock } from "./WaitingBlock";
|
||||
import { PriorityBars } from "./DialogList";
|
||||
import { useEmployeeDirectory } from "./useEmployeeDirectory";
|
||||
import { statusFor } from "./data";
|
||||
import {
|
||||
agentColorOf,
|
||||
controlModeOf,
|
||||
createConversationLabel,
|
||||
fetchConversationLabels,
|
||||
groupColorOf,
|
||||
setConversationArchived,
|
||||
setConversationAssignee,
|
||||
setConversationGroup,
|
||||
setConversationLabels,
|
||||
setConversationNote,
|
||||
setConversationPriority,
|
||||
type ApiConversation,
|
||||
type ConversationLabelRef,
|
||||
type ConversationPriority,
|
||||
} from "./model";
|
||||
import type { EmployeeGroupRef } from "../../types";
|
||||
@@ -42,38 +41,29 @@ 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);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setNewLabel("");
|
||||
setErrorText("");
|
||||
}, [detail.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<ApiConversation>) {
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
@@ -86,31 +76,8 @@ export function DialogControls({
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 +93,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 +101,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>
|
||||
@@ -220,59 +171,12 @@ export function DialogControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
<DialogLabels detail={detail} busy={busy} setBusy={setBusy} setErrorText={setErrorText} applyConversation={applyConversation} run={run} />
|
||||
|
||||
<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>
|
||||
@@ -282,36 +186,6 @@ export function DialogControls({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// «сегодня, 17:02» · «вчера, 11:05» · «12 авг, 09:30»
|
||||
export function startedLabel(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -340,8 +214,3 @@ 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>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Dropdown } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { createConversationLabel, fetchConversationLabels, setConversationLabels,
|
||||
type ApiConversation, type ConversationLabelRef } from "./model";
|
||||
import "./dialogLabels.css";
|
||||
|
||||
export function DialogLabels({ detail, busy, setBusy, setErrorText, applyConversation, run }: {
|
||||
detail: ApiConversation;
|
||||
busy: boolean;
|
||||
setBusy: (busy: boolean) => void;
|
||||
setErrorText: (error: string) => void;
|
||||
applyConversation: (updated: ApiConversation) => void;
|
||||
run: (action: () => Promise<ApiConversation>) => Promise<void>;
|
||||
}) {
|
||||
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
|
||||
const [newLabel, setNewLabel] = useState("");
|
||||
useEffect(() => { setNewLabel(""); }, [detail.id]);
|
||||
useEffect(() => {
|
||||
fetchConversationLabels().then(setLabels).catch((error) => {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
});
|
||||
}, [setErrorText]);
|
||||
|
||||
async function addLabel(id?: number) {
|
||||
if (busy) return;
|
||||
let labelId = id;
|
||||
setBusy(true);
|
||||
setErrorText("");
|
||||
try {
|
||||
if (labelId === undefined) {
|
||||
const name = newLabel.trim();
|
||||
if (!name) return;
|
||||
const label = await createConversationLabel(name);
|
||||
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
|
||||
labelId = label.id;
|
||||
setNewLabel("");
|
||||
}
|
||||
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
|
||||
applyConversation(await setConversationLabels(detail.id, ids));
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedIds = new Set(detail.labels.map((item) => item.id));
|
||||
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
|
||||
return (
|
||||
<>
|
||||
<label className="ctx-label">{t("conversations.labels")}</label>
|
||||
<div className="ctx-labels">
|
||||
<Dropdown
|
||||
disabled={busy}
|
||||
trigger={["click"]}
|
||||
overlayClassName="app-dropdown is-wide ctx-labels-menu"
|
||||
menu={{
|
||||
items: [
|
||||
...availableLabels.map((label) => ({
|
||||
key: label.id,
|
||||
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
|
||||
})),
|
||||
{
|
||||
key: "new",
|
||||
label: (
|
||||
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
aria-label={t("conversations.new_label")}
|
||||
disabled={busy}
|
||||
placeholder={t("conversations.new_label")}
|
||||
value={newLabel}
|
||||
onChange={(event) => setNewLabel(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
void addLabel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" aria-label={t("conversations.add")} disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
|
||||
</Dropdown>
|
||||
{detail.labels.map((label) => (
|
||||
<b className="ctx-label-chip" key={label.id}>
|
||||
<i style={{ background: label.color || "var(--n-5)" }} />
|
||||
{label.name}
|
||||
<button
|
||||
aria-label={t("conversations.remove_label", { name: label.name })}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</b>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import type { ApiConversation } from "./model";
|
||||
|
||||
export function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(detail.note);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft(detail.note);
|
||||
}, [detail.id, detail.note]);
|
||||
|
||||
return (
|
||||
<section className="ctx-section is-note">
|
||||
<div className="ctx-section-head">
|
||||
<h4>{t("conversations.note")}</h4>
|
||||
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="ctx-note is-editing">
|
||||
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
|
||||
<div className="ctx-note-actions">
|
||||
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Keep the inline add button from inheriting full-width menu item styles. */
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label button {
|
||||
width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function HelpCenterApp() {
|
||||
);
|
||||
}
|
||||
if (!manifest || !themeReady) {
|
||||
return <main className="help-boot-loading"><Loader size={44} /></main>;
|
||||
return <main className="help-boot-loading"><Loader size={72} /></main>;
|
||||
}
|
||||
return route.articleSlug
|
||||
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
|
||||
|
||||
@@ -42,11 +42,17 @@ export function HelpLayout({
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
<footer className="help-footer">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
<span className="help-brand-mark"><LogoIcon /></span>
|
||||
<span>{portal.name}</span>
|
||||
</a>
|
||||
<span>{t("portals.knowledge_base_support")}</span>
|
||||
<div className="help-footer-row">
|
||||
<a className="help-footer-brand" href={homeHref}>
|
||||
<span className="help-brand-mark"><LogoIcon /></span>
|
||||
<span>{portal.name}</span>
|
||||
</a>
|
||||
<span>{t("portals.knowledge_base_support")}</span>
|
||||
</div>
|
||||
<span className="help-footer-powered">
|
||||
{t("portals.powered_by")}{" "}
|
||||
<a href="https://chatballs.ru" target="_blank" rel="noopener noreferrer">Chatballs</a>
|
||||
</span>
|
||||
</footer>
|
||||
{portal.webWidgetKey && <PortalWebWidget widgetKey={portal.webWidgetKey} />}
|
||||
</div>
|
||||
|
||||
@@ -153,17 +153,37 @@ html.help-center-document body {
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
display: flex;
|
||||
display: grid;
|
||||
width: min(1024px, calc(100% - 48px));
|
||||
margin: 80px auto 0;
|
||||
padding: 34px 0 42px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-top: 1px solid var(--help-line-soft);
|
||||
color: var(--help-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.help-footer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.help-footer-powered {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.help-footer-powered a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.help-footer-powered a:hover {
|
||||
color: var(--help-ink);
|
||||
}
|
||||
|
||||
.help-footer-brand {
|
||||
gap: 9px;
|
||||
font-size: 16px;
|
||||
@@ -256,8 +276,7 @@ html.help-center-document body {
|
||||
color: var(--help-text-muted);
|
||||
}
|
||||
|
||||
.help-fatal,
|
||||
.help-boot-loading {
|
||||
.help-fatal {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
|
||||
@@ -115,10 +115,14 @@
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
gap: 14px;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.help-footer-row {
|
||||
display: grid;
|
||||
justify-content: start;
|
||||
gap: 12px;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.help-support-launcher {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user