mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
feat(calls): Step B
This commit is contained in:
1 parent
bb9fc5d210
commit
bba6f17557
38 files changed
+1864
-699
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export { edevsHubTheme } from "./theme";
|
||||
export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView";
|
||||
export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession";
|
||||
Reference in new issue
Block a user