diff --git a/apps/backend/chatballs/calls/services.py b/apps/backend/chatballs/calls/services.py index 145a534..d77a530 100644 --- a/apps/backend/chatballs/calls/services.py +++ b/apps/backend/chatballs/calls/services.py @@ -13,7 +13,7 @@ from chatballs.calls.errors import ( CallInvalidTransition, CallTokenError, ) -from chatballs.calls.lifecycle import transition_call +from chatballs.calls.lifecycle import finish_call, transition_call from chatballs.calls.metrics import record_call_metric from chatballs.calls.models import ( TERMINAL_CALL_STATUSES, @@ -231,6 +231,15 @@ def issue_staff_access_token(*, context: TenantContext, call_session: CallSessio def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession: + """Оператор закончил звонок — из любой фазы, в которой тот ещё жив. + + Кнопка у оператора одна и означает «прекратить»: до ответа клиента это + отмена, после — завершение. Раньше здесь был только переход в CANCELLED, и + он разрешён лишь из REQUESTED/RINGING: клиент принял звонок, соединение не + установилось (частый случай за NAT), оператор жмёт «завершить» — и получает + 409, а звонок остаётся висеть. Фазу выбирает `finish_call`, тот же код, что + и у клиента. + """ user = context.actor_user if user is None or context.membership is None: raise CallAccessDenied(t("calls.operator_context_required")) @@ -238,12 +247,9 @@ def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSes if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists(): raise CallConflict(t("calls.not_a_participant")) try: - # Повторная отмена идемпотентна: transition_call вернёт звонок без изменений. - return transition_call( - call_session_id=call_session.id, - target_status=CallStatus.CANCELLED, - ended_by=CallEndedBy.STAFF, - ) + # Повторный вызов идемпотентен: у завершённого звонка finish_call + # возвращает его как есть. + return finish_call(call_session_id=call_session.id, side=ParticipantSide.STAFF) except CallInvalidTransition as error: raise CallConflict(t("calls.cannot_cancel")) from error diff --git a/apps/backend/chatballs/calls/tests/test_flow.py b/apps/backend/chatballs/calls/tests/test_flow.py index 74e410a..653924f 100644 --- a/apps/backend/chatballs/calls/tests/test_flow.py +++ b/apps/backend/chatballs/calls/tests/test_flow.py @@ -16,6 +16,7 @@ from chatballs.calls.models import ( CallStatus, InviteDeliveryStatus, ) +from chatballs.calls.lifecycle import transition_call from chatballs.calls.services import ( decline_call_for_identity, open_call_for_identity, @@ -61,6 +62,22 @@ class CancelCallApiTests(CallTestCase): 1, ) + def test_operator_ends_call_the_customer_already_accepted(self) -> None: + # Клиент принял звонок, соединение не установилось (частый случай за + # NAT). Кнопка оператора одна и обязана закончить звонок, а не упереться + # в запрет перехода. + created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) + open_call_for_identity(identity=self.identity) + transition_call(call_session_id=created.call_session.id, target_status=CallStatus.ACCEPTED) + + response = self.client.post(f"/api/v1/calls/{created.call_session.id}/cancel/") + + self.assertEqual(response.status_code, 200) + created.call_session.refresh_from_db() + self.assertEqual(created.call_session.status, CallStatus.FAILED) + self.assertEqual(created.call_session.failure_code, "ABORTED_BEFORE_CONNECT") + self.assertEqual(created.call_session.ended_by, CallEndedBy.STAFF) + def test_non_participant_cannot_cancel(self) -> None: created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) self.client.force_authenticate(user=self.operator) diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 0a0abff..4d5b743 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -26,6 +26,7 @@ from chatballs.conversations.models import ( Conversation, LifecycleState, ) +from chatballs.conversations.contact_avatars import contact_avatar_url_in from chatballs.i18n import t from chatballs.identity.audit_catalog import ( audit_action_label, @@ -161,7 +162,7 @@ def client_row(contact: Contact) -> dict: # на другом языке это перестало бы работать. "isGuest": not contact.name, "phone": contact.phone, - "avatarUrl": contact.avatar_url, + "avatarUrl": contact_avatar_url_in(contact, contact.organization_id), "email": next( ( identity.external_user_id @@ -301,7 +302,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict: # на другом языке это перестало бы работать. "isGuest": not contact.name, "phone": contact.phone, - "avatarUrl": contact.avatar_url, + "avatarUrl": contact_avatar_url_in(contact, contact.organization_id), # Поля карточки из чата (описание, компания, город). "description": contact.description, "company": contact.company, @@ -370,7 +371,7 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None: "cid": f"CUS-{other.id}", "name": other.name or t("conversations.guest"), "isGuest": not other.name, - "avatarUrl": other.avatar_url, + "avatarUrl": contact_avatar_url_in(other, other.organization_id), "dialogs": other.conversations.count(), "sources": sorted({identity.connection.provider for identity in identities}), "phone": other.phone, diff --git a/apps/backend/chatballs/conversations/contact_avatars.py b/apps/backend/chatballs/conversations/contact_avatars.py new file mode 100644 index 0000000..8084042 --- /dev/null +++ b/apps/backend/chatballs/conversations/contact_avatars.py @@ -0,0 +1,87 @@ +"""Фото контакта: скачиваем у провайдера и отдаём со своего адреса. + +Рабочее место живёт под `Content-Security-Policy: img-src 'self'`, поэтому +ссылка на CDN мессенджера до экрана не доезжает — оператор видит инициалы +вместо лица. Значит, фото должно лежать у нас и отдаваться тенантным +эндпоинтом, как фото сотрудника (`identity.avatars`). + +Источник фото запоминается в `Contact.avatar_source`: у MAX это адрес из +профиля отправителя, у Telegram — идентификатор файла фотографии. Пока +источник тот же, повторно ничего не качается. Строка `tg:none` означает «у +человека в Telegram фото нет»: без неё каждое его сообщение стоило бы лишнего +запроса к API. +""" + +from __future__ import annotations + +import hashlib +import logging + +from django.core.files.base import ContentFile + +from chatballs.conversations.models import Contact +from chatballs.identity.avatars import image_type, organization_public_id + +logger = logging.getLogger(__name__) + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 +# Проверено и фото нет: помним, чтобы не спрашивать провайдера снова. +NO_AVATAR = "none" + + +def contact_avatar_url_in(contact: Contact | None, organization_id: int) -> str | None: + """Ссылка на фото контакта для рабочего места; None — фото нет.""" + if contact is None: + return None + if contact.avatar: + version = hashlib.sha1(contact.avatar.name.encode("utf-8")).hexdigest()[:8] + public_id = organization_public_id(organization_id) + return f"/api/v1/organizations/{public_id}/conversations/clients/{contact.id}/avatar/?v={version}" + # Демо-набор и старые записи держат ссылку на наш же адрес — она рабочая. + return contact.avatar_url or None + + +def store_contact_avatar(contact: Contact, *, content: bytes, source: str) -> bool: + """Сохранить скачанное фото. False — это не картинка или она слишком велика.""" + if not content or len(content) > MAX_AVATAR_BYTES: + return False + detected = image_type(content) + if detected is None: + return False + content_type, suffix = detected + if contact.avatar: + contact.avatar.delete(save=False) + contact.avatar.save(f"avatar{suffix}", ContentFile(content), save=False) + contact.avatar_content_type = content_type + contact.avatar_source = source[:512] + contact.save(update_fields=["avatar", "avatar_content_type", "avatar_source"]) + return True + + +def _checked_marker(source: str) -> str: + """«Фото по этому источнику спрашивали, его нет» — чтобы не спрашивать снова.""" + return f"{NO_AVATAR}:{source}"[:512] + + +def refresh_contact_avatar(integration, inbound, contact: Contact) -> None: + """Подтянуть фото отправителя, если провайдер его отдаёт и оно новое. + + Сбой скачивания не мешает сообщению: фото — украшение карточки, а не её + содержание. + """ + from chatballs.conversations import transports + + try: + source = transports.avatar_source(integration, inbound) + if not source or contact.avatar_source in (source, _checked_marker(source)): + return + fetched = transports.download_avatar(integration, inbound) + if fetched is None: + contact.avatar_source = _checked_marker(source) + contact.save(update_fields=["avatar_source"]) + return + content, source_key = fetched + if not store_contact_avatar(contact, content=content, source=source_key): + logger.info("Contact %s avatar from %s is not an image", contact.id, source_key) + except Exception as error: # noqa: BLE001 - провайдер/сеть, деградация мягкая + logger.info("Contact %s avatar download failed: %s", contact.id, error) diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index 997c05d..0eab175 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -17,6 +17,7 @@ from chatballs.ai.provider.base import ProviderError from chatballs.ai.runtime import HANDOFF_TOKEN from chatballs.channels.runtime import run_channel_turn from chatballs.conversations import transports +from chatballs.conversations.contact_avatars import refresh_contact_avatar from chatballs.conversations.models import ( ConnectionIdentity, Contact, @@ -177,13 +178,27 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: context = TenantContext.for_resource(channel.organization) agent = getattr(channel, "ai_agent", None) ai_available = bool(agent and agent.is_active) + if not ai_available: + # Частая причина «диалог сразу ждёт оператора»: у канала подключения нет + # агента или он не активен. В журнале это должно быть видно одной + # строкой, иначе настройку ищут перебором. + logger.info( + "Channel %s has no active AI agent (agent=%s) — conversation goes to the operator queue", + channel.id, + getattr(agent, "status", None), + ) source = f"{integration.provider.lower()}:{integration.id}" if _already_processed(context, source, inbound.external_id, inbound.text): return # Явный шаринг контакта: сообщение без текста, но с телефоном. is_contact_share = bool(inbound.phone) - is_voice = bool(inbound.voice_file_id or inbound.voice_url or inbound.voice_content) + is_voice = bool( + inbound.voice_file_id + or inbound.voice_url + or inbound.voice_content + or inbound.voice_unavailable + ) files = tuple(inbound.files or ()) # Файлы без текста: сообщение-контейнер не создаём, каждый файл — своя реплика. files_only = bool(files) and not inbound.text and not is_contact_share and not is_voice @@ -224,11 +239,12 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: if identity.phone_verified_at is None: identity.phone_verified_at = timezone.now() identity.save(update_fields=["phone_verified_at"]) - # Аватар обновляем при каждом заходе: провайдер может сменить фото, - # а контакт ещё не шарил телефон (is_contact_share=False). + # Адрес фото у провайдера храним как было, но показываем оператору не + # его: страница под CSP `img-src 'self'` чужую картинку не покажет. if inbound.avatar_url and contact.avatar_url != inbound.avatar_url: contact.avatar_url = inbound.avatar_url contact.save(update_fields=["avatar_url"]) + refresh_contact_avatar(integration, inbound, contact) conversation = ( Conversation.objects.filter(channel=channel, contact=contact, lifecycle=LifecycleState.OPEN) diff --git a/apps/backend/chatballs/conversations/migrations/0025_contact_avatar_contact_avatar_content_type_and_more.py b/apps/backend/chatballs/conversations/migrations/0025_contact_avatar_contact_avatar_content_type_and_more.py new file mode 100644 index 0000000..0bb053e --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0025_contact_avatar_contact_avatar_content_type_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.16 on 2026-09-14 21:32 + +import chatballs.conversations.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0024_queue_policy_author'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='avatar', + field=models.FileField(blank=True, default='', max_length=512, upload_to=chatballs.conversations.models.contact_avatar_upload_path), + ), + migrations.AddField( + model_name='contact', + name='avatar_content_type', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='contact', + name='avatar_source', + field=models.CharField(blank=True, default='', max_length=512), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 5f21e2f..6c6ceda 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -11,6 +11,14 @@ from chatballs.tenancy.models import TenantRelationModel # разделено на независимые оси; перехват оператором — атомарный. +def contact_avatar_upload_path(instance: "Contact", filename: str) -> str: + import uuid + from pathlib import Path + + suffix = Path(filename).suffix.lower()[:8] or ".jpg" + return f"organizations/{instance.organization.public_id}/contacts/{uuid.uuid4().hex}{suffix}" + + class Contact(models.Model): organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts") name = models.CharField(max_length=255, blank=True) @@ -22,6 +30,16 @@ class Contact(models.Model): # getUpdates, поэтому для него поле остаётся пустым. Хранится только URL — # само изображение живёт на стороне провайдера. avatar_url = models.URLField(max_length=512, blank=True, default="") + # Фото контакта, скачанное у провайдера и лежащее у нас. Внешней ссылкой + # обойтись нельзя: страница рабочего места живёт под CSP `img-src 'self'`, + # и картинка с чужого домена до экрана не доезжает — оператор видит + # инициалы вместо фото. Источник запоминается, чтобы не качать то же самое + # на каждое сообщение. + avatar = models.FileField( + upload_to=contact_avatar_upload_path, max_length=512, blank=True, default="" + ) + avatar_content_type = models.CharField(max_length=64, blank=True, default="") + avatar_source = models.CharField(max_length=512, blank=True, default="") # Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город — # заполняет оператор. description = models.TextField(blank=True, default="") diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index c2ddac9..338869c 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.http import FileResponse from rest_framework.request import Request from rest_framework.response import Response @@ -21,6 +22,29 @@ class ConversationStatsView(ConversationViewBase): return Response(sales_overview_stats(request.tenant_context, period)) +class ContactAvatarView(ConversationViewBase): + """Фото контакта, скачанное у провайдера. + + Отдаётся со своего адреса: страница рабочего места живёт под CSP + `img-src 'self'`, и ссылка на CDN мессенджера до экрана не доезжает. + Видно тому же, кто видит диалоги, — фото клиента и есть часть карточки. + """ + + def get(self, request: Request, contact_id: int): + contact = Contact.objects.filter( + organization=self._org(request), id=contact_id + ).first() + if contact is None or not contact.avatar: + return Response({"detail": t("sales.client_not_found")}, status=404) + response = FileResponse( + contact.avatar.open("rb"), + content_type=contact.avatar_content_type or "application/octet-stream", + filename="avatar", + ) + response["Cache-Control"] = "private, max-age=86400" + return response + + class ClientsView(ConversationViewBase): required_capability = "customers.view" diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index afc3776..81acb2e 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -8,6 +8,7 @@ from chatballs.conversations.models import ( MessageKind, ) from chatballs.i18n import t +from chatballs.conversations.contact_avatars import contact_avatar_url_in from chatballs.identity.avatars import user_avatar_url_in from chatballs.integrations.features import features_payload from chatballs.integrations.models import IntegrationProvider @@ -228,7 +229,9 @@ def conversation_payload( "id": conversation.contact_id, "name": conversation.contact.name, "phone": conversation.contact.phone, - "avatarUrl": conversation.contact.avatar_url, + "avatarUrl": contact_avatar_url_in( + conversation.contact, conversation.organization_id + ), "description": conversation.contact.description, "company": conversation.contact.company, "city": conversation.contact.city, diff --git a/apps/backend/chatballs/conversations/test_contact_avatars.py b/apps/backend/chatballs/conversations/test_contact_avatars.py new file mode 100644 index 0000000..e58b817 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_contact_avatars.py @@ -0,0 +1,137 @@ +"""Фото контакта из мессенджера: скачивается один раз и отдаётся со своего адреса.""" + +from unittest import mock + +from django.test import TestCase + +from chatballs.channels.models import Channel +from chatballs.conversations.contact_avatars import ( + contact_avatar_url_in, + refresh_contact_avatar, +) +from chatballs.conversations.models import Contact +from chatballs.conversations.transports.base import InboundMessage +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import Organization +from chatballs.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, +) +from chatballs.tenancy.database import tenant_atomic +from chatballs.testing import TenantAPIClient as APIClient + +PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 64 + + +class ContactAvatarTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + self.contact = Contact.objects.create( + organization=self.organization, name="Иван" + ) + self.inbound = InboundMessage( + external_id="1", + user_id="777", + chat_id="777", + text="привет", + display_name="Иван", + ) + + def _integration(self, provider: str) -> Integration: + return Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=provider, + name=provider, + secret="token", + channel=self.channel, + ) + + def test_telegram_photo_is_asked_once_and_stored(self) -> None: + integration = self._integration(IntegrationProvider.TELEGRAM) + with mock.patch( + "chatballs.conversations.transports.telegram.download_profile_photo", + return_value=PNG, + ) as download: + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + self.assertEqual(download.call_count, 1) + self.contact.refresh_from_db() + self.assertTrue(self.contact.avatar) + self.assertEqual(self.contact.avatar_content_type, "image/png") + self.assertEqual(self.contact.avatar_source, "tg:777") + + def test_telegram_without_photo_is_not_asked_again(self) -> None: + integration = self._integration(IntegrationProvider.TELEGRAM) + with mock.patch( + "chatballs.conversations.transports.telegram.download_profile_photo", + return_value=None, + ) as download: + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + self.assertEqual(download.call_count, 1) + self.contact.refresh_from_db() + self.assertFalse(self.contact.avatar) + + def test_max_photo_follows_the_url_from_the_update(self) -> None: + integration = self._integration(IntegrationProvider.MAX) + inbound = InboundMessage( + external_id="1", + user_id="777", + chat_id="", + text="привет", + display_name="Иван", + avatar_url="https://cdn.example.test/ivan.png", + ) + with mock.patch( + "chatballs.conversations.transports.max.download_file", + return_value=(PNG, "image/png"), + ) as download: + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, inbound, self.contact) + with tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, inbound, self.contact) + self.assertEqual(download.call_count, 1) + self.contact.refresh_from_db() + self.assertEqual(self.contact.avatar_source, "https://cdn.example.test/ivan.png") + + def test_download_failure_leaves_the_contact_alone(self) -> None: + integration = self._integration(IntegrationProvider.TELEGRAM) + with mock.patch( + "chatballs.conversations.transports.telegram.download_profile_photo", + side_effect=OSError("network is down"), + ), tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + self.contact.refresh_from_db() + self.assertFalse(self.contact.avatar) + self.assertEqual(self.contact.avatar_source, "") + + def test_stored_photo_is_served_from_our_own_address(self) -> None: + integration = self._integration(IntegrationProvider.TELEGRAM) + with mock.patch( + "chatballs.conversations.transports.telegram.download_profile_photo", + return_value=PNG, + ), tenant_atomic(self.organization.id): + refresh_contact_avatar(integration, self.inbound, self.contact) + self.contact.refresh_from_db() + url = contact_avatar_url_in(self.contact, self.organization.id) + self.assertIn(f"/conversations/clients/{self.contact.id}/avatar/", url) + + client = APIClient() + client.login(username="owner@example.com", password="temporary-password") + response = client.get(f"/api/v1/conversations/clients/{self.contact.id}/avatar/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "image/png") + self.assertEqual(b"".join(response.streaming_content), PNG) + + def test_contact_without_photo_has_no_url(self) -> None: + self.assertIsNone(contact_avatar_url_in(self.contact, self.organization.id)) diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index 5e97821..d8517f3 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -168,6 +168,51 @@ class TransportNormalizeTests(TestCase): self.assertEqual(inbound.phone, "") self.assertEqual(inbound.text, "Здравствуйте") + def test_max_voice_attachment_becomes_a_voice_message(self) -> None: + update = { + "update_type": "message_created", + "message": { + "sender": {"user_id": 42, "name": "Мария"}, + "recipient": {"chat_id": 100}, + "body": { + "mid": "m-3", + "text": "", + "attachments": [ + { + "type": "audio", + "duration": 7, + "payload": {"url": "https://cdn.example.test/voice.ogg"}, + } + ], + }, + }, + } + inbound = max_transport._normalize(update) + self.assertIsNotNone(inbound) + self.assertEqual(inbound.voice_url, "https://cdn.example.test/voice.ogg") + self.assertEqual(inbound.voice_duration, 7) + self.assertFalse(inbound.voice_unavailable) + + def test_max_voice_without_url_still_reaches_the_operator(self) -> None: + # Форма вложения у MAX описана неполно. Незнакомая — не повод молча + # терять реплику клиента: оператор увидит её заглушкой. + update = { + "update_type": "message_created", + "message": { + "sender": {"user_id": 42, "name": "Мария"}, + "recipient": {"chat_id": 100}, + "body": { + "mid": "m-4", + "text": "", + "attachments": [{"type": "audio", "payload": {"token": "abc"}}], + }, + }, + } + inbound = max_transport._normalize(update) + self.assertIsNotNone(inbound) + self.assertTrue(inbound.voice_unavailable) + self.assertEqual(inbound.voice_url, "") + class ContactShareIngestTests(TestCase): """Шаринг контакта: телефон сохраняется в Contact, AI-ход не запускается, diff --git a/apps/backend/chatballs/conversations/transports/__init__.py b/apps/backend/chatballs/conversations/transports/__init__.py index 59a6dd1..f2461e4 100644 --- a/apps/backend/chatballs/conversations/transports/__init__.py +++ b/apps/backend/chatballs/conversations/transports/__init__.py @@ -154,6 +154,36 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena ) +# Фото контакта (chatballs.conversations.contact_avatars): MAX отдаёт адрес в +# профиле отправителя, Telegram — только по отдельному запросу к API. + + +def avatar_source(integration, inbound) -> str: + """Чем определяется «то же самое фото» у этого провайдера. + + Пока значение не изменилось, фото не перекачивается. У MAX это адрес из + апдейта, у Telegram — сам отправитель: апдейт про фото ничего не говорит, + и спрашивать API на каждое сообщение было бы расточительством. + """ + if integration.provider == IntegrationProvider.TELEGRAM: + return f"tg:{inbound.user_id}" if inbound.user_id else "" + return inbound.avatar_url + + +def download_avatar(integration, inbound) -> tuple[bytes, str] | None: + """Фото отправителя: (байты, ключ источника). None — фото у провайдера нет.""" + source = avatar_source(integration, inbound) + if not source: + return None + if integration.provider == IntegrationProvider.TELEGRAM: + content = _telegram.download_profile_photo(integration, inbound.user_id) + return (content, source) if content else None + if integration.provider == IntegrationProvider.MAX and inbound.avatar_url: + content, _content_type = _max.download_file(integration, inbound.avatar_url, "") + return (content, source) if content else None + return None + + def download_voice(integration, inbound) -> tuple[bytes, str]: if inbound.voice_content: return inbound.voice_content, inbound.voice_mime or "audio/webm" diff --git a/apps/backend/chatballs/conversations/transports/base.py b/apps/backend/chatballs/conversations/transports/base.py index f95b058..cc03b12 100644 --- a/apps/backend/chatballs/conversations/transports/base.py +++ b/apps/backend/chatballs/conversations/transports/base.py @@ -60,6 +60,10 @@ class InboundMessage: voice_content: bytes = b"" voice_duration: int = 0 voice_mime: str = "" + # Голосовое в сообщении есть, но источника для скачивания провайдер не дал + # (незнакомая форма вложения). Сообщение всё равно доезжает до оператора + # заглушкой: молча терять реплику клиента нельзя. + voice_unavailable: bool = False # Файлы и фото: каждый становится отдельным сообщением kind=file. files: tuple[InboundFile, ...] = field(default_factory=tuple) diff --git a/apps/backend/chatballs/conversations/transports/max.py b/apps/backend/chatballs/conversations/transports/max.py index 3193f22..11c8222 100644 --- a/apps/backend/chatballs/conversations/transports/max.py +++ b/apps/backend/chatballs/conversations/transports/max.py @@ -109,9 +109,10 @@ def _normalize(update: dict) -> InboundMessage | None: chat_id = first(recipient, "chat_id", "chatId") external_id = first(inner, "mid", "msgId", "seq") or first(update, "update_id", "updateId", "timestamp") phone = _contact_phone(inner, msg) - voice_url, voice_duration = _voice_attachment(inner) + voice_url, voice_duration, voice_unavailable = _voice_attachment(inner) files = _file_attachments(inner) - if (not text and not phone and not voice_url and not files) or user_id is None or external_id is None: + has_voice = bool(voice_url) or voice_unavailable + if (not text and not phone and not has_voice and not files) or user_id is None or external_id is None: return None return InboundMessage( external_id=str(external_id), @@ -125,6 +126,7 @@ def _normalize(update: dict) -> InboundMessage | None: voice_url=voice_url, voice_duration=voice_duration, voice_mime="audio/ogg" if voice_url else "", + voice_unavailable=voice_unavailable, files=files, ) @@ -221,15 +223,29 @@ def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filena return False -def _voice_attachment(inner: dict) -> tuple[str, int]: - """Голосовое/аудио-вложение MAX: payload.url для скачивания.""" +def _voice_attachment(inner: dict) -> tuple[str, int, bool]: + """Голосовое/аудио-вложение MAX: адрес для скачивания и длительность. + + Третье значение — «вложение было, а адреса в нём нет». Форма вложения у MAX + описана неполно, и раньше такое сообщение уходило в никуда: текста в нём + нет, адреса нет — normalize возвращал None, и реплика клиента просто + пропадала. Теперь оператор видит её заглушкой, а в журнале остаётся сам + payload, по которому разбирают форму. + """ for attachment in inner.get("attachments") or []: - if attachment.get("type") in ("audio", "voice"): - payload = attachment.get("payload") or {} - url = str(first(payload, "url", "download_url", default="")) - if url: - return url, int(first(attachment, "duration", default=0) or payload.get("duration") or 0) - return "", 0 + if attachment.get("type") not in ("audio", "voice"): + continue + payload = attachment.get("payload") or {} + duration = int(first(attachment, "duration", default=0) or payload.get("duration") or 0) + url = str(first(payload, "url", "download_url", default="")) + if url: + return url, duration, False + logger.warning( + "MAX voice attachment without a download url: %s", + json.dumps(attachment, ensure_ascii=False), + ) + return "", duration, True + return "", 0, False def poll_updates(integration) -> tuple[list[InboundMessage], str]: diff --git a/apps/backend/chatballs/conversations/transports/telegram.py b/apps/backend/chatballs/conversations/transports/telegram.py index 2b00bbc..0761cfb 100644 --- a/apps/backend/chatballs/conversations/transports/telegram.py +++ b/apps/backend/chatballs/conversations/transports/telegram.py @@ -180,6 +180,33 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard}) +def download_profile_photo(integration, user_id: str) -> bytes | None: + """Фото профиля отправителя: getUserProfilePhotos -> file_id -> getFile. + + Telegram не кладёт фото в апдейт, поэтому его спрашивают отдельно — один + раз на контакт (chatballs.conversations.contact_avatars). None — фото у + человека нет или оно закрыто настройками приватности. + """ + token = integration.secret + if not token or not user_id: + return None + data = request_json( + f"{_base(integration)}/bot{token}/getUserProfilePhotos?user_id={user_id}&limit=1", + proxy_url=_proxy(integration), + ) + photos = ((data.get("result") or {}).get("photos") or []) if data.get("ok") else [] + if not photos or not photos[0]: + return None + # Размеры идут от меньшего к большему; аватар рисуется 40-64px, поэтому + # средний размер лучше самого большого — он в разы легче. + sizes = [size for size in photos[0] if size.get("file_id")] + if not sizes: + return None + chosen = sizes[min(1, len(sizes) - 1)] + content, _content_type = download_file(integration, str(chosen["file_id"])) + return content + + def download_file(integration, file_id: str) -> tuple[bytes, str]: """Скачивание файла по file_id: getFile -> file_path -> /file/bot/.""" token = integration.secret diff --git a/apps/backend/chatballs/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py index 7b09392..8838be6 100644 --- a/apps/backend/chatballs/conversations/urls.py +++ b/apps/backend/chatballs/conversations/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("stats/", reporting_views.ConversationStatsView.as_view(), name="conversation-stats"), path("clients/", reporting_views.ClientsView.as_view(), name="conversation-clients"), path("clients//", reporting_views.ClientDetailView.as_view(), name="conversation-client-detail"), + path("clients//avatar/", reporting_views.ContactAvatarView.as_view(), name="conversation-client-avatar"), path("clients//merge/", reporting_views.ClientMergeView.as_view(), name="conversation-client-merge"), path("/", views.ConversationDetailView.as_view(), name="conversation-detail"), path("/claim/", views.ConversationClaimView.as_view(), name="conversation-claim"), diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx index 82c1fa3..06a083e 100644 --- a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -63,11 +63,20 @@ export function AudioCallOverlay(props: Props) { : builtStatus; const onAccept = () => { void rtc.start(); }; + // «Завершить» обязано сработать всегда. Токен доступа есть не в каждом + // состоянии (карточку звонка могли открыть из списка), а сам запрос может + // упасть — тогда звонок заканчивается вторым путём, по сессии оператора. const finish = async () => { + if (!call || isTerminalCallStatus(call.status)) return; const token = props.access?.accessToken; - if (!token || !call || isTerminalCallStatus(call.status)) return; - try { props.onCallChange(await endCallByAccess(token)); } - finally { rtc.stop(); } + try { + if (token) props.onCallChange(await endCallByAccess(token)); + else props.onCancel(); + } catch { + props.onCancel(); + } finally { + rtc.stop(); + } }; const onEnd = () => { void finish(); }; const endAndClose = async () => { diff --git a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx index 986f90b..becc8a4 100644 --- a/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/VideoCallOverlay.tsx @@ -66,11 +66,19 @@ export function VideoCallOverlay(props: Props) { const channel = providerMeta[props.dialog.channel]; const subtitle = subtitleFor(mode, call, status); const mediaCaption = rtc.mediaIssue === "devices" ? t("conversations.no_access_camera_microphone") : rtc.mediaIssue === "video" ? t("conversations.camera_unavailable") : t("conversations.camera_off"); + // «Завершить» обязано сработать всегда: нет токена доступа или запрос не + // прошёл — звонок заканчивается вторым путём, по сессии оператора. const finish = async () => { + if (!call || TERMINAL[call.status]) return; const token = props.access?.accessToken; - if (!token || !call || TERMINAL[call.status]) return; - try { props.onCallChange(await endCallByAccess(token)); } - finally { rtc.stop(); } + try { + if (token) props.onCallChange(await endCallByAccess(token)); + else props.onCancel(); + } catch { + props.onCancel(); + } finally { + rtc.stop(); + } }; const endAndClose = async () => { if (mode === "active" || mode === "reconnecting" || mode === "connecting" || mode === "precall") await finish();