feat(calls): Step B

This commit is contained in:
Andrey committed 2026-07-12 01:17:17 +03:00
1 parent bb9fc5d210
commit bba6f17557
38 files changed
+1864 -699

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -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=
+15 -1
View File
@@ -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),
}
)
+28 -1
View File
@@ -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",
@@ -0,0 +1,130 @@
"""WebSocket signaling звонков (SPEC-HUB-0013 §9).
Правила:
- аутентификация первым сообщением {"type": "auth", "token": <call access 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"])
@@ -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
@@ -0,0 +1,7 @@
from django.urls import path
from hub_platform.calls.consumers import CallSignalingConsumer
websocket_urlpatterns = [
path("ws/calls/", CallSignalingConsumer.as_asgi()),
]
@@ -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 {
@@ -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)
@@ -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
@@ -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):
@@ -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
@@ -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)
+16 -4
View File
@@ -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):
+2 -1
View File
@@ -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"
+3
View File
@@ -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
@@ -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, { icon: CallViewStatus["icon"]; title: string; caption: (name: string) => 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<Record<ApiCall["status"], StatusView>> = {
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<Record<ApiCall["status"], string>> = {
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 (
<Modal open={open} onCancel={onClose} footer={null} closable={false} width={428} className="call-modal" destroyOnHidden>
<div className="call-head">
<span className="call-head-avatar" style={{ background: dialog.avatarBg }}>{dialog.initials}</span>
<div className="call-head-info">
<div className="call-head-name">
{dialog.name}
<em style={{ background: channel.bg, color: channel.color }}>{channel.label}</em>
</div>
<span className="call-head-subtitle">{subtitle}</span>
</div>
<button className="call-head-close" onClick={onClose} aria-label="Закрыть">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
<div className="call-media">
{isWaiting && !status && (
<div className="call-media-center">
<div className="call-rings">
<i /><i />
<span className="call-big-avatar" style={{ background: dialog.avatarBg }}>{dialog.initials}</span>
</div>
<div className="call-media-name">
<strong>{dialog.name}</strong>
<span>Вызываем…</span>
</div>
</div>
)}
{status && (
<div className="call-status">
<span className={`call-status-icon ${status.tone}`}><StatusIcon name={status.icon} /></span>
<div>
<div className="call-status-title">{status.title}</div>
<div className="call-status-caption">{caption}</div>
</div>
{showActions && (
<div className="call-status-actions">
{status.retry && <button className="primary" onClick={onRetry}>{status.retry}</button>}
<button className="secondary" onClick={onClose}>Закрыть</button>
</div>
)}
</div>
)}
</div>
{isWaiting && (
<div className="call-bar-ringing">
<button className="call-hangup" onClick={onCancel} aria-label="Отменить вызов">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" style={{ transform: "rotate(135deg)" }}><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>
</button>
<span>Отменить</span>
</div>
)}
<Modal open={props.open} onCancel={endAndClose} footer={null} closable={false} width={428} className="call-modal" destroyOnHidden>
<CallView
mode={mode}
peerName={props.dialog.name}
peerInitials={props.dialog.initials}
peerAvatarColor={props.dialog.avatarBg}
subtitle={subtitle}
channelBadge={<em className="call-channel" style={{ background: channel.bg, color: channel.color }}>{channel.label}</em>}
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}
/>
</Modal>
);
}
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 <svg {...common} strokeWidth={2} style={{ animation: "callSpin .9s linear infinite" }}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>;
case "alert":
return <svg {...common}><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>;
case "declined":
return <svg {...common}><path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
case "missed":
return <svg {...common}><path d="M23 7l-8 8-4-4-9 9" /><polyline points="17 7 23 7 23 13" /></svg>;
case "clock":
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>;
}
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")}`;
}
@@ -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<ApiConversation[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(initialConversationId ?? null);
const [detail, setDetail] = useState<ApiConversation | null>(null);
// Онлайн-звонок текущего диалога: оверлей по baseline «Экран звонка».
const [callOpen, setCallOpen] = useState(false);
const [call, setCall] = useState<ApiCall | null>(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 (
<div className="sales-dialogs">
<DialogList
@@ -198,15 +129,17 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder
setSelectedId={setSelectedId}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={() => void onCall()} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} onClaim={onClaim} onCall={() => void callController.start()} />
<CallOverlay
open={callOpen}
open={callController.open}
dialog={selectedDialog}
call={call}
errorText={callError}
onCancel={() => 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}
/>
<Composer
mode={controlMode}
@@ -1,51 +1,15 @@
/* Экран звонка оператора (baseline «# UI для онлайн звонка/Экран звонка.dc.html»).
Модалка antd без рамок — карточка звонка рисуется целиком этим css. */
.call-modal .ant-modal-content {
padding: 0;
border-radius: 24px;
overflow: hidden;
}
.call-head {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 15px;
border-bottom: 1px solid #e8e8e8;
.call-modal .hub-call-view {
border: 0;
box-shadow: none;
}
.call-head-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex: none;
}
.call-head-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.call-head-name {
display: flex;
align-items: center;
gap: 7px;
font-size: 14px;
font-weight: 600;
color: #1f1f1f;
}
.call-head-name em {
.call-channel {
display: inline-flex;
align-items: center;
padding: 1px 8px;
@@ -54,202 +18,3 @@
font-weight: 600;
font-style: normal;
}
.call-head-subtitle {
font-size: 12px;
color: #8c8c8c;
}
.call-head-close {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid #e8e8e8;
background: #fff;
color: #8c8c8c;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: none;
}
.call-head-close:hover {
background: #f5f5f5;
color: #434343;
}
.call-media {
position: relative;
aspect-ratio: 16 / 9;
background: #141414;
overflow: hidden;
}
.call-media-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.call-rings {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.call-rings i {
position: absolute;
width: 88px;
height: 88px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
animation: callRing 1.9s ease-out infinite;
}
.call-rings i:nth-child(2) {
animation-delay: 0.95s;
}
.call-big-avatar {
width: 88px;
height: 88px;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 600;
position: relative;
z-index: 2;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.call-media-name {
text-align: center;
}
.call-media-name strong {
display: block;
font-size: 15px;
font-weight: 600;
color: #fff;
}
.call-media-name span {
display: block;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.6);
margin-top: 3px;
}
.call-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 22px 26px;
text-align: center;
background: #1a1a1a;
}
.call-status-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.call-status-icon.neutral { background: rgba(255, 255, 255, 0.08); color: #fff; }
.call-status-icon.error { background: rgba(255, 77, 79, 0.16); color: #ff7875; }
.call-status-icon.warn { background: rgba(250, 173, 20, 0.16); color: #ffc53d; }
.call-status-title {
font-size: 15.5px;
font-weight: 600;
color: #fff;
line-height: 1.3;
}
.call-status-caption {
font-size: 12.5px;
color: rgba(255, 255, 255, 0.6);
margin-top: 6px;
line-height: 1.5;
max-width: 280px;
}
.call-status-actions {
display: flex;
gap: 9px;
margin-top: 2px;
}
.call-status-actions button {
height: 38px;
padding: 0 16px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.call-status-actions button.primary {
border: none;
background: #1677ff;
color: #fff;
}
.call-status-actions button.secondary {
border: 1px solid rgba(255, 255, 255, 0.28);
background: transparent;
color: #fff;
}
.call-bar-ringing {
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
padding: 18px 16px 22px;
}
.call-hangup {
width: 58px;
height: 58px;
border-radius: 50%;
border: none;
background: #ff4d4f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.35);
}
.call-bar-ringing span {
font-size: 12.5px;
color: #8c8c8c;
}
@keyframes callRing {
0% { transform: scale(1); opacity: 0.55; }
100% { transform: scale(2.3); opacity: 0; }
}
@keyframes callSpin {
to { transform: rotate(360deg); }
}
@@ -137,10 +137,16 @@ export type ApiCall = {
durationSeconds: number | null;
};
export type CallAccess = { accessToken: string; iceServers: RTCIceServer[] };
export type CreatedCall = { call: ApiCall; access: CallAccess };
// Запрос звонка: при режиме AI backend атомарно выполняет takeover (§6).
export const requestCall = (conversationId: number) =>
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" });
@@ -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<ApiCall | null>(null);
const [access, setAccess] = useState<CallAccess | null>(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,
};
}
+14 -4
View File
@@ -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<WebConfig> {
@@ -70,7 +73,7 @@ export async function poll(token: string, since: number): Promise<Poll> {
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<CallBootstrap | null> {
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<boolean>
}
// Страница звонка: обмен 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<CallBootstrap | null> {
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<CallInfo | null> {
async function callAccessAction(action: "accept" | "decline", accessToken: string): Promise<CallInfo | null> {
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<CallStateEnvelope | null> {
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);
+120 -245
View File
@@ -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/<invite-token> (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<string, Phase> = {
DECLINED: "declined",
CANCELLED: "cancelled",
MISSED: "missed",
EXPIRED: "expired",
ENDED: "ended",
FAILED: "failed",
};
const STATUS_VIEW: Record<string, { icon: "spinner" | "alert" | "declined" | "missed" | "clock"; tone: "neutral" | "error" | "warn"; title: string; caption: string }> = {
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<Phase>("loading");
const [loading, setLoading] = useState(true);
const [invalid, setInvalid] = useState(false);
const [call, setCall] = useState<CallInfo | null>(null);
const [accessToken, setAccessToken] = useState<string>("");
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [mediaError, setMediaError] = useState(false);
const [accessToken, setAccessToken] = useState("");
const [iceServers, setIceServers] = useState<RTCIceServer[]>([]);
const [started, setStarted] = useState(false);
const [joining, setJoining] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const videoRef = useRef<HTMLVideoElement>(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 (
<div style={{ minHeight: "100vh", boxSizing: "border-box", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, background: "#f5f6f8", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#262626" }}>
<div style={{ width: "100%", maxWidth: 428, background: "#fff", border: "1px solid #e8e8e8", borderRadius: 24, boxShadow: "0 12px 40px rgba(0,0,0,0.12)", overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "12px 15px", borderBottom: "1px solid #e8e8e8" }}>
<div style={{ width: 38, height: 38, borderRadius: "50%", background: "#1677ff", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontWeight: 600, flex: "none" }}>{initials}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: "#1f1f1f", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{staffName}</span>
<span style={{ fontSize: 12, color: "#8c8c8c" }}>{subtitle}</span>
</div>
</div>
<div style={{ position: "relative", aspectRatio: "16/9", background: "#141414", overflow: "hidden" }}>
<video ref={videoRef} autoPlay muted playsInline style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: showVideo ? "block" : "none", transform: "scaleX(-1)" }} />
{phase === "precall" && !showVideo && (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16 }}>
<div style={{ width: 88, height: 88, borderRadius: "50%", background: "#595959", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, fontWeight: 600, boxShadow: "0 4px 18px rgba(0,0,0,.35)" }}>ВЫ</div>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: 15, fontWeight: 600, color: "#fff" }}>Вы</div>
<div style={{ fontSize: 12.5, color: "rgba(255,255,255,.6)", marginTop: 3 }}>{mediaError ? "Нет доступа к камере и микрофону" : "Камера выключена"}</div>
</div>
</div>
)}
{status && (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: "22px 26px", textAlign: "center", background: "#1a1a1a" }}>
<StatusIcon name={status.icon} tone={status.tone} />
<div>
<div style={{ fontSize: 15.5, fontWeight: 600, color: "#fff", lineHeight: 1.3 }}>{status.title}</div>
<div style={{ fontSize: 12.5, color: "rgba(255,255,255,.6)", marginTop: 6, lineHeight: 1.5, maxWidth: 280 }}>
{phase === "ended" && call?.durationSeconds != null
? `Длительность ${fmtDuration(call.durationSeconds)}. ${status.caption}`
: status.caption}
</div>
</div>
</div>
)}
</div>
{phase === "precall" && (
<div style={{ display: "flex", flexDirection: "column", gap: 15, padding: "16px 16px 18px" }}>
<div style={{ display: "flex", justifyContent: "center", gap: 32 }}>
<ToggleControl label="Микрофон" on={micOn} disabled={mediaError} onClick={() => setMicOn((v) => !v)} kind="mic" />
<ToggleControl label="Камера" on={camOn} disabled={mediaError} onClick={() => setCamOn((v) => !v)} kind="cam" />
</div>
<div style={{ display: "flex", gap: 10 }}>
<button onClick={() => void decline()} style={{ flex: 1, height: 44, borderRadius: 12, border: "1px solid #d9d9d9", background: "#fff", color: "#262626", fontSize: 14, fontWeight: 600, fontFamily: "inherit", cursor: "pointer" }}>Отклонить</button>
<button onClick={() => void join()} disabled={joining} style={{ flex: 1, height: 44, borderRadius: 12, border: "none", background: "#1677ff", color: "#fff", fontSize: 14, fontWeight: 600, fontFamily: "inherit", cursor: "pointer", boxShadow: "0 2px 8px rgba(22,119,255,.35)" }}>{joining ? "Подключение…" : "Присоединиться"}</button>
</div>
{mediaError && (
<div style={{ fontSize: 12, color: "#8c8c8c", textAlign: "center", lineHeight: 1.5 }}>
Разрешите доступ к камере и микрофону в настройках браузера — или присоединяйтесь без видео.
</div>
)}
</div>
)}
</div>
</div>
<main className="public-call-page">
<CallView
mode={mode}
peerName={peerName}
peerInitials={initials}
subtitle={callViewSubtitle(mode, status)}
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={joining || rtc.preparing}
cancelLabel="Отклонить"
onToggleMic={rtc.toggleMic}
onToggleCam={rtc.toggleCam}
onJoin={() => void join()}
onCancel={() => void cancelPrecall()}
onEnd={rtc.end}
onClose={close}
/>
</main>
);
}
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 (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
<button onClick={onClick} disabled={disabled} aria-label={label} style={{ width: 54, height: 54, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", cursor: disabled ? "default" : "pointer", flex: "none", opacity: disabled ? 0.5 : 1, ...style }}>
<DeviceIcon kind={kind} on={on} />
</button>
<span style={{ fontSize: 12, color: "#8c8c8c" }}>{label}</span>
</div>
);
}
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
? <svg {...common}><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M19 10v1a7 7 0 0 1-14 0v-1" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
: <svg {...common}><line x1="3" y1="3" x2="21" y2="21" /><path d="M9 9v2a3 3 0 0 0 5.1 2.1" /><path d="M15 9.3V5a3 3 0 0 0-5.9-.7" /><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1" /><line x1="12" y1="18" x2="12" y2="22" /></svg>;
}
return on
? <svg {...common}><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
: <svg {...common}><line x1="2" y1="2" x2="22" y2="22" /><path d="M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" /><path d="M10 5h4a2 2 0 0 1 2 2v3l4-3v9" /></svg>;
}
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: <svg {...common} strokeWidth={2} style={{ animation: "edvSpin .9s linear infinite" }}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>,
alert: <svg {...common}><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>,
declined: <svg {...common}><path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>,
missed: <svg {...common}><path d="M23 7l-8 8-4-4-9 9" /><polyline points="17 7 23 7 23 13" /></svg>,
clock: <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>,
};
return (
<div style={{ width: 56, height: 56, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", background: tones.bg, color: tones.fg }}>
{icons[name]}
</div>
);
function useConnectionTimer(active: boolean) {
const [startedAt, setStartedAt] = useState<number | null>(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;
}
+24
View File
@@ -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);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { CallViewMode, CallViewStatus } from "@edevs/ui";
import type { CallInfo } from "../api";
const TERMINAL: Record<string, { icon: CallViewStatus["icon"]; tone: "neutral" | "error" | "warn"; title: string; caption: string }> = {
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")}`;
}
+15
View File
@@ -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;
}
}
+2 -1
View File
@@ -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}"
+13
View File
@@ -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;
+10
View File
@@ -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;
+1
View File
@@ -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"
+270
View File
@@ -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<string, unknown>;
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<string, unknown>): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({ id: commandId(), ...payload }));
}
private async route(message: Record<string, unknown>): Promise<void> {
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<void> {
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<void> {
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<void> {
if (!this.pc?.remoteDescription) {
this.pendingCandidates.push(candidate);
return;
}
try {
await this.pc.addIceCandidate(candidate);
} catch {
/* кандидат от устаревшей negotiation — игнорируем */
}
}
private async flushCandidates(): Promise<void> {
if (!this.pc?.remoteDescription) return;
const candidates = this.pendingCandidates.splice(0);
for (const candidate of candidates) await this.addCandidate(candidate);
}
}
+8
View File
@@ -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";
+1
View File
@@ -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"
+54
View File
@@ -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 ? (
<svg {...common}><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M19 10v1a7 7 0 0 1-14 0v-1" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
) : (
<svg {...common}><line x1="3" y1="3" x2="21" y2="21" /><path d="M9 9v2a3 3 0 0 0 5.1 2.1" /><path d="M15 9.3V5a3 3 0 0 0-5.9-.7" /><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
);
}
return on ? (
<svg {...common}><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
) : (
<svg {...common}><line x1="2" y1="2" x2="22" y2="22" /><path d="M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" /><path d="M10 5h4a2 2 0 0 1 2 2v3l4-3v9" /></svg>
);
}
export function PhoneIcon() {
return <svg {...common} width="24" height="24" style={{ transform: "rotate(135deg)" }}><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
}
export function CloseIcon() {
return <svg {...common} width="17" height="17"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>;
}
export function FullscreenIcon() {
return <svg {...common} width="21" height="21"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>;
}
export function SpinnerIcon() {
return <svg {...common} width="16" height="16" strokeWidth={2} className="hub-call-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>;
}
export type StatusIconName = "spinner" | "alert" | "declined" | "missed" | "clock" | "unsupported";
export function StatusIcon({ name }: { name: StatusIconName }) {
if (name === "spinner") return <svg {...common} width="26" height="26" strokeWidth={2} className="hub-call-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>;
if (name === "alert") return <svg {...common} width="26" height="26"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>;
if (name === "declined") return <svg {...common} width="26" height="26"><path transform="rotate(135 12 12)" d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
if (name === "missed") return <svg {...common} width="26" height="26"><path d="M23 7l-8 8-4-4-9 9" /><polyline points="17 7 23 7 23 13" /></svg>;
if (name === "unsupported") return <svg {...common} width="26" height="26"><circle cx="12" cy="12" r="9" /><line x1="8" y1="8" x2="16" y2="16" /></svg>;
return <svg {...common} width="26" height="26"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>;
}
+147
View File
@@ -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<HTMLVideoElement>(null);
useEffect(() => {
if (ref.current) ref.current.srcObject = stream ?? null;
}, [stream]);
return <video ref={ref} autoPlay playsInline muted={muted} className={`${className}${mirrored ? " mirrored" : ""}`} />;
}
export function CallView(props: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const isLive = props.mode === "active" || props.mode === "reconnecting";
const showRemoteVideo = isLive && props.remoteCamOn !== false && props.remoteStream != null;
const showLocalVideo = props.camOn !== false && props.localStream != null;
const timer = formatDuration(props.elapsedSeconds ?? 0);
return (
<div className="hub-call-view" ref={rootRef}>
<header className="hub-call-head">
<span className="hub-call-avatar small" style={{ background: props.peerAvatarColor ?? "#1677ff" }}>{props.peerInitials}</span>
<div className="hub-call-head-info">
<div className="hub-call-peer">{props.peerName}{props.channelBadge}</div>
<span>{props.subtitle}</span>
</div>
{isLive && <span className={`hub-call-timer ${props.mode === "reconnecting" ? "warn" : ""}`}><i />{timer}</span>}
{props.onClose && <button className="hub-call-close" onClick={props.onClose} aria-label="Закрыть"><CloseIcon /></button>}
</header>
<div className="hub-call-media">
{showRemoteVideo && <Video stream={props.remoteStream} className="hub-call-remote-video" />}
{props.mode === "ringing" && (
<CallAvatar name={props.peerName} initials={props.peerInitials} color={props.peerAvatarColor} caption="Вызываем…" rings />
)}
{props.mode === "precall" && (
showLocalVideo
? <Video stream={props.localStream} muted mirrored className="hub-call-preview-video" />
: <CallAvatar name="Вы" initials="ВЫ" color="#595959" caption={props.mediaCaption ?? "Камера выключена"} />
)}
{isLive && !showRemoteVideo && (
<CallAvatar name={props.peerName} initials={props.peerInitials} color={props.peerAvatarColor} caption="Камера собеседника выключена" />
)}
{isLive && (
<>
{props.mode === "reconnecting" && <div className="hub-call-reconnect"><SpinnerIcon />Связь прервана · восстанавливаем соединение…</div>}
<div className="hub-call-name-label">{props.remoteMicOn === false && <DeviceIcon kind="mic" on={false} />}<span>{props.peerName}</span></div>
<div className="hub-call-pip">
{showLocalVideo ? <Video stream={props.localStream} muted mirrored className="hub-call-local-video" /> : <span className="hub-call-avatar pip">ВЫ</span>}
<b>Вы</b>
</div>
</>
)}
{(props.mode === "connecting" || props.mode === "status") && props.status && <StatusView status={props.status} />}
</div>
{props.mode === "ringing" && props.onCancel && <RingingBar label={props.cancelLabel ?? "Отменить"} onClick={props.onCancel} />}
{props.mode === "precall" && (
<div className="hub-call-precall-bar">
<div className="hub-call-device-row">
<DeviceControl label="Микрофон" kind="mic" on={props.micOn !== false} onClick={props.onToggleMic} />
<DeviceControl label="Камера" kind="cam" on={props.camOn !== false} onClick={props.onToggleCam} />
</div>
<div className="hub-call-join-row">
<button className="secondary" onClick={props.onCancel}>{props.cancelLabel ?? "Отмена"}</button>
<button className="primary" onClick={props.onJoin} disabled={props.joining}>{props.joining ? "Подключение…" : "Присоединиться"}</button>
</div>
</div>
)}
{isLive && (
<div className="hub-call-active-bar">
<DeviceButton kind="mic" on={props.micOn !== false} onClick={props.onToggleMic} />
<DeviceButton kind="cam" on={props.camOn !== false} onClick={props.onToggleCam} />
<button className="hub-call-round" onClick={() => void rootRef.current?.requestFullscreen()} aria-label="Полный экран"><FullscreenIcon /></button>
<button className="hub-call-end" onClick={props.onEnd} aria-label="Завершить"><PhoneIcon /></button>
</div>
)}
</div>
);
}
function CallAvatar({ name, initials, color, caption, rings }: { name: string; initials: string; color?: string; caption: string; rings?: boolean }) {
return <div className="hub-call-avatar-stage"><div className={`hub-call-avatar-wrap${rings ? " rings" : ""}`}><i /><i /><span className="hub-call-avatar big" style={{ background: color ?? "#1677ff" }}>{initials}</span></div><div><strong>{name}</strong><span>{caption}</span></div></div>;
}
function StatusView({ status }: { status: CallViewStatus }) {
return <div className="hub-call-status"><span className={`hub-call-status-icon ${status.tone}`}><StatusIcon name={status.icon} /></span><div><strong>{status.title}</strong><p>{status.caption}</p></div>{status.actions && <div className="hub-call-status-actions">{status.actions.map((action) => <button key={action.label} className={action.kind} onClick={action.onClick}>{action.label}</button>)}</div>}</div>;
}
function DeviceButton({ kind, on, onClick }: { kind: "mic" | "cam"; on: boolean; onClick?: () => void }) {
return <button className={`hub-call-device${on ? "" : " off"}`} onClick={onClick} aria-label={kind === "mic" ? "Микрофон" : "Камера"}><DeviceIcon kind={kind} on={on} /></button>;
}
function DeviceControl({ label, kind, on, onClick }: { label: string; kind: "mic" | "cam"; on: boolean; onClick?: () => void }) {
return <div className="hub-call-device-control"><DeviceButton kind={kind} on={on} onClick={onClick} /><span>{label}</span></div>;
}
function RingingBar({ label, onClick }: { label: string; onClick: () => void }) {
return <div className="hub-call-ringing-bar"><button className="hub-call-end round" onClick={onClick} aria-label={label}><PhoneIcon /></button><span>{label}</span></div>;
}
function formatDuration(seconds: number) {
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
}
+58
View File
@@ -0,0 +1,58 @@
.hub-call-view { width:100%; max-width:428px; background:#fff; border:1px solid #e8e8e8; border-radius:24px; box-shadow:0 12px 40px rgba(0,0,0,.12); overflow:hidden; color:#262626; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; }
.hub-call-head { display:flex; align-items:center; gap:11px; padding:12px 15px; border-bottom:1px solid #e8e8e8; }
.hub-call-avatar { display:flex; align-items:center; justify-content:center; border-radius:50%; color:#fff; font-weight:600; flex:none; }
.hub-call-avatar.small { width:38px; height:38px; font-size:14px; }
.hub-call-avatar.big { width:88px; height:88px; font-size:30px; position:relative; z-index:2; box-shadow:0 4px 18px rgba(0,0,0,.35); }
.hub-call-avatar.pip { width:34px; height:34px; font-size:12px; background:#595959; position:absolute; inset:0; margin:auto; }
.hub-call-head-info { display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; }
.hub-call-head-info > span { font-size:12px; color:#8c8c8c; }
.hub-call-peer { display:flex; align-items:center; gap:7px; min-width:0; font-size:14px; font-weight:600; color:#1f1f1f; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.hub-call-close { width:34px; height:34px; border-radius:8px; border:1px solid #e8e8e8; background:#fff; color:#8c8c8c; display:flex; align-items:center; justify-content:center; cursor:pointer; flex:none; }
.hub-call-timer { display:flex; align-items:center; gap:6px; padding:4px 9px; border-radius:999px; background:#fff1f0; color:#cf1322; font-size:11.5px; font-weight:600; }
.hub-call-timer.warn { background:#fffbe6; color:#ad6800; }
.hub-call-timer i { width:6px; height:6px; border-radius:50%; background:#ff4d4f; }
.hub-call-media { position:relative; aspect-ratio:16/9; background:#141414; overflow:hidden; }
.hub-call-remote-video,.hub-call-preview-video,.hub-call-local-video { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.hub-call-preview-video.mirrored,.hub-call-local-video.mirrored { transform:scaleX(-1); }
.hub-call-avatar-stage { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; z-index:3; }
.hub-call-avatar-stage > div:last-child { text-align:center; }
.hub-call-avatar-stage strong { display:block; font-size:15px; color:#fff; }
.hub-call-avatar-stage div > span { display:block; margin-top:3px; color:rgba(255,255,255,.6); font-size:12.5px; }
.hub-call-avatar-wrap { position:relative; display:flex; align-items:center; justify-content:center; }
.hub-call-avatar-wrap > i { display:none; position:absolute; width:88px; height:88px; border-radius:50%; border:2px solid rgba(255,255,255,.6); animation:hub-call-ring 1.9s ease-out infinite; }
.hub-call-avatar-wrap.rings > i { display:block; }
.hub-call-avatar-wrap.rings > i:nth-child(2) { animation-delay:.95s; }
.hub-call-reconnect { position:absolute; top:0; left:0; right:0; z-index:6; display:flex; align-items:center; justify-content:center; gap:9px; padding:9px 12px; background:rgba(20,20,20,.82); color:#fff; font-size:12.5px; font-weight:500; }
.hub-call-name-label { position:absolute; left:12px; bottom:12px; z-index:5; display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:8px; background:rgba(0,0,0,.5); color:#fff; font-size:12px; font-weight:500; }
.hub-call-name-label svg { width:14px; height:14px; color:#ff7875; }
.hub-call-pip { position:absolute; right:12px; bottom:12px; width:100px; height:66px; z-index:4; overflow:hidden; border:2px solid rgba(255,255,255,.9); border-radius:10px; background:#1f1f1f; box-shadow:0 4px 14px rgba(0,0,0,.4); }
.hub-call-pip b { position:absolute; left:5px; bottom:4px; color:#fff; font-size:10px; text-shadow:0 1px 2px rgba(0,0,0,.6); }
.hub-call-status { position:absolute; inset:0; z-index:7; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:22px 26px; text-align:center; background:#1a1a1a; }
.hub-call-status-icon { width:56px; height:56px; display:flex; align-items:center; justify-content:center; border-radius:50%; font-size:24px; font-weight:600; }
.hub-call-status-icon.neutral { background:rgba(255,255,255,.08); color:#fff; }
.hub-call-status-icon.error { background:rgba(255,77,79,.16); color:#ff7875; }
.hub-call-status-icon.warn { background:rgba(250,173,20,.16); color:#ffc53d; }
.hub-call-status strong { color:#fff; font-size:15.5px; line-height:1.3; }
.hub-call-status p { max-width:280px; margin:6px 0 0; color:rgba(255,255,255,.6); font-size:12.5px; line-height:1.5; }
.hub-call-status-actions { display:flex; gap:9px; margin-top:2px; }
.hub-call-status-actions button,.hub-call-join-row button { height:38px; padding:0 16px; border-radius:10px; font:600 13.5px inherit; cursor:pointer; }
.hub-call-status-actions .primary,.hub-call-join-row .primary { border:0; background:#1677ff; color:#fff; }
.hub-call-status-actions .secondary { border:1px solid rgba(255,255,255,.28); background:transparent; color:#fff; }
.hub-call-ringing-bar { display:flex; flex-direction:column; align-items:center; gap:9px; padding:18px 16px 22px; }
.hub-call-ringing-bar span,.hub-call-device-control span { color:#8c8c8c; font-size:12px; }
.hub-call-precall-bar { display:flex; flex-direction:column; gap:15px; padding:16px 16px 18px; }
.hub-call-device-row { display:flex; justify-content:center; gap:32px; }
.hub-call-device-control { display:flex; flex-direction:column; align-items:center; gap:8px; }
.hub-call-device,.hub-call-round { width:54px; height:54px; display:flex; align-items:center; justify-content:center; border:1px solid #d9d9d9; border-radius:50%; background:#f5f5f5; color:#434343; cursor:pointer; }
.hub-call-device.off { border-color:#ffccc7; background:#fff2f0; color:#cf1322; }
.hub-call-join-row { display:flex; gap:10px; }
.hub-call-join-row button { flex:1; height:44px; }
.hub-call-join-row .secondary { border:1px solid #d9d9d9; background:#fff; color:#262626; }
.hub-call-join-row button:disabled { cursor:default; opacity:.6; }
.hub-call-active-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:16px; }
.hub-call-end { width:60px; height:54px; border:0; border-radius:27px; display:flex; align-items:center; justify-content:center; background:#ff4d4f; color:#fff; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35); }
.hub-call-end.round { width:58px; height:58px; border-radius:50%; }
.hub-call-spin { animation:hub-call-spin .9s linear infinite; }
@keyframes hub-call-ring { from { transform:scale(1); opacity:.55; } to { transform:scale(2.3); opacity:0; } }
@keyframes hub-call-spin { to { transform:rotate(360deg); } }
@media (max-width:480px) { .hub-call-view { max-width:none; border-radius:18px; } .hub-call-head { padding:11px 12px; } .hub-call-pip { width:88px; height:58px; } }
+159
View File
@@ -0,0 +1,159 @@
import { CallRtcClient, type CallSide, type PublicCallState, type RtcConnectionPhase } from "@edevs/shared";
import { useCallback, useEffect, useRef, useState } from "react";
export type CallMediaIssue = "none" | "video" | "devices" | "unsupported";
type Options = {
resetKey: string;
previewEnabled: boolean;
accessToken: string;
side: CallSide;
iceServers: RTCIceServer[];
onCallState: (call: PublicCallState) => void;
};
const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]);
export function useCallRtcSession(options: Options) {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [micOn, setMicOn] = useState(true);
const [camOn, setCamOn] = useState(true);
const [remoteMicOn, setRemoteMicOn] = useState(true);
const [remoteCamOn, setRemoteCamOn] = useState(true);
const [mediaIssue, setMediaIssue] = useState<CallMediaIssue>("none");
const [connectionPhase, setConnectionPhase] = useState<RtcConnectionPhase | "idle">("idle");
const [preparing, setPreparing] = useState(false);
const localRef = useRef<MediaStream | null>(null);
const clientRef = useRef<CallRtcClient | null>(null);
const preparingRef = useRef(false);
const onCallStateRef = useRef(options.onCallState);
onCallStateRef.current = options.onCallState;
const stop = useCallback(() => {
clientRef.current?.dispose();
clientRef.current = null;
localRef.current?.getTracks().forEach((track) => track.stop());
localRef.current = null;
setLocalStream(null);
setRemoteStream(null);
}, []);
const prepare = useCallback(async () => {
if (localRef.current || preparingRef.current || !navigator.mediaDevices?.getUserMedia || typeof RTCPeerConnection === "undefined") {
if (!navigator.mediaDevices?.getUserMedia || typeof RTCPeerConnection === "undefined") setMediaIssue("unsupported");
return;
}
preparingRef.current = true;
setPreparing(true);
try {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
setMediaIssue("none");
} catch {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
setCamOn(false);
setMediaIssue("video");
}
localRef.current = stream;
setLocalStream(stream);
} catch {
setCamOn(false);
setMicOn(false);
setMediaIssue("devices");
} finally {
preparingRef.current = false;
setPreparing(false);
}
}, []);
useEffect(() => {
stop();
setMicOn(true);
setCamOn(true);
setRemoteMicOn(true);
setRemoteCamOn(true);
setMediaIssue("none");
setConnectionPhase("idle");
}, [options.resetKey, stop]);
useEffect(() => {
if (options.previewEnabled) void prepare();
}, [options.previewEnabled, prepare]);
useEffect(() => stop, [stop]);
const start = useCallback(() => {
if (!options.accessToken || clientRef.current) return false;
if (mediaIssue === "devices") setMediaIssue("none");
const client = new CallRtcClient({
accessToken: options.accessToken,
side: options.side,
iceServers: options.iceServers,
localStream: localRef.current,
handlers: {
onCallState: (call) => {
onCallStateRef.current(call);
if (TERMINAL.has(call.status)) queueMicrotask(stop);
},
onRemoteStream: setRemoteStream,
onRemoteMedia: ({ mic, cam }) => {
setRemoteMicOn(mic);
setRemoteCamOn(cam);
},
onConnection: setConnectionPhase,
onClosed: stop,
},
});
clientRef.current = client;
setConnectionPhase("connecting");
client.start();
return true;
}, [mediaIssue, options.accessToken, options.iceServers, options.side, stop]);
const toggleMic = useCallback(() => {
setMicOn((current) => {
const next = !current;
clientRef.current?.setMediaState(next, camOn);
localRef.current?.getAudioTracks().forEach((track) => { track.enabled = next; });
return next;
});
}, [camOn]);
const toggleCam = useCallback(() => {
setCamOn((current) => {
const next = !current;
clientRef.current?.setMediaState(micOn, next);
localRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; });
return next;
});
}, [micOn]);
const end = useCallback(() => clientRef.current?.end(), []);
const restart = useCallback(async () => {
stop();
setConnectionPhase("connecting");
await prepare();
return start();
}, [prepare, start, stop]);
return {
localStream,
remoteStream,
micOn,
camOn,
remoteMicOn,
remoteCamOn,
mediaIssue,
connectionPhase,
preparing,
prepare,
start,
restart,
end,
stop,
toggleMic,
toggleCam,
};
}
+2
View File
@@ -1 +1,3 @@
export { edevsHubTheme } from "./theme";
export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView";
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";