From 55ab309ae7b6006b2d1b8c0d2c0fb3cc232d83f0 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 26 Jul 2026 20:58:04 +0300 Subject: [PATCH] :sparkles: feat(conversations): complete email dialogs and contacts --- .../hub_platform/conversations/clients.py | 30 +- .../conversations/html_sanitizer.py | 122 ++++++++ .../hub_platform/conversations/ingest.py | 14 +- .../migrations/0008_message_content_html.py | 15 + .../hub_platform/conversations/models.py | 3 + .../hub_platform/conversations/poller.py | 2 + .../conversations/reporting_views.py | 64 +++++ .../hub_platform/conversations/serializers.py | 34 ++- .../hub_platform/conversations/services.py | 48 +++- .../conversations/test_email_transport.py | 46 +++ .../conversations/test_lifecycle.py | 190 +++++++++++++ .../conversations/transports/base.py | 3 + .../conversations/transports/email.py | 10 + .../hub_platform/conversations/urls.py | 11 +- .../hub_platform/conversations/view_base.py | 43 +++ .../hub_platform/conversations/views.py | 262 ++++++++++-------- .../migrations/0005_integration_is_active.py | 15 + .../hub_platform/integrations/models.py | 3 + .../hub_platform/integrations/serializers.py | 1 + .../hub_platform/integrations/services.py | 22 +- .../integrations/test_activation.py | 66 +++++ .../hub_platform/integrations/views.py | 5 + .../hub_platform/notifications/tests.py | 43 +++ .../src/features/conversations/Composer.tsx | 34 ++- .../conversations/ConversationActions.tsx | 62 +++++ .../conversations/ConversationThread.tsx | 14 +- .../conversations/ConversationWorkspace.tsx | 69 +++-- .../src/features/conversations/DialogList.tsx | 4 +- .../conversations/EmailMessageBody.tsx | 37 +++ .../src/features/conversations/data.ts | 4 +- .../src/features/conversations/model.ts | 24 +- .../src/features/conversations/types.ts | 5 +- .../integrations/ConnectionsTable.tsx | 5 +- .../integrations/IntegrationsPage.tsx | 22 +- .../features/integrations/ProvidersTable.tsx | 5 +- .../src/features/integrations/model.ts | 1 + .../src/features/integrations/rows.tsx | 20 +- .../src/features/sales/SalesClientsPage.tsx | 5 +- .../client-detail/SalesClientDetailPage.tsx | 9 +- .../client-detail/SalesClientDialogsTab.tsx | 5 +- .../sales/client-detail/SalesClientHeader.tsx | 21 +- .../client-detail/SalesClientOverviewTab.tsx | 5 +- .../src/features/sales/client-detail/model.ts | 10 +- .../features/sales/clients/SalesClientRow.tsx | 7 +- .../sales/clients/SalesClientsFilters.tsx | 2 +- .../sales/clients/SalesClientsHeader.tsx | 5 +- .../clients/SalesClientsPagination.test.ts | 31 +++ .../sales/clients/SalesClientsPagination.tsx | 16 +- .../sales/clients/SalesClientsTable.tsx | 6 +- .../sales/clients/exportClientsCsv.ts | 47 ++++ .../src/features/sales/clients/model.ts | 7 +- .../features/sales/clients/useSalesClients.ts | 30 +- .../src/features/sales/dialogs/composer.css | 18 -- .../sales/dialogs/context/ClientContext.tsx | 7 +- .../sales/dialogs/context/HistoryContext.tsx | 3 +- .../features/sales/dialogs/email-message.css | 8 + .../src/features/sales/dialogs/feedback.css | 24 ++ .../src/features/sales/dialogs/styles.css | 2 + .../src/layout/ShellRouteContent.tsx | 2 +- apps/internal-ui/src/shared/providers.ts | 6 +- apps/internal-ui/src/shared/ui-controls.tsx | 12 +- 61 files changed, 1384 insertions(+), 262 deletions(-) create mode 100644 apps/backend/hub_platform/conversations/html_sanitizer.py create mode 100644 apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py create mode 100644 apps/backend/hub_platform/conversations/reporting_views.py create mode 100644 apps/backend/hub_platform/conversations/test_lifecycle.py create mode 100644 apps/backend/hub_platform/conversations/view_base.py create mode 100644 apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py create mode 100644 apps/backend/hub_platform/integrations/test_activation.py create mode 100644 apps/internal-ui/src/features/conversations/ConversationActions.tsx create mode 100644 apps/internal-ui/src/features/conversations/EmailMessageBody.tsx create mode 100644 apps/internal-ui/src/features/sales/clients/SalesClientsPagination.test.ts create mode 100644 apps/internal-ui/src/features/sales/clients/exportClientsCsv.ts create mode 100644 apps/internal-ui/src/features/sales/dialogs/email-message.css create mode 100644 apps/internal-ui/src/features/sales/dialogs/feedback.css diff --git a/apps/backend/hub_platform/conversations/clients.py b/apps/backend/hub_platform/conversations/clients.py index 76b2976..664d114 100644 --- a/apps/backend/hub_platform/conversations/clients.py +++ b/apps/backend/hub_platform/conversations/clients.py @@ -21,7 +21,12 @@ from hub_platform.identity.models import AuditEvent from hub_platform.orders.models import Order, PaymentStatus # Короткие коды для UI (совпадают с фронтовыми справочниками). -PROVIDER_CODE = {"MAX": "MAX", "TELEGRAM": "TG", "WEB": "WEB"} +PROVIDER_CODE = { + "MAX": "MAX", + "TELEGRAM": "TG", + "WEB": "WEB", + "EMAIL": "EMAIL", +} PRODUCT_CODE = {"firepage": "FP", "foxray": "FX"} # Понятные подписи для аудита диалогов. @@ -30,6 +35,7 @@ AUDIT_LABELS = { "conversations.released_to_ai": "Возврат к AI", "conversations.returned_to_queue": "Возврат в очередь", "conversations.closed": "Диалог закрыт", + "conversations.marked_spam": "Диалог помечен как спам", } @@ -96,6 +102,14 @@ def clients_overview( "cid": f"CUS-{contact.id}", "name": contact.name or "Гость", "phone": contact.phone, + "email": next( + ( + identity.external_user_id + for identity in contact.identities.all() + if identity.connection.provider == "EMAIL" + ), + "", + ), # Первый непустой @логин среди identity каналов (остальные — в карточке). "username": next((identity.username for identity in contact.identities.all() if identity.username), ""), # Статус выводится из данных: есть оплаченный заказ — клиент, иначе лид. @@ -167,7 +181,11 @@ def client_detail( identities = [ { "provider": identity.connection.provider, - "value": identity.display_name or identity.external_user_id, + "value": ( + identity.external_user_id + if identity.connection.provider == "EMAIL" + else identity.display_name or identity.external_user_id + ), "username": identity.username, "createdAt": identity.created_at.isoformat(), } @@ -228,6 +246,14 @@ def client_detail( "cid": f"CUS-{contact.id}", "name": contact.name or "Гость", "phone": contact.phone, + "email": next( + ( + identity.external_user_id + for identity in identity_qs + if identity.connection.provider == "EMAIL" + ), + "", + ), "channels": sorted(channels), "products": sorted(products), "openDialogs": open_dialogs, diff --git a/apps/backend/hub_platform/conversations/html_sanitizer.py b/apps/backend/hub_platform/conversations/html_sanitizer.py new file mode 100644 index 0000000..b00d6c2 --- /dev/null +++ b/apps/backend/hub_platform/conversations/html_sanitizer.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import html +from html.parser import HTMLParser +from urllib.parse import urlsplit + + +_ALLOWED_TAGS = { + "a", + "b", + "blockquote", + "br", + "code", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "li", + "ol", + "p", + "pre", + "s", + "span", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "u", + "ul", +} +_BLOCKED_TAGS = { + "applet", + "audio", + "button", + "embed", + "form", + "head", + "iframe", + "input", + "link", + "meta", + "object", + "script", + "select", + "style", + "svg", + "textarea", + "title", + "video", +} +_VOID_TAGS = {"br", "hr"} + + +def _safe_href(value: str) -> str: + candidate = value.strip() + if not candidate: + return "" + parsed = urlsplit(candidate) + if parsed.scheme.lower() not in {"http", "https", "mailto"}: + return "" + return candidate + + +class _Sanitizer(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + self.blocked_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + if tag in _BLOCKED_TAGS: + self.blocked_depth += 1 + return + if self.blocked_depth or tag not in _ALLOWED_TAGS: + return + safe_attrs: list[str] = [] + if tag == "a": + href = next((value or "" for key, value in attrs if key.lower() == "href"), "") + href = _safe_href(href) + if href: + safe_attrs.extend( + [ + f'href="{html.escape(href, quote=True)}"', + 'target="_blank"', + 'rel="noopener noreferrer"', + ] + ) + suffix = f" {' '.join(safe_attrs)}" if safe_attrs else "" + self.parts.append(f"<{tag}{suffix}>") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self.handle_starttag(tag, attrs) + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in _BLOCKED_TAGS: + self.blocked_depth = max(0, self.blocked_depth - 1) + return + if self.blocked_depth or tag not in _ALLOWED_TAGS or tag in _VOID_TAGS: + return + self.parts.append(f"") + + def handle_data(self, data: str) -> None: + if not self.blocked_depth: + self.parts.append(html.escape(data)) + + +def sanitize_email_html(markup: str) -> str: + parser = _Sanitizer() + parser.feed(markup) + parser.close() + return "".join(parser.parts).strip() diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index a470a2f..b73d57b 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -76,6 +76,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: logger.warning("Integration %s has no channel — inbound dropped", integration.id) return context = TenantContext.for_resource(channel.organization) + agent = getattr(channel, "ai_agent", None) + ai_available = bool(agent and agent.is_active) source = f"{integration.provider.lower()}:{integration.id}" if _already_processed(context, source, inbound.external_id, inbound.text): return @@ -123,8 +125,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: connection=integration, contact=contact, external_chat_id=inbound.chat_id, - control_mode=ControlMode.AI, - expected_responder=ExpectedResponder.AI, + control_mode=ControlMode.AI if ai_available else ControlMode.PAUSED, + expected_responder=ExpectedResponder.AI if ai_available else ExpectedResponder.OPERATOR, previous_conversation=previous, ) # C07: only a brand-new dialog counts toward new_dialogs quota. @@ -146,10 +148,15 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: author_type=MessageAuthor.CONTACT, kind=MessageKind.CONTACT if is_contact_share else MessageKind.TEXT, text=message_text, + content_html=inbound.content_html, external_id=inbound.external_id, ) 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"]) if inbound.thread_meta: # Email: Message-ID последнего входящего — для ответа в тред; # тема диалога фиксируется по первому письму (ADR-HUB-0035). @@ -202,7 +209,8 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: transports.send_contact_ack(integration, chat_id=conversation.external_chat_id, user_id=inbound.user_id, text=ack) return - # AI отвечает только когда диалог ведёт AI (ADR-HUB-0003). + # Операторский канал без активного агента сразу создаёт очередь и не + # имитирует сбой AI перед клиентом. if conversation.control_mode != ControlMode.AI: return diff --git a/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py b/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py new file mode 100644 index 0000000..eac1ca2 --- /dev/null +++ b/apps/backend/hub_platform/conversations/migrations/0008_message_content_html.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0007_conversation_transport_meta"), + ] + + operations = [ + migrations.AddField( + model_name="message", + name="content_html", + field=models.TextField(blank=True), + ), + ] diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/hub_platform/conversations/models.py index 11d8365..98c1627 100644 --- a/apps/backend/hub_platform/conversations/models.py +++ b/apps/backend/hub_platform/conversations/models.py @@ -145,6 +145,9 @@ class Message(TenantRelationModel): # телефона), полученный контакт. Пустая строка = текст. kind = models.CharField(max_length=32, choices=MessageKind.choices, default=MessageKind.TEXT, blank=True) text = models.TextField(blank=True) + # Санитизированный HTML входящего email. Остальные транспорты и исходящие + # ответы используют plain text. + content_html = models.TextField(blank=True) external_id = models.CharField(max_length=128, blank=True) created_at = models.DateTimeField(auto_now_add=True, db_index=True) diff --git a/apps/backend/hub_platform/conversations/poller.py b/apps/backend/hub_platform/conversations/poller.py index 2350651..066db41 100644 --- a/apps/backend/hub_platform/conversations/poller.py +++ b/apps/backend/hub_platform/conversations/poller.py @@ -17,7 +17,9 @@ def poll_all_messengers(context) -> int: for integration in Integration.objects.filter( organization=context.organization, provider__in=transports.SUPPORTED_PROVIDERS, + is_active=True, channel__isnull=False, + channel__is_active=True, ).exclude(secret="") if integration.config.get("purpose") != "notifications" ] diff --git a/apps/backend/hub_platform/conversations/reporting_views.py b/apps/backend/hub_platform/conversations/reporting_views.py new file mode 100644 index 0000000..1969626 --- /dev/null +++ b/apps/backend/hub_platform/conversations/reporting_views.py @@ -0,0 +1,64 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from hub_platform.conversations.clients import client_detail, clients_overview +from hub_platform.conversations.command import command_center_overview +from hub_platform.conversations.models import Contact +from hub_platform.conversations.stats import sales_overview_stats +from hub_platform.conversations.view_base import ConversationViewBase +from hub_platform.identity.policy import accessible_department_ids + + +class ConversationStatsView(ConversationViewBase): + def get(self, request: Request) -> Response: + period = request.query_params.get("period", "today") + if period not in ("today", "d7", "d30"): + period = "today" + department_ids = accessible_department_ids( + request.tenant_context.membership, self.required_capability + ) + return Response( + sales_overview_stats(request.tenant_context, period, department_ids) + ) + + +class CommandOverviewView(ConversationViewBase): + required_capability = "company.view" + require_organization_scope = True + + def get(self, request: Request) -> Response: + period = request.query_params.get("period", "today") + if period not in ("today", "d7", "d30"): + period = "today" + return Response(command_center_overview(request.tenant_context, period)) + + +class ClientsView(ConversationViewBase): + required_capability = "customers.view" + + def get(self, request: Request) -> Response: + department_ids = accessible_department_ids( + request.tenant_context.membership, self.required_capability + ) + return Response( + {"items": clients_overview(self._org(request).id, department_ids)} + ) + + +class ClientDetailView(ConversationViewBase): + required_capability = "customers.view" + + def get(self, request: Request, contact_id: int) -> Response: + try: + department_ids = accessible_department_ids( + request.tenant_context.membership, self.required_capability + ) + return Response( + { + "client": client_detail( + self._org(request).id, contact_id, department_ids + ) + } + ) + except Contact.DoesNotExist: + return Response({"detail": "Клиент не найден"}, status=404) diff --git a/apps/backend/hub_platform/conversations/serializers.py b/apps/backend/hub_platform/conversations/serializers.py index 00bde4a..038348a 100644 --- a/apps/backend/hub_platform/conversations/serializers.py +++ b/apps/backend/hub_platform/conversations/serializers.py @@ -1,4 +1,5 @@ from hub_platform.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor +from hub_platform.integrations.models import IntegrationProvider def message_payload(message: Message) -> dict[str, object]: @@ -8,6 +9,7 @@ def message_payload(message: Message) -> dict[str, object]: "authorUserId": message.author_user_id, "kind": message.kind, "text": message.text, + "contentHtml": message.content_html, "createdAt": message.created_at.isoformat(), } @@ -74,6 +76,15 @@ def _contact_username(conversation: Conversation) -> str: return identity.username if identity else "" +def _contact_email(conversation: Conversation) -> str: + if ( + conversation.connection_id + and conversation.connection.provider == IntegrationProvider.EMAIL + ): + return conversation.external_chat_id + return "" + + def _conversation_history(conversation: Conversation) -> list[Conversation]: # История по тому же источнику identity: для sales — по contact, для # support — по snapshot (ADR-HUB-0002: цепочка прошлых обращений). @@ -90,7 +101,13 @@ def _conversation_history(conversation: Conversation) -> list[Conversation]: ) -def conversation_payload(conversation: Conversation, *, with_messages: bool = False, last_read_id: int = 0) -> dict[str, object]: +def conversation_payload( + conversation: Conversation, + *, + with_messages: bool = False, + last_read_id: int = 0, + viewer_id: int | None = None, +) -> dict[str, object]: last = None if with_messages else _last_message(conversation) channel = conversation.channel payload = { @@ -112,6 +129,7 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa "id": conversation.contact_id, "name": conversation.contact.name, "phone": conversation.contact.phone, + "email": _contact_email(conversation), "username": _contact_username(conversation) if with_messages else "", } if conversation.contact_id @@ -122,6 +140,20 @@ def conversation_payload(conversation: Conversation, *, with_messages: bool = Fa "controlMode": conversation.control_mode, "expectedResponder": conversation.expected_responder, "assignedOperatorId": conversation.assigned_operator_id, + "assignedOperator": ( + { + "id": conversation.assigned_operator_id, + "name": ( + conversation.assigned_operator.full_name + or conversation.assigned_operator.email + ), + } + if conversation.assigned_operator_id + else None + ), + "isAssignedToViewer": bool( + viewer_id and conversation.assigned_operator_id == viewer_id + ), "lastActivityAt": conversation.last_activity_at.isoformat(), "createdAt": conversation.created_at.isoformat(), } diff --git a/apps/backend/hub_platform/conversations/services.py b/apps/backend/hub_platform/conversations/services.py index 60ad49a..9103e2e 100644 --- a/apps/backend/hub_platform/conversations/services.py +++ b/apps/backend/hub_platform/conversations/services.py @@ -23,6 +23,11 @@ class ClaimError(Exception): pass +def _require_open(conversation: Conversation) -> None: + if conversation.lifecycle != LifecycleState.OPEN: + raise ClaimError("Диалог закрыт") + + def _operator_label(operator) -> str: return getattr(operator, "full_name", "") or operator.email @@ -41,6 +46,7 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat operator = context.actor_user if operator is None or conversation.organization_id != context.organization_id: raise ClaimError("Диалог недоступен") + _require_open(conversation) if ( conversation.control_mode == ControlMode.HUMAN and conversation.assigned_operator_id @@ -64,6 +70,10 @@ def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversati conversation = Conversation.objects.select_for_update().get( id=conversation_id, organization=context.organization ) + _require_open(conversation) + agent = getattr(conversation.channel, "ai_agent", None) + if agent is None or not agent.is_active: + raise ClaimError("У канала нет активного AI-агента") conversation.control_mode = ControlMode.AI conversation.assigned_operator = None conversation.expected_responder = ExpectedResponder.AI @@ -78,6 +88,7 @@ def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversa conversation = Conversation.objects.select_for_update().get( id=conversation_id, organization=context.organization ) + _require_open(conversation) conversation.control_mode = ControlMode.PAUSED conversation.assigned_operator = None conversation.expected_responder = ExpectedResponder.OPERATOR @@ -92,6 +103,7 @@ def post_operator_message( operator = context.actor_user if operator is None or conversation.organization_id != context.organization_id: raise Conversation.DoesNotExist + _require_open(conversation) message = Message.objects.create( conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text ) @@ -118,6 +130,7 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me operator = context.actor_user if operator is None or conversation.organization_id != context.organization_id: raise Conversation.DoesNotExist + _require_open(conversation) is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT message = Message.objects.create( @@ -148,7 +161,40 @@ def close_conversation(*, context: TenantContext, conversation_id: int) -> Conve conversation = Conversation.objects.select_for_update().get( id=conversation_id, organization=context.organization ) + _require_open(conversation) conversation.lifecycle = LifecycleState.CLOSED + conversation.control_mode = ControlMode.PAUSED + conversation.assigned_operator = None conversation.expected_responder = ExpectedResponder.NOBODY - conversation.save(update_fields=["lifecycle", "expected_responder"]) + conversation.save( + update_fields=[ + "lifecycle", + "control_mode", + "assigned_operator", + "expected_responder", + ] + ) + return conversation + + +@transaction.atomic +def mark_conversation_as_spam( + *, context: TenantContext, conversation_id: int +) -> Conversation: + conversation = Conversation.objects.select_for_update().get( + id=conversation_id, organization=context.organization + ) + _require_open(conversation) + conversation.lifecycle = LifecycleState.SPAM + conversation.control_mode = ControlMode.PAUSED + conversation.assigned_operator = None + conversation.expected_responder = ExpectedResponder.NOBODY + conversation.save( + update_fields=[ + "lifecycle", + "control_mode", + "assigned_operator", + "expected_responder", + ] + ) return conversation diff --git a/apps/backend/hub_platform/conversations/test_email_transport.py b/apps/backend/hub_platform/conversations/test_email_transport.py index fab58a2..2b5ba80 100644 --- a/apps/backend/hub_platform/conversations/test_email_transport.py +++ b/apps/backend/hub_platform/conversations/test_email_transport.py @@ -8,6 +8,8 @@ from django.test import TestCase from hub_platform.channels.models import Channel from hub_platform.conversations.models import Contact, Conversation, MessageAuthor +from hub_platform.conversations.clients import client_detail, clients_overview +from hub_platform.conversations.serializers import conversation_payload from hub_platform.conversations.transports import email as email_transport from hub_platform.identity.bootstrap import bootstrap_edevs_owner from hub_platform.identity.models import Organization @@ -86,6 +88,29 @@ class EmailNormalizeTests(TestCase): self.assertIn("Добрый день!", inbound.text) self.assertIn("«FoxRay»", inbound.text) self.assertNotIn("

