diff --git a/.env.example b/.env.example index 4cbd949..65d528e 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ REDIS_URL=redis://redis:6379/0 HUB_CALL_INVITE_TTL_SECONDS=300 HUB_CALL_ACCESS_TTL_SECONDS=3600 HUB_CALL_CONNECT_GRACE_SECONDS=120 +HUB_CALL_RECONNECT_GRACE_SECONDS=60 +# STUN для WebRTC (через запятую); TURN добавит контур Coturn. +HUB_CALL_STUN_URLS= INTERNAL_UI_PORT=5173 WEB_CHAT_PORT=5175 diff --git a/.env.production.example b/.env.production.example index 5a90c93..40cbae4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -34,6 +34,9 @@ REDIS_URL=redis://redis:6379/0 HUB_CALL_INVITE_TTL_SECONDS=300 HUB_CALL_ACCESS_TTL_SECONDS=3600 HUB_CALL_CONNECT_GRACE_SECONDS=120 +HUB_CALL_RECONNECT_GRACE_SECONDS=60 +# STUN для WebRTC (через запятую); TURN добавит контур Coturn. +HUB_CALL_STUN_URLS= HUB_AI_PROVIDER=openrouter HUB_OPENROUTER_API_KEY= diff --git a/apps/backend/hub_backend/asgi.py b/apps/backend/hub_backend/asgi.py index 48ee236..a1fc313 100644 --- a/apps/backend/hub_backend/asgi.py +++ b/apps/backend/hub_backend/asgi.py @@ -4,4 +4,18 @@ from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") -application = get_asgi_application() +# HTTP-приложение инициализируется до импорта consumer'ов (django.setup). +django_asgi_app = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + +from hub_platform.calls.routing import websocket_urlpatterns # noqa: E402 + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + # Собственный signaling звонков (SPEC-HUB-0013 §9). Аутентификация — + # первым сообщением по call access token, Django-сессия не нужна. + "websocket": URLRouter(websocket_urlpatterns), + } +) diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/hub_backend/settings.py index a5adb32..84b2633 100644 --- a/apps/backend/hub_backend/settings.py +++ b/apps/backend/hub_backend/settings.py @@ -57,6 +57,9 @@ INSTALLED_APPS = [ "hub_platform.events", "hub_platform.support", "hub_platform.calls", + # django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels» + # конфликтует с доменным hub_platform.channels, а без runserver-оверрайда + # (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS. ] MIDDLEWARE = [ @@ -113,6 +116,19 @@ CACHES = { } } +# Signaling звонков: Redis только fan-out/presence, source of truth lifecycle — +# PostgreSQL (SPEC-HUB-0013 §9). В тестах — InMemory layer. +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } + if TESTING + else { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [os.environ.get("REDIS_URL", "redis://redis:6379/0")]}, + } +} + AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 10}}, @@ -186,8 +202,19 @@ HUB_CALL_INVITE_TTL_SECONDS = int(os.environ.get("HUB_CALL_INVITE_TTL_SECONDS", HUB_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("HUB_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) # Grace period: принятый звонок без установленного соединения закрывается FAILED. HUB_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_CONNECT_GRACE_SECONDS", str(2 * 60))) -if HUB_CALL_INVITE_TTL_SECONDS <= 0 or HUB_CALL_ACCESS_TTL_SECONDS <= 0 or HUB_CALL_CONNECT_GRACE_SECONDS <= 0: +# Grace period восстановления активного звонка после обрыва участника. +HUB_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_RECONNECT_GRACE_SECONDS", str(60))) +if ( + HUB_CALL_INVITE_TTL_SECONDS <= 0 + or HUB_CALL_ACCESS_TTL_SECONDS <= 0 + or HUB_CALL_CONNECT_GRACE_SECONDS <= 0 + or HUB_CALL_RECONNECT_GRACE_SECONDS <= 0 +): raise ImproperlyConfigured("HUB call token TTL values must be positive") + +# ICE-серверы для WebRTC (SPEC-HUB-0013 §10): STUN сейчас, TURN добавит контур +# Coturn. Формат: URL через запятую (stun:host:port). +HUB_CALL_STUN_URLS = env_list("HUB_CALL_STUN_URLS", []) STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", diff --git a/apps/backend/hub_platform/calls/consumers.py b/apps/backend/hub_platform/calls/consumers.py new file mode 100644 index 0000000..b1f2910 --- /dev/null +++ b/apps/backend/hub_platform/calls/consumers.py @@ -0,0 +1,130 @@ +"""WebSocket signaling звонков (SPEC-HUB-0013 §9). + +Правила: +- аутентификация первым сообщением {"type": "auth", "token": } + — token не попадает в URL, логи и историю прокси; +- в комнате только два разрешённых участника конкретной CallSession; +- SDP и ICE не сохраняются и не логируются (payload событий не пишется в логи); +- поздние события завершённого звонка игнорируются; +- reconnect WebSocket не создаёт новую CallSession — только presence-статусы. +""" + +import logging + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from hub_platform.calls import signaling +from hub_platform.calls.errors import CallTokenError +from hub_platform.calls.models import TERMINAL_CALL_STATUSES +from hub_platform.calls.services import authorize_call_access_token + +logger = logging.getLogger(__name__) + +AUTH_TIMEOUT_CLOSE = 4401 +# События, которые сервер только ретранслирует второму участнику. +RELAY_TYPES = {"webrtc.offer", "webrtc.answer", "webrtc.ice_candidate", "participant.media_state"} +SEEN_COMMANDS_LIMIT = 512 + + +class CallSignalingConsumer(AsyncJsonWebsocketConsumer): + async def connect(self) -> None: + self.call_id = None + self.side = None + self.group = None + self._seen_commands: set[str] = set() + await self.accept() + + async def receive_json(self, content: dict, **kwargs) -> None: + msg_type = content.get("type") + if self.call_id is None: + await self._authenticate(msg_type, content) + return + + # Идемпотентность command-событий: повтор с тем же id игнорируется. + command_id = content.get("id") + if isinstance(command_id, str) and command_id: + if command_id in self._seen_commands: + return + if len(self._seen_commands) >= SEEN_COMMANDS_LIMIT: + self._seen_commands.clear() + self._seen_commands.add(command_id) + + if msg_type in RELAY_TYPES: + await self._relay(msg_type, content) + elif msg_type == "participant.connection_state": + await self._connection_state(content) + elif msg_type == "call.ended": + payload = await database_sync_to_async(signaling.end_from_signaling)(self.call_id, self.side) + await self._broadcast({"type": "call.state", "call": payload}, include_self=True) + # незнакомые типы игнорируются без разрыва соединения + + async def disconnect(self, code: int) -> None: + if self.group is None: + return + await self.channel_layer.group_discard(self.group, self.channel_name) + payload = await database_sync_to_async(signaling.signaling_leave)(self.call_id, self.side) + if payload is not None: + await self._broadcast( + {"type": "participant.connection_state", "side": self.side, "state": "DISCONNECTED"}, + ) + + # --- шаги протокола --- + + async def _authenticate(self, msg_type, content: dict) -> None: + if msg_type != "auth": + await self.close(code=AUTH_TIMEOUT_CLOSE) + return + token = str(content.get("token", "")) + try: + claims, call = await database_sync_to_async( + lambda: authorize_call_access_token(token=token) + )() + except CallTokenError: + await self.send_json({"type": "error", "code": "AUTH_FAILED"}) + await self.close(code=AUTH_TIMEOUT_CLOSE) + return + self.call_id = call.id + self.side = claims.side + self.group = f"call.{call.id}" + await self.channel_layer.group_add(self.group, self.channel_name) + payload = await database_sync_to_async(signaling.signaling_join)(self.call_id, self.side) + await self.send_json({"type": "call.state", "call": payload}) + await self._broadcast({"type": "peer.joined", "side": self.side}) + logger.info("call signaling joined: call=%s side=%s", self.call_id, self.side) + + async def _relay(self, msg_type: str, content: dict) -> None: + # Сервер валидирует состояние CallSession перед передачей (SPEC §9); + # payload не логируется — там SDP/ICE. + status = await database_sync_to_async(signaling.call_status)(self.call_id) + if status in TERMINAL_CALL_STATUSES: + return + if msg_type == "webrtc.offer": + changed = await database_sync_to_async(signaling.start_negotiation)(self.call_id) + if changed is not None: + await self._broadcast({"type": "call.state", "call": changed}, include_self=True) + await self._broadcast({**content, "side": self.side}) + + async def _connection_state(self, content: dict) -> None: + connected = content.get("state") == "CONNECTED" + payload, became_active = await database_sync_to_async(signaling.report_connection)( + self.call_id, self.side, connected + ) + await self._broadcast( + {"type": "participant.connection_state", "side": self.side, "state": str(content.get("state", ""))[:16]}, + ) + if became_active and payload is not None: + await self._broadcast({"type": "call.state", "call": payload}, include_self=True) + + # --- fan-out --- + + async def _broadcast(self, payload: dict, *, include_self: bool = False) -> None: + await self.channel_layer.group_send( + self.group, + {"type": "call.message", "payload": payload, "sender": self.channel_name, "include_self": include_self}, + ) + + async def call_message(self, event: dict) -> None: + if not event.get("include_self") and event.get("sender") == self.channel_name: + return + await self.send_json(event["payload"]) diff --git a/apps/backend/hub_platform/calls/maintenance.py b/apps/backend/hub_platform/calls/maintenance.py index a1bd405..17ef43a 100644 --- a/apps/backend/hub_platform/calls/maintenance.py +++ b/apps/backend/hub_platform/calls/maintenance.py @@ -46,4 +46,27 @@ def expire_stale_calls() -> int: finished += 1 except CallInvalidTransition: continue + + # Активный звонок с участником, не восстановившимся после обрыва (SPEC §5: + # временный обрыв → reconnecting, после grace period — FAILED). + reconnect_deadline = now - timedelta(seconds=settings.HUB_CALL_RECONNECT_GRACE_SECONDS) + dropped = ( + CallSession.objects.filter( + status=CallStatus.ACTIVE, + participants__left_at__lte=reconnect_deadline, + ) + .distinct() + .values_list("id", flat=True) + ) + for call_id in dropped: + try: + transition_call( + call_session_id=call_id, + target_status=CallStatus.FAILED, + ended_by=CallEndedBy.TIMEOUT, + failure_code="PEER_DISCONNECTED", + ) + finished += 1 + except CallInvalidTransition: + continue return finished diff --git a/apps/backend/hub_platform/calls/routing.py b/apps/backend/hub_platform/calls/routing.py new file mode 100644 index 0000000..4d90829 --- /dev/null +++ b/apps/backend/hub_platform/calls/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from hub_platform.calls.consumers import CallSignalingConsumer + +websocket_urlpatterns = [ + path("ws/calls/", CallSignalingConsumer.as_asgi()), +] diff --git a/apps/backend/hub_platform/calls/serializers.py b/apps/backend/hub_platform/calls/serializers.py index 7090530..1221164 100644 --- a/apps/backend/hub_platform/calls/serializers.py +++ b/apps/backend/hub_platform/calls/serializers.py @@ -1,3 +1,5 @@ +from django.conf import settings + from hub_platform.calls.models import CallSession @@ -46,6 +48,14 @@ def _staff_label(call: CallSession) -> str: return getattr(call.initiated_by, "full_name", "") or "Оператор" +def ice_servers_payload() -> list[dict]: + # ICE-конфигурация клиента (SPEC §10): STUN организации; краткоживущие + # TURN credentials добавит контур Coturn. + if not settings.HUB_CALL_STUN_URLS: + return [] + return [{"urls": list(settings.HUB_CALL_STUN_URLS)}] + + def public_call_state_payload(call: CallSession) -> dict: # Клиентская страница звонка: только lifecycle, без внутренних ID и данных диалога. return { diff --git a/apps/backend/hub_platform/calls/signaling.py b/apps/backend/hub_platform/calls/signaling.py new file mode 100644 index 0000000..fb9aede --- /dev/null +++ b/apps/backend/hub_platform/calls/signaling.py @@ -0,0 +1,148 @@ +"""Доменные операции WebSocket-signaling (SPEC-HUB-0013 §9). + +Вызываются consumer'ом через database_sync_to_async и возвращают готовые +payload-словари: ORM не утекает в async-контекст. Source of truth lifecycle — +PostgreSQL; Redis (channel layer) — только fan-out и presence. +""" + +from django.utils import timezone + +from hub_platform.calls.errors import CallInvalidTransition +from hub_platform.calls.lifecycle import transition_call +from hub_platform.calls.models import ( + CallEndedBy, + CallParticipant, + CallSession, + CallStatus, + ParticipantConnectionState, + ParticipantSide, + TERMINAL_CALL_STATUSES, +) +from hub_platform.calls.serializers import public_call_state_payload + +SIDE_TO_ENDED_BY = { + ParticipantSide.STAFF: CallEndedBy.STAFF, + ParticipantSide.CUSTOMER: CallEndedBy.CUSTOMER, +} + + +def _call(call_id) -> CallSession: + return CallSession.objects.select_related("initiated_by").get(id=call_id) + + +def call_state(call_id) -> dict: + return public_call_state_payload(_call(call_id)) + + +def call_status(call_id) -> str: + return CallSession.objects.values_list("status", flat=True).get(id=call_id) + + +def _set_participant(call_id, side: str, *, state: str, joined: bool | None = None) -> None: + updates: dict = {"last_connection_state": state} + if joined is True: + updates["joined_at"] = timezone.now() + updates["left_at"] = None + if joined is False: + updates["left_at"] = timezone.now() + if state == ParticipantConnectionState.CONNECTED: + # Восстановление после reconnect: сбрасываем таймер grace period. + updates["left_at"] = None + CallParticipant.objects.filter(call_session_id=call_id, side=side).update(**updates) + + +def signaling_join(call_id, side: str) -> dict: + """Участник открыл signaling-соединение: presence + состояние звонка.""" + call = _call(call_id) + if call.status not in TERMINAL_CALL_STATUSES: + participant = CallParticipant.objects.filter(call_session_id=call_id, side=side).first() + if participant is not None: + participant.last_connection_state = ParticipantConnectionState.CONNECTING + if participant.joined_at is None: + participant.joined_at = timezone.now() + participant.left_at = None + participant.save(update_fields=["last_connection_state", "joined_at", "left_at"]) + return public_call_state_payload(call) + + +def signaling_leave(call_id, side: str) -> dict | None: + """Разрыв WebSocket: participant DISCONNECTED, grace-таймер запускается. + + Возвращает None для завершённого звонка — уведомлять уже некого. + """ + call = _call(call_id) + if call.status in TERMINAL_CALL_STATUSES: + return None + _set_participant(call_id, side, state=ParticipantConnectionState.DISCONNECTED, joined=False) + return public_call_state_payload(call) + + +def start_negotiation(call_id) -> dict | None: + """Первый SDP offer: ACCEPTED → CONNECTING. Возвращает payload при переходе.""" + if call_status(call_id) != CallStatus.ACCEPTED: + return None + try: + call = transition_call(call_session_id=call_id, target_status=CallStatus.CONNECTING) + except CallInvalidTransition: + return None + return public_call_state_payload(call) + + +def report_connection(call_id, side: str, connected: bool) -> tuple[dict | None, bool]: + """Участник сообщил состояние WebRTC-соединения. + + ACTIVE устанавливается только когда обе стороны подтвердили соединение + (SPEC §5). Возвращает (payload при смене статуса звонка, became_active). + """ + _set_participant( + call_id, + side, + state=ParticipantConnectionState.CONNECTED if connected else ParticipantConnectionState.RECONNECTING, + joined=False if not connected else None, + ) + if not connected: + return None, False + status = call_status(call_id) + if status not in {CallStatus.ACCEPTED, CallStatus.CONNECTING}: + return None, False + both_connected = ( + CallParticipant.objects.filter( + call_session_id=call_id, + last_connection_state=ParticipantConnectionState.CONNECTED, + ).count() + == 2 + ) + if not both_connected: + return None, False + try: + if status == CallStatus.ACCEPTED: + transition_call(call_session_id=call_id, target_status=CallStatus.CONNECTING) + call = transition_call(call_session_id=call_id, target_status=CallStatus.ACTIVE) + except CallInvalidTransition: + return None, False + return public_call_state_payload(call), True + + +def end_from_signaling(call_id, side: str) -> dict: + """Завершение звонка стороной: идемпотентно, целевой статус — по фазе.""" + ended_by = SIDE_TO_ENDED_BY.get(side, CallEndedBy.SYSTEM) + call = _call(call_id) + if call.status in TERMINAL_CALL_STATUSES: + return public_call_state_payload(call) + try: + if call.status == CallStatus.ACTIVE: + call = transition_call(call_session_id=call_id, target_status=CallStatus.ENDED, ended_by=ended_by) + elif call.status in {CallStatus.ACCEPTED, CallStatus.CONNECTING}: + call = transition_call( + call_session_id=call_id, + target_status=CallStatus.FAILED, + ended_by=ended_by, + failure_code="ABORTED_BEFORE_CONNECT", + ) + elif side == ParticipantSide.STAFF: + call = transition_call(call_session_id=call_id, target_status=CallStatus.CANCELLED, ended_by=ended_by) + else: + call = transition_call(call_session_id=call_id, target_status=CallStatus.DECLINED, ended_by=ended_by) + except CallInvalidTransition: + call = _call(call_id) + return public_call_state_payload(call) diff --git a/apps/backend/hub_platform/calls/tests/helpers.py b/apps/backend/hub_platform/calls/tests/helpers.py index 9d04688..b171feb 100644 --- a/apps/backend/hub_platform/calls/tests/helpers.py +++ b/apps/backend/hub_platform/calls/tests/helpers.py @@ -7,7 +7,10 @@ from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUse from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider -class CallTestCase(TestCase): +class CallDomainMixin: + """Общий домен тестов звонков. Отдельно от TestCase, чтобы signaling-тесты + могли использовать TransactionTestCase (consumer работает в потоках).""" + def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") self.organization = Organization.objects.get(slug="edevs") @@ -69,3 +72,7 @@ class CallTestCase(TestCase): connection=self.connection, contact=contact, ) + + +class CallTestCase(CallDomainMixin, TestCase): + pass diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index 6e68858..bf8d2e2 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -51,6 +51,7 @@ class InternalCallApiTests(CallTestCase): self.assertEqual(response.status_code, 200) claims = verify_call_access_token(response.json()["accessToken"]) self.assertEqual(claims.call_session_id, created.call_session.id) + self.assertIn("iceServers", response.json()) class PublicInviteApiTests(CallTestCase): diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/hub_platform/calls/tests/test_flow.py index 2461e30..f17ab8a 100644 --- a/apps/backend/hub_platform/calls/tests/test_flow.py +++ b/apps/backend/hub_platform/calls/tests/test_flow.py @@ -130,7 +130,9 @@ class CustomerAccessApiTests(CallTestCase): cancel_call(call_session=call, user=self.owner) response = self._post("/api/v1/calls/access/state/", token) self.assertEqual(response.status_code, 200) - payload = response.json()["call"] + body = response.json() + self.assertIn("iceServers", body) + payload = body["call"] self.assertEqual(payload["status"], CallStatus.CANCELLED) self.assertEqual(set(payload), {"callId", "status", "staffName", "endedBy", "durationSeconds"}) @@ -283,7 +285,7 @@ class ExpirySweepTests(CallTestCase): self.assertEqual(CallSession.objects.get().status, CallStatus.EXPIRED) def test_accepted_call_without_connection_fails_after_grace(self) -> None: - created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) + create_call_request(conversation_id=self.conversation.id, initiator=self.owner) token = open_call_for_identity(identity=self.identity).customer_access_token from hub_platform.calls.services import accept_call_by_access_token diff --git a/apps/backend/hub_platform/calls/tests/test_signaling.py b/apps/backend/hub_platform/calls/tests/test_signaling.py new file mode 100644 index 0000000..c37bd86 --- /dev/null +++ b/apps/backend/hub_platform/calls/tests/test_signaling.py @@ -0,0 +1,249 @@ +"""WebSocket signaling (проход B, SPEC-HUB-0013 §9): auth по access token, +relay только между участниками звонка, переходы CONNECTING/ACTIVE/ENDED, +reconnect без новой CallSession, поздние события игнорируются.""" + +from datetime import timedelta + +from asgiref.sync import async_to_sync +from channels.testing import WebsocketCommunicator +from django.test import TransactionTestCase +from django.utils import timezone + +from hub_backend.asgi import application +from hub_platform.calls.maintenance import expire_stale_calls +from hub_platform.calls.models import ( + CallParticipant, + CallSession, + CallStatus, + ParticipantConnectionState, + ParticipantSide, +) +from hub_platform.calls.services import ( + accept_call_by_access_token, + create_call_request, + open_call_for_identity, +) +from hub_platform.calls.tests.helpers import CallDomainMixin +from hub_platform.conversations.models import Message + +WS_PATH = "/ws/calls/" + + +class SignalingTestCase(CallDomainMixin, TransactionTestCase): + # TransactionTestCase: consumer выполняет ORM в отдельных потоках, поэтому + # транзакционная изоляция обычного TestCase не подходит. + def _make_accepted_call(self): + created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner) + customer_token = open_call_for_identity(identity=self.identity).customer_access_token + accept_call_by_access_token(token=customer_token) + return created, customer_token + + async def _connect(self, token: str) -> WebsocketCommunicator: + communicator = WebsocketCommunicator(application, WS_PATH) + connected, _ = await communicator.connect() + assert connected + await communicator.send_json_to({"type": "auth", "token": token}) + state = await communicator.receive_json_from() + assert state["type"] == "call.state", state + return communicator + + async def _drain_until(self, communicator: WebsocketCommunicator, msg_type: str, limit: int = 10) -> dict: + for _ in range(limit): + message = await communicator.receive_json_from() + if message["type"] == msg_type: + return message + raise AssertionError(f"no {msg_type} received") + + +class SignalingAuthTests(SignalingTestCase): + def test_invalid_token_is_rejected(self) -> None: + async def scenario(): + communicator = WebsocketCommunicator(application, WS_PATH) + connected, _ = await communicator.connect() + assert connected + await communicator.send_json_to({"type": "auth", "token": "garbage"}) + message = await communicator.receive_json_from() + self.assertEqual(message, {"type": "error", "code": "AUTH_FAILED"}) + await communicator.wait() + + async_to_sync(scenario)() + + def test_first_message_must_be_auth(self) -> None: + async def scenario(): + communicator = WebsocketCommunicator(application, WS_PATH) + await communicator.connect() + await communicator.send_json_to({"type": "webrtc.offer", "sdp": "x"}) + output = await communicator.receive_output() + self.assertEqual(output["type"], "websocket.close") + + async_to_sync(scenario)() + + def test_join_marks_presence_without_new_session(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + communicator = await self._connect(customer_token) + await communicator.disconnect() + # Повторное подключение — reconnect, не новый звонок. + second = await self._connect(customer_token) + await second.disconnect() + + async_to_sync(scenario)() + self.assertEqual(CallSession.objects.count(), 1) + participant = CallParticipant.objects.get(call_session=created.call_session, side=ParticipantSide.CUSTOMER) + self.assertIsNotNone(participant.joined_at) + self.assertEqual(participant.last_connection_state, ParticipantConnectionState.DISCONNECTED) + + +class SignalingRelayTests(SignalingTestCase): + def test_offer_relays_to_peer_and_moves_call_to_connecting(self) -> None: + created, customer_token = self._make_accepted_call() + staff_token = created.staff_access_token + + async def scenario(): + staff = await self._connect(staff_token) + customer = await self._connect(customer_token) + await self._drain_until(staff, "peer.joined") + await staff.send_json_to({"type": "webrtc.offer", "id": "offer-1", "sdp": "fake-sdp"}) + relayed = await self._drain_until(customer, "webrtc.offer") + self.assertEqual(relayed["sdp"], "fake-sdp") + self.assertEqual(relayed["side"], ParticipantSide.STAFF) + # Повтор с тем же idempotency id не ретранслируется. + await staff.send_json_to({"type": "webrtc.offer", "id": "offer-1", "sdp": "fake-sdp"}) + await staff.send_json_to({"type": "webrtc.ice_candidate", "id": "ice-1", "candidate": "c1"}) + second = await self._drain_until(customer, "webrtc.ice_candidate") + self.assertEqual(second["candidate"], "c1") + await staff.disconnect() + await customer.disconnect() + + async_to_sync(scenario)() + created.call_session.refresh_from_db() + self.assertEqual(created.call_session.status, CallStatus.CONNECTING) + # SDP не сохраняется нигде в БД. + self.assertFalse(Message.objects.filter(text__icontains="fake-sdp").exists()) + + def test_active_requires_both_sides_connected(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + staff = await self._connect(created.staff_access_token) + customer = await self._connect(customer_token) + await staff.send_json_to({"type": "participant.connection_state", "id": "s1", "state": "CONNECTED"}) + await customer.send_json_to({"type": "participant.connection_state", "id": "c1", "state": "CONNECTED"}) + state = await self._drain_until(customer, "call.state") + self.assertEqual(state["call"]["status"], CallStatus.ACTIVE) + await staff.disconnect() + await customer.disconnect() + + async_to_sync(scenario)() + created.call_session.refresh_from_db() + self.assertEqual(created.call_session.status, CallStatus.ACTIVE) + self.assertIsNotNone(created.call_session.connected_at) + self.assertTrue( + Message.objects.filter(conversation=self.conversation, text__icontains="соединение установлено").exists() + ) + + def test_end_from_active_is_idempotent_with_duration(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + staff = await self._connect(created.staff_access_token) + customer = await self._connect(customer_token) + await staff.send_json_to({"type": "participant.connection_state", "id": "s1", "state": "CONNECTED"}) + await customer.send_json_to({"type": "participant.connection_state", "id": "c1", "state": "CONNECTED"}) + await self._drain_until(staff, "call.state") + await staff.send_json_to({"type": "call.ended", "id": "end-1"}) + ended = await self._drain_until(customer, "call.state", limit=15) + while ended["call"]["status"] != CallStatus.ENDED: + ended = await self._drain_until(customer, "call.state", limit=15) + await customer.send_json_to({"type": "call.ended", "id": "end-2"}) + await staff.disconnect() + await customer.disconnect() + + async_to_sync(scenario)() + call = CallSession.objects.get() + self.assertEqual(call.status, CallStatus.ENDED) + self.assertEqual(call.ended_by, "STAFF") + self.assertIsNotNone(call.duration_seconds) + self.assertEqual(Message.objects.filter(text__icontains="Звонок завершён").count(), 1) + + def test_late_events_after_end_are_ignored(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + staff = await self._connect(created.staff_access_token) + customer = await self._connect(customer_token) + await staff.send_json_to({"type": "call.ended", "id": "end-1"}) + await self._drain_until(customer, "call.state") + await staff.send_json_to({"type": "webrtc.offer", "id": "late-1", "sdp": "late"}) + self.assertTrue(await customer.receive_nothing(timeout=0.3)) + await staff.disconnect() + await customer.disconnect() + + async_to_sync(scenario)() + + def test_end_before_connect_becomes_failed(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + customer = await self._connect(customer_token) + await customer.send_json_to({"type": "call.ended", "id": "end-1"}) + state = await self._drain_until(customer, "call.state") + self.assertEqual(state["call"]["status"], CallStatus.FAILED) + await customer.disconnect() + + async_to_sync(scenario)() + call = CallSession.objects.get() + self.assertEqual(call.failure_code, "ABORTED_BEFORE_CONNECT") + self.assertEqual(call.ended_by, "CUSTOMER") + + +class ReconnectSweepTests(SignalingTestCase): + def test_active_call_fails_after_reconnect_grace(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + staff = await self._connect(created.staff_access_token) + customer = await self._connect(customer_token) + await staff.send_json_to({"type": "participant.connection_state", "id": "s1", "state": "CONNECTED"}) + await customer.send_json_to({"type": "participant.connection_state", "id": "c1", "state": "CONNECTED"}) + await self._drain_until(staff, "call.state") + await customer.disconnect() # клиент пропал и не вернулся + await staff.disconnect() + + async_to_sync(scenario)() + CallParticipant.objects.filter( + call_session=created.call_session, side=ParticipantSide.CUSTOMER + ).update(left_at=timezone.now() - timedelta(hours=1)) + expire_stale_calls() + call = CallSession.objects.get() + self.assertEqual(call.status, CallStatus.FAILED) + self.assertEqual(call.failure_code, "PEER_DISCONNECTED") + + def test_reconnect_within_grace_keeps_call_active(self) -> None: + created, customer_token = self._make_accepted_call() + + async def scenario(): + staff = await self._connect(created.staff_access_token) + customer = await self._connect(customer_token) + await staff.send_json_to({"type": "participant.connection_state", "id": "s1", "state": "CONNECTED"}) + await customer.send_json_to({"type": "participant.connection_state", "id": "c1", "state": "CONNECTED"}) + await self._drain_until(staff, "call.state") + await customer.disconnect() + # Вернулся: presence восстановлен, left_at сброшен. + customer_again = await self._connect(customer_token) + await customer_again.send_json_to( + {"type": "participant.connection_state", "id": "c2", "state": "CONNECTED"} + ) + relayed = await self._drain_until(staff, "participant.connection_state") + while relayed["state"] != "CONNECTED": + relayed = await self._drain_until(staff, "participant.connection_state") + await customer_again.disconnect() + await staff.disconnect() + + async_to_sync(scenario)() + # Обрыва не осталось (left_at сброшен reconnect'ом до дисконнекта в + # конце сценария) — недавний left_at не старше grace, звонок жив. + expire_stale_calls() + call = CallSession.objects.get() + self.assertEqual(call.status, CallStatus.ACTIVE) diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/hub_platform/calls/views.py index a2539d5..c783e71 100644 --- a/apps/backend/hub_platform/calls/views.py +++ b/apps/backend/hub_platform/calls/views.py @@ -7,7 +7,12 @@ from rest_framework.views import APIView from hub_platform.calls.errors import CallAccessDenied, CallConflict, CallTokenError from hub_platform.calls.models import CallSession from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access -from hub_platform.calls.serializers import call_payload, public_call_state_payload, public_invite_payload +from hub_platform.calls.serializers import ( + call_payload, + ice_servers_payload, + public_call_state_payload, + public_invite_payload, +) from hub_platform.calls.services import ( accept_call_by_access_token, active_call_for_conversation, @@ -61,7 +66,11 @@ class CallCreateView(APIView): request=request, ) return _token_response( - {"call": call_payload(call), "staffAccessToken": created.staff_access_token}, + { + "call": call_payload(call), + "staffAccessToken": created.staff_access_token, + "iceServers": ice_servers_payload(), + }, status=201, ) @@ -94,7 +103,7 @@ class StaffAccessTokenView(APIView): return Response({"detail": str(error)}, status=403) except CallConflict as error: return Response({"detail": str(error)}, status=409) - return _token_response({"accessToken": token}) + return _token_response({"accessToken": token, "iceServers": ice_servers_payload()}) class CallCancelView(APIView): @@ -157,6 +166,7 @@ class InviteResolveView(APIView): resolved.invite.expires_at, ), "accessToken": resolved.customer_access_token, + "iceServers": ice_servers_payload(), } ) @@ -182,7 +192,9 @@ class CallAccessStateView(_CallAccessView): call = call_state_by_access_token(token=_bearer_token(request)) except CallTokenError: return Response({"detail": "Недействительный или истёкший call access token"}, status=404) - return _token_response({"call": public_call_state_payload(call)}) + return _token_response( + {"call": public_call_state_payload(call), "iceServers": ice_servers_payload()} + ) class CallAccessAcceptView(_CallAccessView): diff --git a/apps/backend/hub_platform/webchat/views.py b/apps/backend/hub_platform/webchat/views.py index f633a0a..ccdb523 100644 --- a/apps/backend/hub_platform/webchat/views.py +++ b/apps/backend/hub_platform/webchat/views.py @@ -77,7 +77,7 @@ class WebchatContactView(_Public): class WebchatCallOpenView(_Public): def post(self, request: Request) -> Response: from hub_platform.calls.errors import CallTokenError - from hub_platform.calls.serializers import public_invite_payload + from hub_platform.calls.serializers import ice_servers_payload, public_invite_payload from hub_platform.calls.services import open_call_for_identity session = services.resolve_session(_token(request)) @@ -91,6 +91,7 @@ class WebchatCallOpenView(_Public): { "call": public_invite_payload(resolved.invite.call_session, resolved.invite.expires_at), "accessToken": resolved.customer_access_token, + "iceServers": ice_servers_payload(), } ) response["Cache-Control"] = "no-store" diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 595de51..7ebcdee 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,5 +1,8 @@ Django>=5.2,<5.3 djangorestframework>=3.16,<3.17 +channels>=4.2,<5 +channels-redis>=4.2,<5 +daphne>=4.1,<5 cryptography>=43,<45 pgvector>=0.3,<0.4 psycopg[binary]>=3.2,<3.3 diff --git a/apps/internal-ui/src/features/conversations/CallOverlay.tsx b/apps/internal-ui/src/features/conversations/CallOverlay.tsx index 6020bbc..5c1637c 100644 --- a/apps/internal-ui/src/features/conversations/CallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/CallOverlay.tsx @@ -1,142 +1,157 @@ +import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession } from "@edevs/ui"; import { Modal } from "antd"; +import { useEffect, useMemo, useState } from "react"; import { channelMeta } from "./data"; -import type { ApiCall } from "./model"; +import type { ApiCall, CallAccess } from "./model"; import type { ConversationListItem } from "./types"; import "./call.css"; -// Экран звонка оператора (SPEC-HUB-0013 §6, DG-07): ожидание ответа, отмена -// приглашения и терминальные состояния по baseline «Экран звонка.dc.html». -// Медиасостояния (pre-call, active) подключаются на этапе WebRTC-signaling. - -type StatusView = { - icon: "spinner" | "alert" | "declined" | "missed" | "clock"; - tone: "neutral" | "error" | "warn"; - title: string; - caption: (name: string) => string; - retry?: string; +const TERMINAL: Record string; tone: "neutral" | "error" | "warn"; retry?: string }> = { + DECLINED: { icon: "declined", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, tone: "neutral", retry: "Позвонить снова" }, + MISSED: { icon: "missed", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, tone: "warn", retry: "Перезвонить" }, + EXPIRED: { icon: "clock", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", tone: "warn", retry: "Позвонить снова" }, + CANCELLED: { icon: "declined", title: "Звонок отменён", caption: () => "Приглашение отменено.", tone: "neutral" }, + FAILED: { icon: "alert", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", tone: "error", retry: "Повторить" }, + ENDED: { icon: "declined", title: "Звонок завершён", caption: () => "Разговор завершён.", tone: "neutral" }, }; -const STATUS_VIEW: Partial> = { - ACCEPTED: { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: () => "Клиент принял приглашение. Устанавливаем защищённое соединение…" }, - CONNECTING: { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: () => "Устанавливаем защищённое соединение…" }, - DECLINED: { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, retry: "Позвонить снова" }, - MISSED: { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, retry: "Перезвонить" }, - EXPIRED: { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", retry: "Позвонить снова" }, - CANCELLED: { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: () => "Приглашение отменено." }, - FAILED: { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", retry: "Повторить" }, - ENDED: { icon: "declined", tone: "neutral", title: "Звонок завершён", caption: () => "Разговор завершён." }, -}; - -const SUBTITLES: Partial> = { - REQUESTED: "Отправляем приглашение", - RINGING: "Ожидание ответа", - ACCEPTED: "Соединение", - CONNECTING: "Соединение", -}; - -export function CallOverlay({ open, dialog, call, errorText, onCancel, onRetry, onClose }: { +type Props = { open: boolean; dialog: ConversationListItem | null; call: ApiCall | null; + access: CallAccess | null; errorText: string; + onCallChange: (call: ApiCall) => void; onCancel: () => void; onRetry: () => void; onClose: () => void; -}) { - if (!dialog) return null; - const channel = channelMeta[dialog.channel]; - const isWaiting = call != null && (call.status === "REQUESTED" || call.status === "RINGING"); - const status = errorText - ? { icon: "alert" as const, tone: "error" as const, title: "Не удалось запросить звонок", caption: () => errorText, retry: undefined } - : call - ? STATUS_VIEW[call.status] - : undefined; - const subtitle = errorText ? "Ошибка" : (call && (SUBTITLES[call.status] ?? status?.title)) || ""; - const caption = call?.status === "ENDED" && call.durationSeconds != null - ? `Длительность ${fmtDuration(call.durationSeconds)}.` - : status?.caption(dialog.name); - // Действий нет только у промежуточного «Соединяем звонок» — его закроет - // grace period сервера или переход в активный звонок. - const connectingPhase = !errorText && (call?.status === "ACCEPTED" || call?.status === "CONNECTING"); - const showActions = status != null && !connectingPhase; +}; + +export function CallOverlay(props: Props) { + const call = props.call; + const rtc = useCallRtcSession({ + resetKey: call?.id ?? "", + previewEnabled: props.open && call?.status === "ACCEPTED", + accessToken: props.access?.accessToken ?? "", + side: "STAFF", + iceServers: props.access?.iceServers ?? [], + onCallState: (state) => { + if (call) props.onCallChange({ ...call, status: state.status as ApiCall["status"], endedBy: state.endedBy ?? null, durationSeconds: state.durationSeconds ?? null }); + }, + }); + const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE"); + const mode = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); + const status = useMemo( + () => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start), + [props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start], + ); + + if (!props.dialog) return null; + const channel = channelMeta[props.dialog.channel]; + const subtitle = subtitleFor(mode, call, status); + const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена"; + const endAndClose = () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + props.onClose(); + }; return ( - -
- {dialog.initials} -
-
- {dialog.name} - {channel.label} -
- {subtitle} -
- -
- -
- {isWaiting && !status && ( -
-
- - {dialog.initials} -
-
- {dialog.name} - Вызываем… -
-
- )} - {status && ( -
- -
-
{status.title}
-
{caption}
-
- {showActions && ( -
- {status.retry && } - -
- )} -
- )} -
- - {isWaiting && ( -
- - Отменить -
- )} + + {channel.label}} + localStream={rtc.localStream} + remoteStream={rtc.remoteStream} + micOn={rtc.micOn} + camOn={rtc.camOn} + remoteMicOn={rtc.remoteMicOn} + remoteCamOn={rtc.remoteCamOn} + mediaCaption={mediaCaption} + elapsedSeconds={elapsed} + status={status} + joining={rtc.preparing || rtc.connectionPhase === "connecting"} + onToggleMic={rtc.toggleMic} + onToggleCam={rtc.toggleCam} + onJoin={rtc.start} + onCancel={mode === "ringing" ? props.onCancel : endAndClose} + onEnd={rtc.end} + onClose={endAndClose} + /> ); } -function fmtDuration(seconds: number): string { - const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); - const ss = String(seconds % 60).padStart(2, "0"); - return `${mm}:${ss}`; +function resolveMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): CallViewMode { + if (errorText || !call || TERMINAL[call.status] || connection === "failed" || mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; + if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; + if (call.status === "ACCEPTED" && connection === "idle") return "precall"; + if (connection === "reconnecting") return "reconnecting"; + if (call.status === "ACTIVE" || connection === "connected") return "active"; + return "connecting"; } -function StatusIcon({ name }: { name: "spinner" | "alert" | "declined" | "missed" | "clock" }) { - const common = { viewBox: "0 0 24 24", width: 26, height: 26, fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; - switch (name) { - case "spinner": - return ; - case "alert": - return ; - case "declined": - return ; - case "missed": - return ; - case "clock": - return ; - } +function buildStatus(props: Props, connection: string, mediaIssue: string, onReconnect: () => void, onPrepare: () => void, onJoin: () => void): CallViewStatus | undefined { + if (props.errorText) return { icon: "alert", tone: "error", title: "Не удалось запросить звонок", caption: props.errorText }; + if (mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; + if (mediaIssue === "devices") return { + icon: "alert", + tone: "error", + title: "Нет доступа к камере и микрофону", + caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", + actions: [ + { label: "Повторить проверку", kind: "primary", onClick: onPrepare }, + { label: "Без видео", kind: "secondary", onClick: onJoin }, + ], + }; + if (connection === "failed") return { + icon: "alert", + tone: "error", + title: "Не удалось соединиться", + caption: "Проверьте интернет-соединение и попробуйте снова.", + actions: [{ label: "Повторить", kind: "primary", onClick: onReconnect }], + }; + if (!props.call) return undefined; + if (props.call.status === "ACCEPTED" || props.call.status === "CONNECTING") return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; + const terminal = TERMINAL[props.call.status]; + if (!terminal) return undefined; + const duration = props.call.status === "ENDED" && props.call.durationSeconds != null ? `Длительность ${formatDuration(props.call.durationSeconds)}. ` : ""; + return { + icon: terminal.icon, + tone: terminal.tone, + title: terminal.title, + caption: `${duration}${terminal.caption(props.dialog?.name ?? "Клиент")}`, + actions: [ + ...(terminal.retry ? [{ label: terminal.retry, kind: "primary" as const, onClick: props.onRetry }] : []), + { label: "Закрыть", kind: "secondary" as const, onClick: props.onClose }, + ], + }; +} + +function subtitleFor(mode: CallViewMode, call: ApiCall | null, status?: CallViewStatus) { + if (mode === "ringing") return call?.status === "REQUESTED" ? "Отправляем приглашение" : "Ожидание ответа"; + if (mode === "precall") return "Проверьте камеру и микрофон"; + if (mode === "active") return "Активный звонок"; + if (mode === "reconnecting") return "Переподключение"; + return status?.title ?? "Соединение"; +} + +function useElapsed(connectedAt: string | null, active: boolean) { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (!connectedAt || !active) return; + const update = () => setSeconds(Math.max(0, Math.floor((Date.now() - new Date(connectedAt).getTime()) / 1000))); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [active, connectedAt]); + return seconds; +} + +function formatDuration(seconds: number) { + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 822faf8..7ff09b0 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -5,24 +5,18 @@ import { Composer } from "./Composer"; import { ConversationThread } from "./ConversationThread"; import { DialogList } from "./DialogList"; import { - cancelCall, claimConversation, closeConversation, controlModeOf, - fetchActiveCall, - fetchCall, fetchConversation, fetchConversations, releaseConversation, - requestCall, returnToQueue, toConversationListItem, - type ApiCall, type ApiConversation, } from "./model"; import type { ConversationListItem, ListTab } from "./types"; - -const TERMINAL_CALL_STATUSES = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); +import { useConversationCall } from "./useConversationCall"; // Общий workspace диалогов (SPEC-HUB-0010 §8.2): sales и support используют его. // Параметризуется department (изоляция inbox §10 + фильтр fetchConversations), @@ -41,11 +35,6 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder const [conversations, setConversations] = useState([]); const [selectedId, setSelectedId] = useState(initialConversationId ?? null); const [detail, setDetail] = useState(null); - // Онлайн-звонок текущего диалога: оверлей по baseline «Экран звонка». - const [callOpen, setCallOpen] = useState(false); - const [call, setCall] = useState(null); - const [callError, setCallError] = useState(""); - const [callBusy, setCallBusy] = useState(false); const loadList = useCallback(async () => { try { @@ -82,27 +71,10 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder return () => clearInterval(timer); }, [selectedId, loadDetail]); - // Смена диалога сбрасывает оверлей звонка. - useEffect(() => { - setCallOpen(false); - setCall(null); - setCallError(""); - }, [selectedId]); - - // Поллинг состояния звонка, пока оверлей открыт и звонок не завершён. - const callId = call?.id ?? null; - const callStatus = call?.status ?? null; - useEffect(() => { - if (!callOpen || callId == null || callStatus == null || TERMINAL_CALL_STATUSES.has(callStatus)) return; - const timer = setInterval(async () => { - try { - setCall(await fetchCall(callId)); - } catch { - /* transient */ - } - }, 2000); - return () => clearInterval(timer); - }, [callOpen, callId, callStatus]); + const onConversationChanged = useCallback(() => { + if (selectedId != null) void loadDetail(selectedId); + }, [loadDetail, selectedId]); + const callController = useConversationCall({ conversationId: selectedId, onConversationChanged }); const dialogs = useMemo(() => conversations.map(toConversationListItem), [conversations]); const filtered = useMemo(() => { @@ -142,47 +114,6 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder try { applyUpdated(await closeConversation(selectedId)); } catch { /* ignore */ } }; - // Запрос онлайн-звонка (§6): если в диалоге уже есть незавершённый звонок — - // открываем его состояние; иначе создаём (backend атомарно перехватит AI). - const onCall = async () => { - if (selectedId == null || callBusy) return; - setCallBusy(true); - setCallError(""); - try { - const active = await fetchActiveCall(selectedId); - const next = active ?? (await requestCall(selectedId)); - setCall(next); - if (selectedId != null) void loadDetail(selectedId); - } catch (error) { - setCall(null); - setCallError(error instanceof Error ? error.message : "Не удалось запросить звонок"); - } finally { - setCallBusy(false); - setCallOpen(true); - } - }; - - const onCallCancel = async () => { - if (call == null) return; - try { - setCall(await cancelCall(call.id)); - if (selectedId != null) void loadDetail(selectedId); - } catch { /* поллинг подтянет фактическое состояние */ } - setCallOpen(false); - }; - - const onCallRetry = async () => { - if (selectedId == null) return; - setCall(null); - setCallError(""); - try { - setCall(await requestCall(selectedId)); - void loadDetail(selectedId); - } catch (error) { - setCallError(error instanceof Error ? error.message : "Не удалось запросить звонок"); - } - }; - return (
- void onCall()} /> + void callController.start()} /> void onCallCancel()} - onRetry={() => void onCallRetry()} - onClose={() => setCallOpen(false)} + call={callController.call} + access={callController.access} + errorText={callController.errorText} + onCallChange={callController.setCall} + onCancel={() => void callController.cancel()} + onRetry={() => void callController.retry()} + onClose={callController.close} /> - api<{ call: ApiCall; staffAccessToken: string }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST" }).then((r) => r.call); + api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST" }) + .then((r): CreatedCall => ({ call: r.call, access: { accessToken: r.staffAccessToken, iceServers: r.iceServers } })); export const fetchActiveCall = (conversationId: number) => api<{ call: ApiCall | null }>(`/api/v1/calls/conversations/${conversationId}/active/`).then((r) => r.call); export const fetchCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/`).then((r) => r.call); export const cancelCall = (callId: string) => api<{ call: ApiCall }>(`/api/v1/calls/${callId}/cancel/`, { method: "POST" }).then((r) => r.call); +export const fetchStaffCallAccess = (callId: string) => + api<{ accessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/${callId}/access-token/`, { method: "POST" }); diff --git a/apps/internal-ui/src/features/conversations/useConversationCall.ts b/apps/internal-ui/src/features/conversations/useConversationCall.ts new file mode 100644 index 0000000..b1c0d92 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/useConversationCall.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; + +import { + cancelCall, + fetchActiveCall, + fetchCall, + fetchStaffCallAccess, + requestCall, + type ApiCall, + type CallAccess, +} from "./model"; + +const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); + +type Options = { + conversationId: number | null; + onConversationChanged: () => void; +}; + +export function useConversationCall({ conversationId, onConversationChanged }: Options) { + const [open, setOpen] = useState(false); + const [call, setCall] = useState(null); + const [access, setAccess] = useState(null); + const [errorText, setErrorText] = useState(""); + const [busy, setBusy] = useState(false); + + const reset = useCallback(() => { + setOpen(false); + setCall(null); + setAccess(null); + setErrorText(""); + }, []); + + useEffect(() => reset(), [conversationId, reset]); + + useEffect(() => { + if (!open || !call || TERMINAL.has(call.status)) return; + const timer = setInterval(async () => { + try { setCall(await fetchCall(call.id)); } catch { /* transient */ } + }, 2000); + return () => clearInterval(timer); + }, [open, call?.id, call?.status]); + + const ensureAccess = useCallback(async (callId: string) => { + const next = await fetchStaffCallAccess(callId); + setAccess(next); + return next; + }, []); + + const start = useCallback(async () => { + if (conversationId == null || busy) return; + setBusy(true); + setErrorText(""); + try { + const active = await fetchActiveCall(conversationId); + if (active) { + setCall(active); + await ensureAccess(active.id); + } else { + const created = await requestCall(conversationId); + setCall(created.call); + setAccess(created.access); + } + onConversationChanged(); + } catch (error) { + setCall(null); + setAccess(null); + setErrorText(error instanceof Error ? error.message : "Не удалось запросить звонок"); + } finally { + setBusy(false); + setOpen(true); + } + }, [busy, conversationId, ensureAccess, onConversationChanged]); + + const cancel = useCallback(async () => { + if (!call) return; + try { + setCall(await cancelCall(call.id)); + onConversationChanged(); + } catch { /* поллинг подтянет фактическое состояние */ } + setOpen(false); + }, [call, onConversationChanged]); + + const retry = useCallback(async () => { + if (conversationId == null) return; + setCall(null); + setAccess(null); + setErrorText(""); + try { + const created = await requestCall(conversationId); + setCall(created.call); + setAccess(created.access); + onConversationChanged(); + } catch (error) { + setErrorText(error instanceof Error ? error.message : "Не удалось запросить звонок"); + } + }, [conversationId, onConversationChanged]); + + return { + open, + call, + access, + errorText, + busy, + start, + cancel, + retry, + close: () => setOpen(false), + setCall, + }; +} diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index eb95b6e..319b3b5 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -25,6 +25,9 @@ export type CallInfo = { durationSeconds?: number | null; }; +export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] }; +export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] }; + export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null }; export async function getConfig(channel: string): Promise { @@ -70,7 +73,7 @@ export async function poll(token: string, since: number): Promise { const CALLS_API = "/api/v1/calls"; // Виджет: получить call access token по session token (переход на страницу звонка). -export async function openWebchatCall(sessionToken: string): Promise<{ call: CallInfo; accessToken: string } | null> { +export async function openWebchatCall(sessionToken: string): Promise { const r = await fetch(`${API}/call/open/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, @@ -89,7 +92,7 @@ export async function declineWebchatCall(sessionToken: string): Promise } // Страница звонка: обмен invite token из ссылки TG/MAX на access token. -export async function resolveCallInvite(inviteToken: string): Promise<{ call: CallInfo; accessToken: string } | null> { +export async function resolveCallInvite(inviteToken: string): Promise { const r = await fetch(`${CALLS_API}/invites/resolve/`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -99,7 +102,7 @@ export async function resolveCallInvite(inviteToken: string): Promise<{ call: Ca return r.json(); } -async function callAccessAction(action: "state" | "accept" | "decline", accessToken: string): Promise { +async function callAccessAction(action: "accept" | "decline", accessToken: string): Promise { const r = await fetch(`${CALLS_API}/access/${action}/`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, @@ -108,7 +111,14 @@ async function callAccessAction(action: "state" | "accept" | "decline", accessTo return (await r.json()).call as CallInfo; } -export const fetchCallState = (accessToken: string) => callAccessAction("state", accessToken); +export async function fetchCallState(accessToken: string): Promise { + const r = await fetch(`${CALLS_API}/access/state/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, + }); + if (!r.ok) return null; + return r.json(); +} export const acceptCall = (accessToken: string) => callAccessAction("accept", accessToken); export const declineCall = (accessToken: string) => callAccessAction("decline", accessToken); diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index 5633aea..c17f591 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -1,287 +1,162 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { CallView, useCallRtcSession } from "@edevs/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import { acceptCall, declineCall, fetchCallState, resolveCallInvite, type CallInfo } from "../api"; +import { + acceptCall, + declineCall, + fetchCallState, + resolveCallInvite, + type CallBootstrap, + type CallInfo, +} from "../api"; +import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model"; -// Клиентская страница звонка /calls/ (SPEC-HUB-0013 §7.2, DG-07). -// Верстка и состояния — по baseline «# UI для онлайн звонка/Экран звонка.dc.html» -// (сторона «Клиент»). Медиасоединение WebRTC подключается следующим этапом: -// после принятия страница показывает состояние «Соединяем звонок». -// -// Token проверяется ДО запроса камеры и микрофона (§7.2). Access token живёт -// в sessionStorage вкладки — перезагрузка не «сжигает» одноразовый invite. - -type Phase = - | "loading" - | "invalid" - | "precall" - | "connecting" - | "declined" - | "cancelled" - | "missed" - | "expired" - | "ended" - | "failed"; - -const TERMINAL_BY_STATUS: Record = { - DECLINED: "declined", - CANCELLED: "cancelled", - MISSED: "missed", - EXPIRED: "expired", - ENDED: "ended", - FAILED: "failed", -}; - -const STATUS_VIEW: Record = { - loading: { icon: "spinner", tone: "neutral", title: "Проверяем приглашение", caption: "Секунду…" }, - invalid: { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате." }, - connecting: { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }, - declined: { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: "Вы отклонили вызов. Продолжить общение можно в чате." }, - cancelled: { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: "Сотрудник отменил приглашение. Продолжить общение можно в чате." }, - missed: { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: "Никто не ответил вовремя. Запросите новое приглашение в чате." }, - expired: { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: "Приглашение истекло. Запросите новое приглашение в чате." }, - ended: { icon: "declined", tone: "neutral", title: "Звонок завершён", caption: "Спасибо! Продолжить общение можно в чате." }, - failed: { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: "Проверьте интернет-соединение и попробуйте снова по ссылке из чата." }, -}; - -function storageKey(): string { - return `edevs-call:${location.pathname}`; -} - -function inviteTokenFromPath(): string { - const match = location.pathname.match(/\/calls\/([^/]+)/); - return match ? match[1] : ""; -} - -function accessTokenFromHash(): string { - return location.hash.startsWith("#") ? location.hash.slice(1) : ""; -} +function storageKey() { return `edevs-call:${location.pathname}`; } +function inviteTokenFromPath() { return location.pathname.match(/\/calls\/([^/]+)/)?.[1] ?? ""; } +function accessTokenFromHash() { return location.hash.startsWith("#") ? location.hash.slice(1) : ""; } export function CallApp() { - const [phase, setPhase] = useState("loading"); + const [loading, setLoading] = useState(true); + const [invalid, setInvalid] = useState(false); const [call, setCall] = useState(null); - const [accessToken, setAccessToken] = useState(""); - const [micOn, setMicOn] = useState(true); - const [camOn, setCamOn] = useState(true); - const [mediaError, setMediaError] = useState(false); + const [accessToken, setAccessToken] = useState(""); + const [iceServers, setIceServers] = useState([]); + const [started, setStarted] = useState(false); const [joining, setJoining] = useState(false); - const streamRef = useRef(null); - const videoRef = useRef(null); - const stopMedia = useCallback(() => { - streamRef.current?.getTracks().forEach((t) => t.stop()); - streamRef.current = null; + const rtc = useCallRtcSession({ + resetKey: call?.callId ?? "", + previewEnabled: Boolean(call && accessToken && !isTerminalCall(call.status) && !started), + accessToken, + side: "CUSTOMER", + iceServers, + onCallState: (state) => setCall((current) => current ? { ...current, ...state } : state), + }); + const close = useCallback(() => { + if (started) rtc.end(); + if (history.length > 1) history.back(); else window.close(); + }, [rtc.end, started]); + + const applyBootstrap = useCallback((value: CallBootstrap) => { + sessionStorage.setItem(storageKey(), value.accessToken); + setAccessToken(value.accessToken); + setIceServers(value.iceServers ?? []); + setCall(value.call); + setLoading(false); }, []); - const applyCall = useCallback((info: CallInfo | null) => { - if (!info) return; - setCall(info); - const terminal = TERMINAL_BY_STATUS[info.status]; - if (terminal) { - setPhase(terminal); - stopMedia(); - } else if (info.status === "ACCEPTED" || info.status === "CONNECTING") { - setPhase("connecting"); - } - }, [stopMedia]); - - // Вход: access token из fragment (Web Chat) или invite token из пути (TG/MAX). useEffect(() => { - const saved = sessionStorage.getItem(storageKey()); const fromHash = accessTokenFromHash(); - if (fromHash) { - sessionStorage.setItem(storageKey(), fromHash); - history.replaceState(null, "", location.pathname); // token не остаётся в адресе - } + const saved = sessionStorage.getItem(storageKey()); + if (fromHash) history.replaceState(null, "", location.pathname); const token = fromHash || saved || ""; if (token) { setAccessToken(token); - void fetchCallState(token).then((info) => { - if (!info) { setPhase("invalid"); return; } - setPhase("precall"); - applyCall(info); + void fetchCallState(token).then((result) => { + if (!result) { setInvalid(true); setLoading(false); return; } + setCall(result.call); + setIceServers(result.iceServers ?? []); + setLoading(false); }); return; } const invite = inviteTokenFromPath(); - if (!invite) { setPhase("invalid"); return; } - void resolveCallInvite(invite).then((resolved) => { - if (!resolved) { setPhase("invalid"); return; } - sessionStorage.setItem(storageKey(), resolved.accessToken); - setAccessToken(resolved.accessToken); - setPhase("precall"); - applyCall(resolved.call); + if (!invite) { setInvalid(true); setLoading(false); return; } + void resolveCallInvite(invite).then((result) => { + if (!result) { setInvalid(true); setLoading(false); return; } + applyBootstrap(result); }); - }, [applyCall]); + }, [applyBootstrap]); - // Поллинг состояния: отмена сотрудником и таймауты приходят с сервера. useEffect(() => { - if (!accessToken || phase === "invalid" || TERMINAL_BY_STATUS[call?.status ?? ""]) return; + if (!accessToken || !call || isTerminalCall(call.status)) return; const timer = setInterval(async () => { - const info = await fetchCallState(accessToken); - if (info) applyCall(info); + const result = await fetchCallState(accessToken); + if (result) { + setCall(result.call); + if (result.iceServers) setIceServers(result.iceServers); + } }, 2000); return () => clearInterval(timer); - }, [accessToken, phase, call?.status, applyCall]); + }, [accessToken, call?.status]); - // Pre-call preview: камера/микрофон запрашиваются только после проверки token. useEffect(() => { - if (phase !== "precall") return; - let cancelled = false; - navigator.mediaDevices - .getUserMedia({ video: true, audio: true }) - .then((stream) => { - if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } - streamRef.current = stream; - setMediaError(false); - if (videoRef.current) videoRef.current.srcObject = stream; - }) - .catch(() => { if (!cancelled) setMediaError(true); }); - return () => { cancelled = true; }; - }, [phase]); - - useEffect(() => () => stopMedia(), [stopMedia]); - - // Mute/выключение камеры — track.enabled, без пересоздания потока (§10). - useEffect(() => { - streamRef.current?.getAudioTracks().forEach((t) => { t.enabled = micOn; }); - }, [micOn]); - useEffect(() => { - streamRef.current?.getVideoTracks().forEach((t) => { t.enabled = camOn; }); - if (camOn && videoRef.current && streamRef.current) videoRef.current.srcObject = streamRef.current; - }, [camOn]); + if (isTerminalCall(call?.status)) rtc.stop(); + }, [call?.status, rtc.stop]); async function join() { if (!accessToken || joining) return; setJoining(true); - const info = await acceptCall(accessToken); + if (call?.status === "REQUESTED" || call?.status === "RINGING") { + const accepted = await acceptCall(accessToken); + if (accepted) setCall(accepted); + } + setStarted(true); + rtc.start(); setJoining(false); - if (info) applyCall(info); } - async function decline() { + async function cancelPrecall() { if (!accessToken) return; - const info = await declineCall(accessToken); - if (info) applyCall(info); - else setPhase("declined"); + if (call?.status === "REQUESTED" || call?.status === "RINGING") { + const declined = await declineCall(accessToken); + if (declined) setCall(declined); + return; + } + setStarted(true); + if (rtc.start()) rtc.end(); } - const staffName = call?.staffName || "Оператор"; - const initials = staffName.trim().split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase() || "ОП"; - const showVideo = phase === "precall" && camOn && !mediaError; - const status = phase === "precall" ? null : STATUS_VIEW[phase]; - const subtitle = phase === "precall" ? "Проверьте камеру и микрофон" : status?.title ?? ""; + const mode = resolveCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue }); + const status = useMemo( + () => buildCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close, retry: rtc.restart, prepare: rtc.prepare, join: () => void join() }), + [loading, invalid, call, joining, rtc.connectionPhase, rtc.mediaIssue, close, rtc.restart, rtc.prepare], + ); + const elapsed = useConnectionTimer(rtc.connectionPhase === "connected"); + const peerName = call?.staffName || "Оператор"; + const initials = peerName.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase() || "ОП"; + const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена"; return ( -
-
- -
-
{initials}
-
- {staffName} - {subtitle} -
-
- -
-
- - {phase === "precall" && ( -
-
- setMicOn((v) => !v)} kind="mic" /> - setCamOn((v) => !v)} kind="cam" /> -
-
- - -
- {mediaError && ( -
- Разрешите доступ к камере и микрофону в настройках браузера — или присоединяйтесь без видео. -
- )} -
- )} -
-
+
+ void join()} + onCancel={() => void cancelPrecall()} + onEnd={rtc.end} + onClose={close} + /> +
); } -function fmtDuration(seconds: number): string { - const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); - const ss = String(seconds % 60).padStart(2, "0"); - return `${mm}:${ss}`; -} - -function ToggleControl({ label, on, disabled, onClick, kind }: { label: string; on: boolean; disabled?: boolean; onClick: () => void; kind: "mic" | "cam" }) { - const style = on - ? { border: "1px solid #d9d9d9", background: "#f5f5f5", color: "#434343" } - : { border: "1px solid #ffccc7", background: "#fff2f0", color: "#cf1322" }; - return ( -
- - {label} -
- ); -} - -function DeviceIcon({ kind, on }: { kind: "mic" | "cam"; on: boolean }) { - const common = { viewBox: "0 0 24 24", width: 22, height: 22, fill: "none", stroke: "currentColor", strokeWidth: 1.85, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; - if (kind === "mic") { - return on - ? - : ; - } - return on - ? - : ; -} - -function StatusIcon({ name, tone }: { name: "spinner" | "alert" | "declined" | "missed" | "clock"; tone: "neutral" | "error" | "warn" }) { - const tones = { - neutral: { bg: "rgba(255,255,255,.08)", fg: "#fff" }, - error: { bg: "rgba(255,77,79,.16)", fg: "#ff7875" }, - warn: { bg: "rgba(250,173,20,.16)", fg: "#ffc53d" }, - }[tone]; - const common = { viewBox: "0 0 24 24", width: 26, height: 26, fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; - const icons = { - spinner: , - alert: , - declined: , - missed: , - clock: , - }; - return ( -
- {icons[name]} -
- ); +function useConnectionTimer(active: boolean) { + const [startedAt, setStartedAt] = useState(null); + const [seconds, setSeconds] = useState(0); + useEffect(() => { + if (!active) return; + const origin = startedAt ?? Date.now(); + if (startedAt == null) setStartedAt(origin); + const update = () => setSeconds(Math.floor((Date.now() - origin) / 1000)); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [active, startedAt]); + return seconds; } diff --git a/apps/web-chat/src/call/model.test.ts b/apps/web-chat/src/call/model.test.ts new file mode 100644 index 0000000..109b192 --- /dev/null +++ b/apps/web-chat/src/call/model.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CallInfo } from "../api"; +import { buildCallViewStatus, isTerminalCall, resolveCallViewMode } from "./model"; + +const call: CallInfo = { callId: "call-1", status: "ACCEPTED", staffName: "Оператор" }; +const action = vi.fn(); + +describe("call view model", () => { + it("keeps accepted call in pre-call until the client starts RTC", () => { + expect(resolveCallViewMode({ loading: false, invalid: false, call, started: false, connection: "idle", mediaIssue: "none" })).toBe("precall"); + }); + + it("shows reconnecting over an already started call", () => { + expect(resolveCallViewMode({ loading: false, invalid: false, call: { ...call, status: "ACTIVE" }, started: true, connection: "reconnecting", mediaIssue: "none" })).toBe("reconnecting"); + }); + + it("exposes baseline actions when media devices are unavailable", () => { + const status = buildCallViewStatus({ loading: false, invalid: false, call, connection: "idle", mediaIssue: "devices", close: action, retry: action, prepare: action, join: action }); + expect(status?.title).toBe("Нет доступа к камере и микрофону"); + expect(status?.actions?.map((item) => item.label)).toEqual(["Повторить проверку", "Без видео"]); + expect(isTerminalCall("ENDED")).toBe(true); + }); +}); diff --git a/apps/web-chat/src/call/model.ts b/apps/web-chat/src/call/model.ts new file mode 100644 index 0000000..d4fba3a --- /dev/null +++ b/apps/web-chat/src/call/model.ts @@ -0,0 +1,47 @@ +import type { CallViewMode, CallViewStatus } from "@edevs/ui"; + +import type { CallInfo } from "../api"; + +const TERMINAL: Record = { + DECLINED: { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: "Вы отклонили вызов. Продолжить общение можно в чате." }, + CANCELLED: { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: "Сотрудник отменил приглашение. Продолжить общение можно в чате." }, + MISSED: { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: "Никто не ответил вовремя. Запросите новое приглашение в чате." }, + EXPIRED: { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: "Приглашение истекло. Запросите новое приглашение в чате." }, + ENDED: { icon: "declined", tone: "neutral", title: "Звонок завершён", caption: "Спасибо! Продолжить общение можно в чате." }, + FAILED: { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: "Проверьте интернет-соединение и попробуйте снова по ссылке из чата." }, +}; + +export const isTerminalCall = (status?: string) => Boolean(status && TERMINAL[status]); + +export function resolveCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string }): CallViewMode { + if (state.loading || state.invalid || !state.call || isTerminalCall(state.call.status) || state.connection === "failed" || state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; + if (!state.started) return "precall"; + if (state.connection === "reconnecting") return "reconnecting"; + if (state.connection === "connected" || state.call.status === "ACTIVE") return "active"; + return "connecting"; +} + +export function buildCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; close: () => void; retry: () => void; prepare: () => void; join: () => void }): CallViewStatus | undefined { + if (state.loading) return { icon: "spinner", tone: "neutral", title: "Проверяем приглашение", caption: "Секунду…" }; + if (state.invalid || !state.call) return { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате." }; + if (state.mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; + if (state.mediaIssue === "devices") return { icon: "alert", tone: "error", title: "Нет доступа к камере и микрофону", caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", actions: [{ label: "Повторить проверку", kind: "primary", onClick: state.prepare }, { label: "Без видео", kind: "secondary", onClick: state.join }] }; + if (state.connection === "failed") return { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: "Проверьте интернет-соединение и попробуйте снова.", actions: [{ label: "Повторить", kind: "primary", onClick: state.retry }] }; + const terminal = TERMINAL[state.call.status]; + if (terminal) { + const duration = state.call.status === "ENDED" && state.call.durationSeconds != null ? `Длительность ${formatDuration(state.call.durationSeconds)}. ` : ""; + return { ...terminal, caption: duration + terminal.caption, actions: [{ label: "Закрыть", kind: "secondary", onClick: state.close }] }; + } + return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; +} + +export function callViewSubtitle(mode: CallViewMode, status?: CallViewStatus) { + if (mode === "precall") return "Проверьте камеру и микрофон"; + if (mode === "active") return "Активный звонок"; + if (mode === "reconnecting") return "Переподключение"; + return status?.title ?? "Соединение"; +} + +function formatDuration(seconds: number) { + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/apps/web-chat/src/styles.css b/apps/web-chat/src/styles.css index 1555775..4ccb7f6 100644 --- a/apps/web-chat/src/styles.css +++ b/apps/web-chat/src/styles.css @@ -23,3 +23,18 @@ body, 0% { transform: scale(1); opacity: 0.55; } 100% { transform: scale(2.3); opacity: 0; } } + +.public-call-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: #f5f6f8; +} + +@media (max-width: 480px) { + .public-call-page { + padding: 8px; + } +} diff --git a/compose.production.yaml b/compose.production.yaml index a68cdfb..c81318c 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -33,7 +33,8 @@ services: env_file: - .env.production command: > - sh -c "gunicorn hub_backend.wsgi:application + sh -c "gunicorn hub_backend.asgi:application + --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers $${HUB_GUNICORN_WORKERS:-3} --timeout $${HUB_GUNICORN_TIMEOUT:-60}" diff --git a/deploy/nginx/frontend.production.conf b/deploy/nginx/frontend.production.conf index 2946ce8..bfafb2b 100644 --- a/deploy/nginx/frontend.production.conf +++ b/deploy/nginx/frontend.production.conf @@ -28,6 +28,19 @@ server { proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + # WebSocket signaling звонков (SPEC-HUB-0013 §9). + location /ws/ { + proxy_pass http://backend:8000/ws/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + } + location /admin/ { add_header X-Frame-Options SAMEORIGIN always; diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf index 25ef545..565a576 100644 --- a/deploy/nginx/local.conf +++ b/deploy/nginx/local.conf @@ -9,6 +9,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # WebSocket signaling звонков (SPEC-HUB-0013 §9). + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + } + # Web Chat widget loader (served from the hub domain). location = /chat-widget.js { proxy_pass http://backend:8000; diff --git a/package-lock.json b/package-lock.json index d4d4c1e..1584b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3550,6 +3550,7 @@ "name": "@edevs/ui", "version": "0.1.0", "peerDependencies": { + "@edevs/shared": "0.1.0", "antd": "^5.26.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/shared/src/callRtc.ts b/packages/shared/src/callRtc.ts new file mode 100644 index 0000000..58e8c6e --- /dev/null +++ b/packages/shared/src/callRtc.ts @@ -0,0 +1,270 @@ +/** + * Клиент P2P-звонка: собственный WebSocket signaling Hub + RTCPeerConnection + * (SPEC-HUB-0013 §9–10). Общий для internal-ui (оператор) и web-chat (клиент). + * + * Роли детерминированы: offer всегда создаёт STAFF (инициатор), CUSTOMER + * только отвечает — glare исключён. Mute/выключение камеры — track.enabled, + * без пересоздания звонка. Access token уходит только первым сообщением + * auth (не в URL). SDP/ICE нигде не сохраняются. + */ + +export type CallSide = "STAFF" | "CUSTOMER"; + +export type PublicCallState = { + callId: string; + status: string; + staffName?: string; + endedBy?: string | null; + durationSeconds?: number | null; +}; + +export type RtcConnectionPhase = "connecting" | "connected" | "reconnecting" | "failed"; + +export type CallRtcHandlers = { + onCallState?: (call: PublicCallState) => void; + onRemoteStream?: (stream: MediaStream) => void; + onRemoteMedia?: (state: { mic: boolean; cam: boolean }) => void; + onConnection?: (phase: RtcConnectionPhase) => void; + onClosed?: () => void; +}; + +const WS_RETRY_LIMIT = 5; + +function commandId(): string { + return typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; +} + +export class CallRtcClient { + private ws: WebSocket | null = null; + private pc: RTCPeerConnection | null = null; + private disposed = false; + private authed = false; + private wsRetries = 0; + private negotiating = false; + private endRequested = false; + private pendingCandidates: RTCIceCandidateInit[] = []; + + constructor( + private readonly options: { + accessToken: string; + side: CallSide; + iceServers: RTCIceServer[]; + localStream: MediaStream | null; + handlers: CallRtcHandlers; + }, + ) {} + + start(): void { + this.openSocket(); + } + + /** Завершить звонок со своей стороны (идемпотентно на сервере). */ + end(): void { + this.endRequested = true; + if (this.authed) this.sendCommand({ type: "call.ended" }); + } + + /** Тихо закрыть соединения (уход со страницы, терминальное состояние). */ + dispose(): void { + this.disposed = true; + this.pc?.close(); + this.pc = null; + this.ws?.close(); + this.ws = null; + } + + /** Состояние устройств: track.enabled + уведомление собеседника. */ + setMediaState(mic: boolean, cam: boolean): void { + this.options.localStream?.getAudioTracks().forEach((track) => { + track.enabled = mic; + }); + this.options.localStream?.getVideoTracks().forEach((track) => { + track.enabled = cam; + }); + this.sendCommand({ type: "participant.media_state", mic, cam }); + } + + // --- WebSocket signaling --- + + private openSocket(): void { + if (this.disposed) return; + const scheme = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${scheme}://${location.host}/ws/calls/`); + this.ws = ws; + this.authed = false; + ws.onopen = () => { + ws.send(JSON.stringify({ type: "auth", token: this.options.accessToken })); + }; + ws.onmessage = (event) => { + let message: Record; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + void this.route(message); + }; + ws.onclose = () => { + if (this.disposed) return; + // Reconnect signaling не создаёт новую CallSession (SPEC §9). + if (this.wsRetries >= WS_RETRY_LIMIT) { + this.options.handlers.onConnection?.("failed"); + return; + } + this.wsRetries += 1; + setTimeout(() => this.openSocket(), Math.min(500 * 2 ** this.wsRetries, 5000)); + }; + } + + private sendCommand(payload: Record): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ id: commandId(), ...payload })); + } + + private async route(message: Record): Promise { + switch (message.type) { + case "error": + this.options.handlers.onConnection?.("failed"); + this.dispose(); + this.options.handlers.onClosed?.(); + return; + case "call.state": { + if (!this.authed) { + this.authed = true; + this.wsRetries = 0; + if (this.endRequested) { + this.sendCommand({ type: "call.ended" }); + return; + } + this.publishMediaState(); + // STAFF мог подключиться позже клиента: peer.joined уже прошёл. + if (this.options.side === "STAFF") void this.sendOffer(false); + } + this.options.handlers.onCallState?.(message.call as PublicCallState); + return; + } + case "peer.joined": + if (this.options.side === "STAFF") void this.sendOffer(this.pc != null); + return; + case "webrtc.offer": + await this.acceptOffer(String(message.sdp ?? "")); + return; + case "webrtc.answer": + if (this.pc && this.pc.signalingState === "have-local-offer") { + await this.pc.setRemoteDescription({ type: "answer", sdp: String(message.sdp ?? "") }); + await this.flushCandidates(); + } + return; + case "webrtc.ice_candidate": + if (message.candidate) await this.addCandidate(message.candidate as RTCIceCandidateInit); + return; + case "participant.media_state": + this.options.handlers.onRemoteMedia?.({ mic: message.mic !== false, cam: message.cam !== false }); + return; + case "participant.connection_state": + if (message.state === "DISCONNECTED" || message.state === "RECONNECTING") { + this.options.handlers.onConnection?.("reconnecting"); + } + return; + default: + return; + } + } + + // --- RTCPeerConnection --- + + private ensurePeer(): RTCPeerConnection { + if (this.pc) return this.pc; + const pc = new RTCPeerConnection({ iceServers: this.options.iceServers }); + this.pc = pc; + this.options.localStream?.getTracks().forEach((track) => { + pc.addTrack(track, this.options.localStream as MediaStream); + }); + pc.ontrack = (event) => { + const stream = event.streams[0] ?? new MediaStream([event.track]); + this.options.handlers.onRemoteStream?.(stream); + }; + pc.onicecandidate = (event) => { + if (event.candidate) this.sendCommand({ type: "webrtc.ice_candidate", candidate: event.candidate.toJSON() }); + }; + pc.onconnectionstatechange = () => { + switch (pc.connectionState) { + case "connected": + this.sendCommand({ type: "participant.connection_state", state: "CONNECTED" }); + this.options.handlers.onConnection?.("connected"); + break; + case "disconnected": + this.sendCommand({ type: "participant.connection_state", state: "RECONNECTING" }); + this.options.handlers.onConnection?.("reconnecting"); + if (this.options.side === "STAFF") void this.sendOffer(true); + break; + case "failed": + this.sendCommand({ type: "participant.connection_state", state: "RECONNECTING" }); + this.options.handlers.onConnection?.("reconnecting"); + if (this.options.side === "STAFF") void this.sendOffer(true); + else this.options.handlers.onConnection?.("failed"); + break; + default: + break; + } + }; + return pc; + } + + private publishMediaState(): void { + const audio = this.options.localStream?.getAudioTracks()[0]; + const video = this.options.localStream?.getVideoTracks()[0]; + this.sendCommand({ + type: "participant.media_state", + mic: audio?.enabled ?? false, + cam: video?.enabled ?? false, + }); + } + + private async sendOffer(iceRestart: boolean): Promise { + if (this.disposed || this.negotiating) return; + this.negotiating = true; + try { + const pc = this.ensurePeer(); + const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined); + await pc.setLocalDescription(offer); + this.sendCommand({ type: "webrtc.offer", sdp: offer.sdp }); + } catch { + this.options.handlers.onConnection?.("failed"); + } finally { + this.negotiating = false; + } + } + + private async acceptOffer(sdp: string): Promise { + if (this.disposed || this.options.side !== "CUSTOMER") return; + try { + const pc = this.ensurePeer(); + await pc.setRemoteDescription({ type: "offer", sdp }); + await this.flushCandidates(); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + this.sendCommand({ type: "webrtc.answer", sdp: answer.sdp }); + } catch { + this.options.handlers.onConnection?.("failed"); + } + } + + private async addCandidate(candidate: RTCIceCandidateInit): Promise { + if (!this.pc?.remoteDescription) { + this.pendingCandidates.push(candidate); + return; + } + try { + await this.pc.addIceCandidate(candidate); + } catch { + /* кандидат от устаревшей negotiation — игнорируем */ + } + } + + private async flushCandidates(): Promise { + if (!this.pc?.remoteDescription) return; + const candidates = this.pendingCandidates.splice(0); + for (const candidate of candidates) await this.addCandidate(candidate); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b77243e..f6a5cd3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,3 +3,11 @@ export type AttentionStatus = "NORMAL" | "ATTENTION" | "CRITICAL"; export type HubApplication = "internal-ui" | "web-chat"; export const defaultApiBaseUrl = "http://localhost:8000/api/v1"; + +export { + CallRtcClient, + type CallRtcHandlers, + type CallSide, + type PublicCallState, + type RtcConnectionPhase, +} from "./callRtc"; diff --git a/packages/ui/package.json b/packages/ui/package.json index fdee80c..016a94f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc -p tsconfig.json" }, "peerDependencies": { + "@edevs/shared": "0.1.0", "antd": "^5.26.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/ui/src/call/CallIcons.tsx b/packages/ui/src/call/CallIcons.tsx new file mode 100644 index 0000000..dbe7d5c --- /dev/null +++ b/packages/ui/src/call/CallIcons.tsx @@ -0,0 +1,54 @@ +type IconProps = { kind: "mic" | "cam"; on?: boolean }; + +const common = { + viewBox: "0 0 24 24", + width: 22, + height: 22, + fill: "none", + stroke: "currentColor", + strokeWidth: 1.85, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, +}; + +export function DeviceIcon({ kind, on = true }: IconProps) { + if (kind === "mic") { + return on ? ( + + ) : ( + + ); + } + return on ? ( + + ) : ( + + ); +} + +export function PhoneIcon() { + return ; +} + +export function CloseIcon() { + return ; +} + +export function FullscreenIcon() { + return ; +} + +export function SpinnerIcon() { + return ; +} + +export type StatusIconName = "spinner" | "alert" | "declined" | "missed" | "clock" | "unsupported"; + +export function StatusIcon({ name }: { name: StatusIconName }) { + if (name === "spinner") return ; + if (name === "alert") return ; + if (name === "declined") return ; + if (name === "missed") return ; + if (name === "unsupported") return ; + return ; +} diff --git a/packages/ui/src/call/CallView.tsx b/packages/ui/src/call/CallView.tsx new file mode 100644 index 0000000..8567ead --- /dev/null +++ b/packages/ui/src/call/CallView.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, type ReactNode } from "react"; + +import { CloseIcon, DeviceIcon, FullscreenIcon, PhoneIcon, SpinnerIcon, StatusIcon, type StatusIconName } from "./CallIcons"; +import "./call-view.css"; + +export type CallViewMode = "ringing" | "precall" | "connecting" | "active" | "reconnecting" | "status"; + +export type CallViewStatus = { + icon: StatusIconName; + tone: "neutral" | "error" | "warn"; + title: string; + caption: string; + actions?: Array<{ label: string; kind: "primary" | "secondary"; onClick: () => void }>; +}; + +type Props = { + mode: CallViewMode; + peerName: string; + peerInitials: string; + peerAvatarColor?: string; + subtitle: string; + channelBadge?: ReactNode; + localStream?: MediaStream | null; + remoteStream?: MediaStream | null; + micOn?: boolean; + camOn?: boolean; + remoteMicOn?: boolean; + remoteCamOn?: boolean; + mediaCaption?: string; + elapsedSeconds?: number; + status?: CallViewStatus; + joining?: boolean; + cancelLabel?: string; + onToggleMic?: () => void; + onToggleCam?: () => void; + onJoin?: () => void; + onCancel?: () => void; + onEnd?: () => void; + onClose?: () => void; +}; + +function Video({ stream, muted, mirrored, className }: { stream?: MediaStream | null; muted?: boolean; mirrored?: boolean; className: string }) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.srcObject = stream ?? null; + }, [stream]); + return