", inbound.text) + self.assertIn("

Добрый день!

", inbound.content_html) + + def test_html_body_is_sanitized_before_ingest(self) -> None: + inbound, _ = email_transport._normalize( + _parsed( + _raw( + html=( + '

Здравствуйте!

' + '' + 'опасная ссылка' + 'сайт' + ) + ) + ), + own_address="support@edevs.tech", + fallback_id="7:100", + ) + self.assertIsNotNone(inbound) + self.assertNotIn("onclick", inbound.content_html) + self.assertNotIn("script", inbound.content_html) + self.assertNotIn("alert", inbound.content_html) + self.assertNotIn("javascript:", inbound.content_html) + self.assertIn('href="https://example.com/path"', inbound.content_html) def test_attachments_add_note(self) -> None: inbound, _ = email_transport._normalize( @@ -225,6 +250,7 @@ class EmailIngestThreadMetaTests(TestCase): with ( mock.patch("hub_platform.conversations.ingest.run_channel_turn", return_value=mock.Mock(text="Ответ")), mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True), + mock.patch("hub_platform.conversations.ingest.record_usage"), ): ingest_inbound(self.integration, inbound) @@ -236,3 +262,23 @@ class EmailIngestThreadMetaTests(TestCase): self.assertEqual(conversation.transport_meta["last_message_id"], "") authors = list(conversation.messages.values_list("author_type", flat=True)) self.assertIn(MessageAuthor.CONTACT, authors) + + def test_email_identity_is_exposed_in_dialog_and_contact_payloads(self) -> None: + self._ingest( + external_id="", + subject="Контакты", + message_id="", + ) + conversation = Conversation.objects.get(channel=self.channel) + dialog = conversation_payload(conversation, with_messages=True) + self.assertEqual(dialog["connection"]["provider"], "EMAIL") + self.assertEqual(dialog["contact"]["email"], "ivan@example.com") + self.assertEqual(dialog["messages"][0]["contentHtml"], "") + + overview = clients_overview(self.organization.id) + self.assertEqual(overview[0]["email"], "ivan@example.com") + self.assertIn("EMAIL", overview[0]["channels"]) + + detail = client_detail(self.organization.id, conversation.contact_id) + self.assertEqual(detail["email"], "ivan@example.com") + self.assertEqual(detail["identities"][0]["value"], "ivan@example.com") diff --git a/apps/backend/hub_platform/conversations/test_lifecycle.py b/apps/backend/hub_platform/conversations/test_lifecycle.py new file mode 100644 index 0000000..077b4c7 --- /dev/null +++ b/apps/backend/hub_platform/conversations/test_lifecycle.py @@ -0,0 +1,190 @@ +import json +from unittest import mock + +from django.test import TestCase + +from hub_platform.channels.models import Channel +from hub_platform.conversations.ingest import ingest_inbound +from hub_platform.conversations.models import ( + ConnectionIdentity, + Contact, + ControlMode, + Conversation, + ExpectedResponder, + LifecycleState, + MessageAuthor, +) +from hub_platform.conversations.transports.base import InboundMessage +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.identity.models import HumanUser, Organization +from hub_platform.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, +) +from hub_platform.testing import TenantAPIClient as APIClient + + +def _connection(channel: Channel) -> Integration: + return Integration.objects.create( + organization=channel.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="test-bot", + channel=channel, + ) + + +class OperatorOnlyIngestTests(TestCase): + def setUp(self) -> None: + bootstrap_edevs_owner( + email="owner@edevs.tech", password="temporary-password" + ) + self.organization = Organization.objects.get(slug="edevs") + self.channel = Channel.objects.create( + organization=self.organization, + code="operator-only", + name="Операторский канал", + ) + self.integration = _connection(self.channel) + + def _ingest_without_ai(self, inbound: InboundMessage) -> tuple[mock.Mock, mock.Mock]: + with ( + mock.patch( + "hub_platform.conversations.ingest.run_channel_turn" + ) as ai_turn, + mock.patch( + "hub_platform.conversations.ingest.transports.send_reply" + ) as send, + ): + ingest_inbound(self.integration, inbound) + return ai_turn, send + + def test_new_dialog_starts_in_queue_without_ai_fallback(self) -> None: + ai_turn, send = self._ingest_without_ai( + InboundMessage( + external_id="operator-1", + user_id="user-1", + chat_id="chat-1", + text="Нужна помощь", + display_name="Гость", + ) + ) + + conversation = self.channel.conversations.get() + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertEqual( + conversation.expected_responder, ExpectedResponder.OPERATOR + ) + self.assertEqual( + list(conversation.messages.values_list("author_type", flat=True)), + [MessageAuthor.CONTACT], + ) + ai_turn.assert_not_called() + send.assert_not_called() + + def test_existing_ai_dialog_moves_to_queue_when_agent_is_unavailable(self) -> None: + contact = Contact.objects.create( + organization=self.organization, name="Клиент" + ) + ConnectionIdentity.objects.create( + contact=contact, + connection=self.integration, + external_user_id="user-existing", + ) + conversation = Conversation.objects.create( + organization=self.organization, + channel=self.channel, + connection=self.integration, + contact=contact, + external_chat_id="chat-existing", + control_mode=ControlMode.AI, + expected_responder=ExpectedResponder.AI, + ) + + ai_turn, send = self._ingest_without_ai( + InboundMessage( + external_id="operator-2", + user_id="user-existing", + chat_id="chat-existing", + text="Вы здесь?", + display_name="Клиент", + ) + ) + + conversation.refresh_from_db() + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertEqual( + conversation.expected_responder, ExpectedResponder.OPERATOR + ) + self.assertEqual(conversation.messages.count(), 1) + ai_turn.assert_not_called() + send.assert_not_called() + + +class ClosedConversationActionTests(TestCase): + def setUp(self) -> None: + bootstrap_edevs_owner( + email="owner@edevs.tech", password="temporary-password" + ) + organization = Organization.objects.get(slug="edevs") + channel = Channel.objects.create( + organization=organization, + code="closed-actions", + name="Закрытые диалоги", + ) + integration = _connection(channel) + contact = Contact.objects.create(organization=organization, name="Иван") + owner = HumanUser.objects.get(email="owner@edevs.tech") + self.conversation = Conversation.objects.create( + organization=organization, + channel=channel, + connection=integration, + contact=contact, + lifecycle=LifecycleState.CLOSED, + control_mode=ControlMode.HUMAN, + assigned_operator=owner, + expected_responder=ExpectedResponder.NOBODY, + ) + self.client = APIClient() + self.client.login( + username="owner@edevs.tech", password="temporary-password" + ) + + def test_closed_dialog_rejects_operational_actions(self) -> None: + base = f"/api/v1/conversations/{self.conversation.id}" + self.assertEqual(self.client.post(f"{base}/claim/").status_code, 409) + self.assertEqual(self.client.post(f"{base}/release/").status_code, 409) + self.assertEqual( + self.client.post(f"{base}/return-queue/").status_code, 409 + ) + self.assertEqual( + self.client.post( + f"{base}/messages/", + data=json.dumps({"text": "Поздний ответ"}), + content_type="application/json", + ).status_code, + 409, + ) + self.assertEqual(self.client.post(f"{base}/close/").status_code, 409) + self.assertEqual(self.client.post(f"{base}/spam/").status_code, 409) + + def test_open_dialog_can_be_marked_as_spam(self) -> None: + self.conversation.lifecycle = LifecycleState.OPEN + self.conversation.expected_responder = ExpectedResponder.OPERATOR + self.conversation.save( + update_fields=["lifecycle", "expected_responder"] + ) + + response = self.client.post( + f"/api/v1/conversations/{self.conversation.id}/spam/" + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["conversation"] + self.assertEqual(payload["lifecycle"], LifecycleState.SPAM) + self.assertEqual(payload["controlMode"], ControlMode.PAUSED) + self.assertIsNone(payload["assignedOperator"]) + self.assertEqual( + payload["expectedResponder"], ExpectedResponder.NOBODY + ) diff --git a/apps/backend/hub_platform/conversations/transports/base.py b/apps/backend/hub_platform/conversations/transports/base.py index 5ef000e..6eb843d 100644 --- a/apps/backend/hub_platform/conversations/transports/base.py +++ b/apps/backend/hub_platform/conversations/transports/base.py @@ -24,6 +24,9 @@ class InboundMessage: username: str = "" # Телефон из явного шаринга контакта (кнопка/форма); text при этом может быть пуст. phone: str = "" + # Безопасный форматированный вариант входящего письма. Plain text остаётся + # обязательным fallback для AI, поиска, уведомлений и превью. + content_html: str = "" # Транспортная мета для тредирования ответа (email: subject/last_message_id). # Пишется в Conversation.transport_meta при ingest (ADR-HUB-0035). thread_meta: dict | None = None diff --git a/apps/backend/hub_platform/conversations/transports/email.py b/apps/backend/hub_platform/conversations/transports/email.py index 77a3a70..9a0a9fb 100644 --- a/apps/backend/hub_platform/conversations/transports/email.py +++ b/apps/backend/hub_platform/conversations/transports/email.py @@ -18,6 +18,7 @@ from email.utils import parseaddr from django.conf import settings +from hub_platform.conversations.html_sanitizer import sanitize_email_html from hub_platform.conversations.transports.base import InboundMessage logger = logging.getLogger(__name__) @@ -56,6 +57,13 @@ def _body_text(message) -> str: return _html_to_text(str(rich.get_content())) if rich is not None else "" +def _body_html(message) -> str: + rich = message.get_body(preferencelist=("html",)) + if rich is None: + return "" + return sanitize_email_html(str(rich.get_content())) + + def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundMessage | None, str]: """Parse one RFC822 message → (InboundMessage | None, raw Message-ID).""" display_name, address = parseaddr(str(message.get("From", ""))) @@ -65,6 +73,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM return None, "" message_id = str(message.get("Message-ID", "")).strip() text = _body_text(message) + content_html = _body_html(message) attachments = sum(1 for _ in message.iter_attachments()) if attachments: # Вложения в первой итерации не принимаются (ADR-HUB-0035). @@ -77,6 +86,7 @@ def _normalize(message, *, own_address: str, fallback_id: str) -> tuple[InboundM user_id=address, chat_id=address, text=text, + content_html=content_html, display_name=str(display_name).strip() or address, thread_meta={"subject": str(message.get("Subject", "")).strip(), "last_message_id": message_id}, ) diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py index d99ef49..3f3cedd 100644 --- a/apps/backend/hub_platform/conversations/urls.py +++ b/apps/backend/hub_platform/conversations/urls.py @@ -1,13 +1,13 @@ from django.urls import path -from hub_platform.conversations import views +from hub_platform.conversations import reporting_views, views urlpatterns = [ path("", views.ConversationListView.as_view(), name="conversation-list"), - path("stats/", views.ConversationStatsView.as_view(), name="conversation-stats"), - path("command-overview/", views.CommandOverviewView.as_view(), name="conversation-command-overview"), - path("clients/", views.ClientsView.as_view(), name="conversation-clients"), - path("clients//", views.ClientDetailView.as_view(), name="conversation-client-detail"), + path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"), + path("command-overview/", reporting_views.CommandOverviewView.as_view(), name="conversation-command-overview"), + path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"), + path("clients//", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"), path("/", views.ConversationDetailView.as_view(), name="conversation-detail"), path("/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"), path("/release/", views.ConversationReleaseView.as_view(), name="conversation-release"), @@ -15,4 +15,5 @@ urlpatterns = [ path("/messages/", views.ConversationMessageView.as_view(), name="conversation-messages"), path("/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"), path("/close/", views.ConversationCloseView.as_view(), name="conversation-close"), + path("/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"), ] diff --git a/apps/backend/hub_platform/conversations/view_base.py b/apps/backend/hub_platform/conversations/view_base.py new file mode 100644 index 0000000..df7b411 --- /dev/null +++ b/apps/backend/hub_platform/conversations/view_base.py @@ -0,0 +1,43 @@ +from rest_framework.request import Request +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability +from hub_platform.conversations.models import Conversation +from hub_platform.conversations.selectors import conversation_for_context +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.policy import require_capability + + +class ConversationViewBase(APIView): + permission_classes = [HasCapability] + required_capability = "conversations.view" + + def _org(self, request: Request): + return request.tenant_context.organization + + def _conversation( + self, + request: Request, + conversation_id: int, + capability: str = "conversations.view", + ) -> Conversation: + conversation = conversation_for_context( + context=request.tenant_context, conversation_id=conversation_id + ) + if not require_capability( + request.tenant_context.membership, capability, conversation + ): + raise Conversation.DoesNotExist + return conversation + + def _audit( + self, request: Request, action: str, conversation: Conversation + ) -> None: + record_audit_event( + action=f"conversations.{action}", + actor=request.user, + organization=self._org(request), + object_type="Conversation", + object_id=str(conversation.id), + request=request, + ) diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/hub_platform/conversations/views.py index aed3b93..2cb191f 100644 --- a/apps/backend/hub_platform/conversations/views.py +++ b/apps/backend/hub_platform/conversations/views.py @@ -1,67 +1,33 @@ from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.views import APIView -from hub_platform.api.permissions import HasCapability -from hub_platform.conversations.clients import client_detail, clients_overview -from hub_platform.conversations.command import command_center_overview -from hub_platform.conversations.models import Contact, ControlMode, Conversation, ConversationRead -from hub_platform.conversations.selectors import ( - conversation_for_context, - conversations_for_context, +from hub_platform.conversations.models import ( + ControlMode, + Conversation, + ConversationRead, + LifecycleState, ) +from hub_platform.conversations.selectors import conversations_for_context from hub_platform.conversations.serializers import conversation_payload, message_payload from hub_platform.conversations.services import ( ClaimError, claim_conversation, close_conversation, + mark_conversation_as_spam, post_operator_message, release_to_ai, request_contact, return_to_queue, ) -from hub_platform.conversations.stats import sales_overview_stats -from hub_platform.identity.audit import record_audit_event +from hub_platform.conversations.view_base import ConversationViewBase from hub_platform.identity.policy import ( ResourceScope, accessible_department_ids, authorize, - require_capability, ) -class _Base(APIView): - permission_classes = [HasCapability] - required_capability = "conversations.view" - - def _org(self, request: Request): - return request.tenant_context.organization - - def _conversation( - self, - request: Request, - conversation_id: int, - capability: str = "conversations.view", - ) -> Conversation: - conversation = conversation_for_context( - context=request.tenant_context, conversation_id=conversation_id - ) - if not require_capability(request.tenant_context.membership, capability, conversation): - raise Conversation.DoesNotExist - return conversation - - def _audit(self, request: Request, action: str, conversation: Conversation) -> None: - record_audit_event( - action=f"conversations.{action}", - actor=request.user, - organization=self._org(request), - object_type="Conversation", - object_id=str(conversation.id), - request=request, - ) - - -class ConversationListView(_Base): +class ConversationListView(ConversationViewBase): def get(self, request: Request) -> Response: items = conversations_for_context(request.tenant_context) department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) @@ -79,10 +45,21 @@ class ConversationListView(_Base): ConversationRead.objects.filter(user=request.user, conversation__in=items) .values_list("conversation_id", "last_read_message_id") ) - return Response({"items": [conversation_payload(c, last_read_id=read_map.get(c.id, 0)) for c in items]}) + return Response( + { + "items": [ + conversation_payload( + c, + last_read_id=read_map.get(c.id, 0), + viewer_id=request.user.id, + ) + for c in items + ] + } + ) -class ConversationDetailView(_Base): +class ConversationDetailView(ConversationViewBase): def get(self, request: Request, conversation_id: int) -> Response: try: conversation = self._conversation(request, conversation_id) @@ -98,10 +75,18 @@ class ConversationDetailView(_Base): if not created and read.last_read_message_id < last_id: read.last_read_message_id = last_id read.save(update_fields=["last_read_message_id", "updated_at"]) - return Response({"conversation": conversation_payload(conversation, with_messages=True)}) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) -class ConversationClaimView(_Base): +class ConversationClaimView(ConversationViewBase): required_capability = "conversations.operate" def post(self, request: Request, conversation_id: int) -> Response: @@ -116,10 +101,18 @@ class ConversationClaimView(_Base): except ClaimError as error: return Response({"detail": str(error)}, status=409) self._audit(request, "claimed", conversation) - return Response({"conversation": conversation_payload(conversation, with_messages=True)}) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) -class ConversationReleaseView(_Base): +class ConversationReleaseView(ConversationViewBase): required_capability = "conversations.operate" def post(self, request: Request, conversation_id: int) -> Response: @@ -127,70 +120,51 @@ class ConversationReleaseView(_Base): self._conversation(request, conversation_id, self.required_capability) except Conversation.DoesNotExist: return Response({"detail": "Диалог не найден"}, status=404) - conversation = release_to_ai( - context=request.tenant_context, conversation_id=conversation_id - ) - self._audit(request, "released_to_ai", conversation) - return Response({"conversation": conversation_payload(conversation, with_messages=True)}) - - -class ConversationReturnQueueView(_Base): - required_capability = "conversations.operate" - - def post(self, request: Request, conversation_id: int) -> Response: try: - self._conversation(request, conversation_id, self.required_capability) - except Conversation.DoesNotExist: - return Response({"detail": "Диалог не найден"}, status=404) - conversation = return_to_queue( - context=request.tenant_context, conversation_id=conversation_id - ) - self._audit(request, "returned_to_queue", conversation) - return Response({"conversation": conversation_payload(conversation, with_messages=True)}) - - -class ConversationStatsView(_Base): - def get(self, request: Request) -> Response: - period = request.query_params.get("period", "today") - if period not in ("today", "d7", "d30"): - period = "today" - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - return Response(sales_overview_stats(request.tenant_context, period, department_ids)) - - -class CommandOverviewView(_Base): - required_capability = "company.view" - require_organization_scope = True - - def get(self, request: Request) -> Response: - period = request.query_params.get("period", "today") - if period not in ("today", "d7", "d30"): - period = "today" - return Response(command_center_overview(request.tenant_context, period)) - - -class ClientsView(_Base): - required_capability = "customers.view" - - def get(self, request: Request) -> Response: - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - return Response({"items": clients_overview(self._org(request).id, department_ids)}) - - -class ClientDetailView(_Base): - required_capability = "customers.view" - - def get(self, request: Request, contact_id: int) -> Response: - try: - department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) - return Response( - {"client": client_detail(self._org(request).id, contact_id, department_ids)} + conversation = release_to_ai( + context=request.tenant_context, conversation_id=conversation_id ) - except Contact.DoesNotExist: - return Response({"detail": "Клиент не найден"}, status=404) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) + self._audit(request, "released_to_ai", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) -class ConversationMessageView(_Base): +class ConversationReturnQueueView(ConversationViewBase): + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + self._conversation(request, conversation_id, self.required_capability) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + try: + conversation = return_to_queue( + context=request.tenant_context, conversation_id=conversation_id + ) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) + self._audit(request, "returned_to_queue", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) + + +class ConversationMessageView(ConversationViewBase): required_capability = "conversations.operate" def post(self, request: Request, conversation_id: int) -> Response: @@ -203,6 +177,8 @@ class ConversationMessageView(_Base): return Response({"detail": "Пустое сообщение"}, status=400) if conversation.control_mode != ControlMode.HUMAN: return Response({"detail": "Сначала перехватите диалог"}, status=409) + if conversation.lifecycle != LifecycleState.OPEN: + return Response({"detail": "Диалог закрыт"}, status=409) manager_override = authorize( request.tenant_context.membership, self.required_capability, @@ -210,13 +186,16 @@ class ConversationMessageView(_Base): ) if conversation.assigned_operator_id != request.user.id and not manager_override: return Response({"detail": "Диалог ведёт другой оператор"}, status=409) - message = post_operator_message( - context=request.tenant_context, conversation=conversation, text=text - ) + try: + message = post_operator_message( + context=request.tenant_context, conversation=conversation, text=text + ) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) return Response({"message": message_payload(message)}, status=201) -class ConversationRequestContactView(_Base): +class ConversationRequestContactView(ConversationViewBase): required_capability = "conversations.operate" def post(self, request: Request, conversation_id: int) -> Response: @@ -226,14 +205,22 @@ class ConversationRequestContactView(_Base): return Response({"detail": "Диалог не найден"}, status=404) if not conversation.contact_id or not conversation.connection_id: return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409) + if conversation.lifecycle != LifecycleState.OPEN: + return Response({"detail": "Диалог закрыт"}, status=409) if conversation.contact.phone: return Response({"detail": "Контакт уже получен"}, status=409) - message = request_contact(context=request.tenant_context, conversation=conversation) + try: + message = request_contact( + context=request.tenant_context, + conversation=conversation, + ) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) self._audit(request, "contact_requested", conversation) return Response({"message": message_payload(message)}, status=201) -class ConversationCloseView(_Base): +class ConversationCloseView(ConversationViewBase): required_capability = "conversations.operate" def post(self, request: Request, conversation_id: int) -> Response: @@ -241,8 +228,45 @@ class ConversationCloseView(_Base): self._conversation(request, conversation_id, self.required_capability) except Conversation.DoesNotExist: return Response({"detail": "Диалог не найден"}, status=404) - conversation = close_conversation( - context=request.tenant_context, conversation_id=conversation_id - ) + try: + conversation = close_conversation( + context=request.tenant_context, conversation_id=conversation_id + ) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) self._audit(request, "closed", conversation) - return Response({"conversation": conversation_payload(conversation, with_messages=True)}) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) + + +class ConversationSpamView(ConversationViewBase): + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + self._conversation(request, conversation_id, self.required_capability) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + try: + conversation = mark_conversation_as_spam( + context=request.tenant_context, conversation_id=conversation_id + ) + except ClaimError as error: + return Response({"detail": str(error)}, status=409) + self._audit(request, "marked_spam", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, + with_messages=True, + viewer_id=request.user.id, + ) + } + ) diff --git a/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py b/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py new file mode 100644 index 0000000..4bced9d --- /dev/null +++ b/apps/backend/hub_platform/integrations/migrations/0005_integration_is_active.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0004_alter_integration_provider_email"), + ] + + operations = [ + migrations.AddField( + model_name="integration", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/backend/hub_platform/integrations/models.py b/apps/backend/hub_platform/integrations/models.py index 461f1e5..9bef1ab 100644 --- a/apps/backend/hub_platform/integrations/models.py +++ b/apps/backend/hub_platform/integrations/models.py @@ -49,6 +49,9 @@ class Integration(models.Model): # Несекретная конфигурация: base_url, модель по умолчанию и т.п. config = models.JSONField(default=dict, blank=True) status = models.CharField(max_length=16, choices=IntegrationStatus.choices, default=IntegrationStatus.UNCHECKED) + # Операционное состояние подключения. В отличие от status (результата + # последней проверки), is_active явно разрешает или запрещает runtime. + is_active = models.BooleanField(default=True) # Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2). channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections") # Курсор Long Polling (marker MAX / offset Telegram). diff --git a/apps/backend/hub_platform/integrations/serializers.py b/apps/backend/hub_platform/integrations/serializers.py index c977b78..08bd932 100644 --- a/apps/backend/hub_platform/integrations/serializers.py +++ b/apps/backend/hub_platform/integrations/serializers.py @@ -9,6 +9,7 @@ def integration_payload(integration: Integration) -> dict[str, object]: "provider": integration.provider, "name": integration.name, "hasSecret": bool(integration.secret), + "isActive": integration.is_active, "config": { "baseUrl": integration.config.get("base_url", ""), "defaultModel": integration.config.get("default_model", ""), diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py index f980d69..cef1385 100644 --- a/apps/backend/hub_platform/integrations/services.py +++ b/apps/backend/hub_platform/integrations/services.py @@ -24,17 +24,26 @@ class IntegrationInput: secret: str | None = None # None = не менять при update config: dict = field(default_factory=dict) channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019) + is_active: bool | None = None -def _resolve_channel(organization: Organization, channel_id: int | None): +def _resolve_channel( + organization: Organization, + channel_id: int | None, + *, + current_channel_id: int | None = None, +): if not channel_id: return None from hub_platform.channels.models import Channel try: - return Channel.objects.get(organization=organization, id=channel_id) + channel = Channel.objects.get(organization=organization, id=channel_id) except Channel.DoesNotExist as error: raise ValidationError({"channel": "Channel not found"}) from error + if not channel.is_active and channel.id != current_channel_id: + raise ValidationError({"channel": "Inactive channel cannot accept connections"}) + return channel def _email_config(config: dict) -> dict: @@ -124,6 +133,7 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int secret=(data.secret or "").strip(), config=_normalized_config(provider, data.config), channel=_resolve_channel(organization, data.channel_id), + is_active=True if data.is_active is None else data.is_active, status=IntegrationStatus.UNCHECKED, ) integration.full_clean(exclude=["secret"]) @@ -148,7 +158,13 @@ def update_integration( raise ValidationError({"integration": "Integration belongs to another organization"}) integration.name = data.name.strip() or integration.name integration.config = _normalized_config(integration.provider, data.config) - integration.channel = _resolve_channel(integration.organization, data.channel_id) + integration.channel = _resolve_channel( + integration.organization, + data.channel_id, + current_channel_id=integration.channel_id, + ) + if data.is_active is not None: + integration.is_active = data.is_active # Пустой/отсутствующий секрет при обновлении не затирает существующий. if data.secret: integration.secret = data.secret.strip() diff --git a/apps/backend/hub_platform/integrations/test_activation.py b/apps/backend/hub_platform/integrations/test_activation.py new file mode 100644 index 0000000..b093251 --- /dev/null +++ b/apps/backend/hub_platform/integrations/test_activation.py @@ -0,0 +1,66 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from hub_platform.channels.models import Channel +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.identity.models import Organization +from hub_platform.integrations.models import IntegrationProvider +from hub_platform.integrations.serializers import integration_payload +from hub_platform.integrations.services import ( + IntegrationInput, + create_integration, + update_integration, +) +from hub_platform.testing import system_tenant_context + + +class IntegrationActivationTests(TestCase): + def setUp(self) -> None: + bootstrap_edevs_owner( + email="owner@edevs.tech", password="temporary-password" + ) + organization = Organization.objects.get(slug="edevs") + self.context = system_tenant_context(organization) + self.channel = Channel.objects.create( + organization=organization, + code="activation", + name="Канал подключения", + ) + + def test_activation_round_trip_in_service_and_payload(self) -> None: + integration = create_integration( + context=self.context, + data=IntegrationInput( + provider=IntegrationProvider.WEB, + name="Виджет", + channel_id=self.channel.id, + ), + ) + updated = update_integration( + context=self.context, + integration=integration, + data=IntegrationInput( + provider=integration.provider, + name=integration.name, + config=integration.config, + channel_id=self.channel.id, + is_active=False, + ), + ) + + self.assertFalse(updated.is_active) + self.assertFalse(integration_payload(updated)["isActive"]) + + def test_new_binding_to_inactive_channel_is_rejected(self) -> None: + self.channel.is_active = False + self.channel.save(update_fields=["is_active"]) + + with self.assertRaises(ValidationError): + create_integration( + context=self.context, + data=IntegrationInput( + provider=IntegrationProvider.WEB, + name="Виджет", + channel_id=self.channel.id, + ), + ) diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/hub_platform/integrations/views.py index 02ef6c2..2eaaf76 100644 --- a/apps/backend/hub_platform/integrations/views.py +++ b/apps/backend/hub_platform/integrations/views.py @@ -31,6 +31,11 @@ def _input(body: dict[str, object], *, current: Integration | None = None) -> In secret=body.get("secret") if "secret" in body else None, config=config if isinstance(config, dict) else {}, channel_id=channel_id, + is_active=( + body["isActive"] + if isinstance(body.get("isActive"), bool) + else (current.is_active if current else None) + ), ) diff --git a/apps/backend/hub_platform/notifications/tests.py b/apps/backend/hub_platform/notifications/tests.py index d1a0aa1..c43ec97 100644 --- a/apps/backend/hub_platform/notifications/tests.py +++ b/apps/backend/hub_platform/notifications/tests.py @@ -170,6 +170,49 @@ class PollerSelectionTests(NotifierTestBase): self.assertIn(client_bot.id, polled_ids) self.assertNotIn(self.integration.id, polled_ids) + def test_disabled_connection_and_inactive_channel_are_not_polled(self) -> None: + from hub_platform.channels.models import Channel + from hub_platform.conversations import poller + + active_channel = Channel.objects.create( + organization=self.organization, + code="active-channel", + name="Активный канал", + ) + inactive_channel = Channel.objects.create( + organization=self.organization, + code="inactive-channel", + name="Неактивный канал", + is_active=False, + ) + Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="disabled-client", + secret="token", + channel=active_channel, + is_active=False, + ) + Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="inactive-channel-client", + secret="token", + channel=inactive_channel, + ) + + with mock.patch( + "hub_platform.conversations.poller.transports.poll", + return_value=([], ""), + ) as poll: + poller.poll_all_messengers(self.context) + + polled_names = [call.args[0].name for call in poll.call_args_list] + self.assertNotIn("disabled-client", polled_names) + self.assertNotIn("inactive-channel-client", polled_names) + class BindingApiTests(NotifierTestBase): def setUp(self) -> None: diff --git a/apps/internal-ui/src/features/conversations/Composer.tsx b/apps/internal-ui/src/features/conversations/Composer.tsx index 1988c42..89e9ac7 100644 --- a/apps/internal-ui/src/features/conversations/Composer.tsx +++ b/apps/internal-ui/src/features/conversations/Composer.tsx @@ -4,9 +4,36 @@ import { Icon } from "../../shared/icons"; import { sendOperatorMessage } from "./model"; import type { ControlMode } from "./types"; -export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) { +export function Composer({ mode, loaded, assignedOperatorName, conversationId, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) { const [text, setText] = useState(""); const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(""); + + if (conversationId == null) { + return
Выберите диалог
; + } + + if (!loaded) { + return
Загрузка диалога…
; + } + + if (mode === "closed") { + return ( +
+ +
Диалог закрыт

История сохранена. Новое обращение клиента создаст новый диалог.

+
+ ); + } + + if (mode === "assigned") { + return ( +
+ +
{assignedOperatorName ? `Диалог ведёт ${assignedOperatorName}` : "Диалог ведёт другой оператор"}

Отправка сообщений доступна назначенному оператору.

+
+ ); + } if (mode === "waiting") { return ( @@ -32,10 +59,13 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue const value = text.trim(); if (!value || conversationId == null || sending) return; setSending(true); + setSendError(""); try { await sendOperatorMessage(conversationId, value); setText(""); onSent(); + } catch (error) { + setSendError(error instanceof Error ? error.message : "Не удалось отправить сообщение"); } finally { setSending(false); } @@ -50,7 +80,6 @@ export function Composer({ mode, conversationId, onClaim, onRelease, onReturnQue
-