mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-02-07 11:16:46 +00:00
wip
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import assert_never
|
from typing import assert_never
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
@@ -12,6 +13,9 @@ from reflector.dailyco_api import (
|
|||||||
RecordingReadyEvent,
|
RecordingReadyEvent,
|
||||||
RecordingStartedEvent,
|
RecordingStartedEvent,
|
||||||
)
|
)
|
||||||
|
from reflector.db.daily_participant_sessions import (
|
||||||
|
daily_participant_sessions_controller,
|
||||||
|
)
|
||||||
from reflector.db.meetings import meetings_controller
|
from reflector.db.meetings import meetings_controller
|
||||||
from reflector.logger import logger as _logger
|
from reflector.logger import logger as _logger
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
@@ -141,15 +145,57 @@ async def _handle_participant_joined(event: ParticipantJoinedEvent):
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_participant_left(event: ParticipantLeftEvent):
|
async def _handle_participant_left(event: ParticipantLeftEvent):
|
||||||
"""Queue poll task for presence reconciliation."""
|
"""Close session directly on webhook and update num_clients.
|
||||||
await _queue_poll_for_room(
|
|
||||||
event.payload.room_name,
|
The webhook IS the authoritative signal that a participant left.
|
||||||
"participant.left",
|
We close the session immediately rather than polling Daily.co API,
|
||||||
event.payload.user_id,
|
which avoids the race where the API still shows the participant.
|
||||||
event.payload.session_id,
|
A delayed reconciliation poll is queued as a safety net.
|
||||||
duration=event.payload.duration,
|
"""
|
||||||
|
room_name = event.payload.room_name
|
||||||
|
if not room_name:
|
||||||
|
logger.warning("participant.left: no room in payload")
|
||||||
|
return
|
||||||
|
|
||||||
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||||
|
if not meeting:
|
||||||
|
logger.warning("participant.left: meeting not found", room_name=room_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
log = logger.bind(
|
||||||
|
meeting_id=meeting.id,
|
||||||
|
room_name=room_name,
|
||||||
|
session_id=event.payload.session_id,
|
||||||
|
user_id=event.payload.user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
existing = await daily_participant_sessions_controller.get_open_session(
|
||||||
|
meeting.id, event.payload.session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await daily_participant_sessions_controller.batch_close_sessions(
|
||||||
|
[existing.id], left_at=now
|
||||||
|
)
|
||||||
|
active = await daily_participant_sessions_controller.get_active_by_meeting(
|
||||||
|
meeting.id
|
||||||
|
)
|
||||||
|
await meetings_controller.update_meeting(meeting.id, num_clients=len(active))
|
||||||
|
log.info(
|
||||||
|
"Participant left - session closed",
|
||||||
|
remaining_clients=len(active),
|
||||||
|
duration=event.payload.duration,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"Participant left - no open session found, skipping direct close",
|
||||||
|
duration=event.payload.duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delayed reconciliation poll as safety net
|
||||||
|
poll_daily_room_presence_task.apply_async(args=[meeting.id], countdown=5)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_recording_started(event: RecordingStartedEvent):
|
async def _handle_recording_started(event: RecordingStartedEvent):
|
||||||
room_name = event.payload.room_name
|
room_name = event.payload.room_name
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, Literal, Optional
|
from typing import Annotated, Any, Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi_pagination import Page
|
from fastapi_pagination import Page
|
||||||
from fastapi_pagination.ext.databases import apaginate
|
from fastapi_pagination.ext.databases import apaginate
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -11,6 +12,10 @@ from redis.exceptions import LockError
|
|||||||
import reflector.auth as auth
|
import reflector.auth as auth
|
||||||
from reflector.db import get_database
|
from reflector.db import get_database
|
||||||
from reflector.db.calendar_events import calendar_events_controller
|
from reflector.db.calendar_events import calendar_events_controller
|
||||||
|
from reflector.db.daily_participant_sessions import (
|
||||||
|
DailyParticipantSession,
|
||||||
|
daily_participant_sessions_controller,
|
||||||
|
)
|
||||||
from reflector.db.meetings import meetings_controller
|
from reflector.db.meetings import meetings_controller
|
||||||
from reflector.db.rooms import rooms_controller
|
from reflector.db.rooms import rooms_controller
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
@@ -617,6 +622,12 @@ class JoinedRequest(BaseModel):
|
|||||||
connection_id: NonEmptyString
|
connection_id: NonEmptyString
|
||||||
"""Must match the connection_id sent to /joining."""
|
"""Must match the connection_id sent to /joining."""
|
||||||
|
|
||||||
|
session_id: NonEmptyString | None = None
|
||||||
|
"""Daily.co session_id for direct session creation. Optional for backward compat."""
|
||||||
|
|
||||||
|
user_name: str | None = None
|
||||||
|
"""Display name from Daily.co participant data."""
|
||||||
|
|
||||||
|
|
||||||
class JoinedResponse(BaseModel):
|
class JoinedResponse(BaseModel):
|
||||||
status: Literal["ok"]
|
status: Literal["ok"]
|
||||||
@@ -716,9 +727,32 @@ async def meeting_joined(
|
|||||||
finally:
|
finally:
|
||||||
await redis.aclose()
|
await redis.aclose()
|
||||||
|
|
||||||
# Trigger presence poll to detect the new participant faster than periodic poll
|
# Create session directly when session_id provided (instant presence update)
|
||||||
|
if body.session_id and meeting.platform == "daily":
|
||||||
|
session = DailyParticipantSession(
|
||||||
|
id=f"{meeting.id}:{body.session_id}",
|
||||||
|
meeting_id=meeting.id,
|
||||||
|
room_id=room.id,
|
||||||
|
session_id=body.session_id,
|
||||||
|
user_id=user["sub"] if user else None,
|
||||||
|
user_name=body.user_name or "Anonymous",
|
||||||
|
joined_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
await daily_participant_sessions_controller.batch_upsert_sessions([session])
|
||||||
|
|
||||||
|
active = await daily_participant_sessions_controller.get_active_by_meeting(
|
||||||
|
meeting.id
|
||||||
|
)
|
||||||
|
await meetings_controller.update_meeting(meeting.id, num_clients=len(active))
|
||||||
|
log.info(
|
||||||
|
"Session created directly",
|
||||||
|
session_id=body.session_id,
|
||||||
|
num_clients=len(active),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger presence poll as reconciliation safety net
|
||||||
if meeting.platform == "daily":
|
if meeting.platform == "daily":
|
||||||
poll_daily_room_presence_task.delay(meeting_id)
|
poll_daily_room_presence_task.apply_async(args=[meeting_id], countdown=3)
|
||||||
|
|
||||||
return JoinedResponse(status="ok")
|
return JoinedResponse(status="ok")
|
||||||
|
|
||||||
@@ -733,14 +767,28 @@ class LeaveResponse(BaseModel):
|
|||||||
async def meeting_leave(
|
async def meeting_leave(
|
||||||
room_name: str,
|
room_name: str,
|
||||||
meeting_id: str,
|
meeting_id: str,
|
||||||
|
request: Request,
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
) -> LeaveResponse:
|
) -> LeaveResponse:
|
||||||
"""Trigger presence recheck when user leaves meeting.
|
"""Trigger presence update when user leaves meeting.
|
||||||
|
|
||||||
Called on tab close/navigation via sendBeacon(). Immediately queues presence
|
When session_id is provided in the body, closes the session directly
|
||||||
poll to detect dirty disconnects faster than 30s periodic poll.
|
for instant presence update. Falls back to polling when session_id
|
||||||
Daily.co webhooks handle clean disconnects, but tab close/crash need this.
|
is not available (e.g., sendBeacon without frame access).
|
||||||
|
Called on tab close/navigation via sendBeacon().
|
||||||
"""
|
"""
|
||||||
|
# Parse session_id from body (sendBeacon may send text/plain or no body)
|
||||||
|
session_id: str | None = None
|
||||||
|
try:
|
||||||
|
body_bytes = await request.body()
|
||||||
|
if body_bytes:
|
||||||
|
data = json.loads(body_bytes)
|
||||||
|
raw = data.get("session_id")
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
session_id = raw.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
room = await rooms_controller.get_by_name(room_name)
|
room = await rooms_controller.get_by_name(room_name)
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail="Room not found")
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
@@ -749,7 +797,27 @@ async def meeting_leave(
|
|||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
|
||||||
if meeting.platform == "daily":
|
# Close session directly when session_id provided
|
||||||
poll_daily_room_presence_task.delay(meeting_id)
|
session_closed = False
|
||||||
|
if session_id and meeting.platform == "daily":
|
||||||
|
existing = await daily_participant_sessions_controller.get_open_session(
|
||||||
|
meeting.id, session_id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await daily_participant_sessions_controller.batch_close_sessions(
|
||||||
|
[existing.id], left_at=datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
active = await daily_participant_sessions_controller.get_active_by_meeting(
|
||||||
|
meeting.id
|
||||||
|
)
|
||||||
|
await meetings_controller.update_meeting(
|
||||||
|
meeting.id, num_clients=len(active)
|
||||||
|
)
|
||||||
|
session_closed = True
|
||||||
|
|
||||||
|
# Only queue poll if we couldn't close directly — the poll runs before
|
||||||
|
# Daily.co API removes the participant, which would undo our correct count
|
||||||
|
if meeting.platform == "daily" and not session_closed:
|
||||||
|
poll_daily_room_presence_task.apply_async(args=[meeting_id], countdown=3)
|
||||||
|
|
||||||
return LeaveResponse(status="ok")
|
return LeaveResponse(status="ok")
|
||||||
|
|||||||
213
server/tests/test_daily_webhook_participant_left.py
Normal file
213
server/tests/test_daily_webhook_participant_left.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Tests for direct session close on participant.left webhook.
|
||||||
|
|
||||||
|
Verifies that _handle_participant_left:
|
||||||
|
1. Closes the session directly (authoritative signal)
|
||||||
|
2. Updates num_clients from remaining active sessions
|
||||||
|
3. Queues a delayed reconciliation poll as safety net
|
||||||
|
4. Handles missing session gracefully
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from reflector.dailyco_api.webhooks import ParticipantLeftEvent, ParticipantLeftPayload
|
||||||
|
from reflector.db.daily_participant_sessions import DailyParticipantSession
|
||||||
|
from reflector.db.meetings import Meeting
|
||||||
|
from reflector.views.daily import _handle_participant_left
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_meeting():
|
||||||
|
return Meeting(
|
||||||
|
id="meeting-123",
|
||||||
|
room_id="room-456",
|
||||||
|
room_name="test-room-20251118120000",
|
||||||
|
room_url="https://daily.co/test-room-20251118120000",
|
||||||
|
host_room_url="https://daily.co/test-room-20251118120000?t=host-token",
|
||||||
|
platform="daily",
|
||||||
|
num_clients=2,
|
||||||
|
is_active=True,
|
||||||
|
start_date=datetime.now(timezone.utc),
|
||||||
|
end_date=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def participant_left_event():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return ParticipantLeftEvent(
|
||||||
|
version="1.0.0",
|
||||||
|
type="participant.left",
|
||||||
|
id="evt-left-abc123",
|
||||||
|
payload=ParticipantLeftPayload(
|
||||||
|
room_name="test-room-20251118120000",
|
||||||
|
session_id="session-alice",
|
||||||
|
user_id="user-alice",
|
||||||
|
user_name="Alice",
|
||||||
|
joined_at=int((now - timedelta(minutes=10)).timestamp()),
|
||||||
|
duration=600,
|
||||||
|
),
|
||||||
|
event_ts=int(now.timestamp()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def existing_session():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return DailyParticipantSession(
|
||||||
|
id="meeting-123:session-alice",
|
||||||
|
meeting_id="meeting-123",
|
||||||
|
room_id="room-456",
|
||||||
|
session_id="session-alice",
|
||||||
|
user_id="user-alice",
|
||||||
|
user_name="Alice",
|
||||||
|
joined_at=now - timedelta(minutes=10),
|
||||||
|
left_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.daily.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.daily.meetings_controller")
|
||||||
|
@patch("reflector.views.daily.daily_participant_sessions_controller")
|
||||||
|
async def test_closes_session_and_updates_num_clients(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_meeting,
|
||||||
|
participant_left_event,
|
||||||
|
existing_session,
|
||||||
|
):
|
||||||
|
"""Webhook directly closes session and updates num_clients from remaining active count."""
|
||||||
|
mock_meetings_ctrl.get_by_room_name = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_sessions_ctrl.get_open_session = AsyncMock(return_value=existing_session)
|
||||||
|
mock_sessions_ctrl.batch_close_sessions = AsyncMock()
|
||||||
|
# One remaining active session after close
|
||||||
|
remaining = DailyParticipantSession(
|
||||||
|
id="meeting-123:session-bob",
|
||||||
|
meeting_id="meeting-123",
|
||||||
|
room_id="room-456",
|
||||||
|
session_id="session-bob",
|
||||||
|
user_id="user-bob",
|
||||||
|
user_name="Bob",
|
||||||
|
joined_at=datetime.now(timezone.utc),
|
||||||
|
left_at=None,
|
||||||
|
)
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(return_value=[remaining])
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
await _handle_participant_left(participant_left_event)
|
||||||
|
|
||||||
|
# Session closed
|
||||||
|
mock_sessions_ctrl.batch_close_sessions.assert_called_once()
|
||||||
|
closed_ids = mock_sessions_ctrl.batch_close_sessions.call_args.args[0]
|
||||||
|
assert closed_ids == [existing_session.id]
|
||||||
|
|
||||||
|
# num_clients updated to remaining count
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_called_once_with(
|
||||||
|
mock_meeting.id, num_clients=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delayed reconciliation poll queued
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
|
call_kwargs = mock_poll_task.apply_async.call_args.kwargs
|
||||||
|
assert call_kwargs["countdown"] == 5
|
||||||
|
assert call_kwargs["args"] == [mock_meeting.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.daily.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.daily.meetings_controller")
|
||||||
|
@patch("reflector.views.daily.daily_participant_sessions_controller")
|
||||||
|
async def test_handles_missing_session(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_meeting,
|
||||||
|
participant_left_event,
|
||||||
|
):
|
||||||
|
"""No crash when session not found in DB — still queues reconciliation poll."""
|
||||||
|
mock_meetings_ctrl.get_by_room_name = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_sessions_ctrl.get_open_session = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await _handle_participant_left(participant_left_event)
|
||||||
|
|
||||||
|
# No session close attempted
|
||||||
|
mock_sessions_ctrl.batch_close_sessions.assert_not_called()
|
||||||
|
# No num_clients update (no authoritative data without session)
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_not_called()
|
||||||
|
# Still queues reconciliation poll
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.daily.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.daily.meetings_controller")
|
||||||
|
@patch("reflector.views.daily.daily_participant_sessions_controller")
|
||||||
|
async def test_updates_num_clients_to_zero_when_last_participant_leaves(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_meeting,
|
||||||
|
participant_left_event,
|
||||||
|
existing_session,
|
||||||
|
):
|
||||||
|
"""num_clients set to 0 when no active sessions remain."""
|
||||||
|
mock_meetings_ctrl.get_by_room_name = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_sessions_ctrl.get_open_session = AsyncMock(return_value=existing_session)
|
||||||
|
mock_sessions_ctrl.batch_close_sessions = AsyncMock()
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(return_value=[])
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
await _handle_participant_left(participant_left_event)
|
||||||
|
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_called_once_with(
|
||||||
|
mock_meeting.id, num_clients=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.daily.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.daily.meetings_controller")
|
||||||
|
async def test_no_room_name_in_event(
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
):
|
||||||
|
"""No crash when room_name is missing from webhook payload."""
|
||||||
|
event = ParticipantLeftEvent(
|
||||||
|
version="1.0.0",
|
||||||
|
type="participant.left",
|
||||||
|
id="evt-left-no-room",
|
||||||
|
payload=ParticipantLeftPayload(
|
||||||
|
room_name=None,
|
||||||
|
session_id="session-x",
|
||||||
|
user_id="user-x",
|
||||||
|
user_name="X",
|
||||||
|
joined_at=int(datetime.now(timezone.utc).timestamp()),
|
||||||
|
duration=0,
|
||||||
|
),
|
||||||
|
event_ts=int(datetime.now(timezone.utc).timestamp()),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _handle_participant_left(event)
|
||||||
|
|
||||||
|
mock_meetings_ctrl.get_by_room_name.assert_not_called()
|
||||||
|
mock_poll_task.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.daily.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.daily.meetings_controller")
|
||||||
|
async def test_meeting_not_found(
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
participant_left_event,
|
||||||
|
):
|
||||||
|
"""No crash when meeting not found for room_name."""
|
||||||
|
mock_meetings_ctrl.get_by_room_name = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
await _handle_participant_left(participant_left_event)
|
||||||
|
|
||||||
|
mock_poll_task.apply_async.assert_not_called()
|
||||||
339
server/tests/test_direct_session_management.py
Normal file
339
server/tests/test_direct_session_management.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""Tests for direct session management via /joined and /leave endpoints.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
1. /joined with session_id creates session directly, updates num_clients
|
||||||
|
2. /joined without session_id (backward compat) still works, queues poll
|
||||||
|
3. /leave with session_id closes session, updates num_clients
|
||||||
|
4. /leave without session_id falls back to poll
|
||||||
|
5. Duplicate /joined calls are idempotent (upsert)
|
||||||
|
6. /leave for already-closed session is a no-op
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from reflector.db.daily_participant_sessions import DailyParticipantSession
|
||||||
|
from reflector.db.meetings import Meeting
|
||||||
|
from reflector.views.rooms import (
|
||||||
|
JoinedRequest,
|
||||||
|
meeting_joined,
|
||||||
|
meeting_leave,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_room():
|
||||||
|
room = MagicMock()
|
||||||
|
room.id = "room-456"
|
||||||
|
room.name = "test-room"
|
||||||
|
room.platform = "daily"
|
||||||
|
return room
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_meeting():
|
||||||
|
return Meeting(
|
||||||
|
id="meeting-123",
|
||||||
|
room_id="room-456",
|
||||||
|
room_name="test-room-20251118120000",
|
||||||
|
room_url="https://daily.co/test-room",
|
||||||
|
host_room_url="https://daily.co/test-room?t=host",
|
||||||
|
platform="daily",
|
||||||
|
num_clients=0,
|
||||||
|
is_active=True,
|
||||||
|
start_date=datetime.now(timezone.utc),
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_redis():
|
||||||
|
redis = AsyncMock()
|
||||||
|
redis.aclose = AsyncMock()
|
||||||
|
return redis
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request_with_session_id():
|
||||||
|
"""Mock Request with session_id in JSON body."""
|
||||||
|
request = AsyncMock()
|
||||||
|
request.body = AsyncMock(return_value=b'{"session_id": "session-abc"}')
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request_empty_body():
|
||||||
|
"""Mock Request with empty JSON body (old frontend / no frame access)."""
|
||||||
|
request = AsyncMock()
|
||||||
|
request.body = AsyncMock(return_value=b"{}")
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.delete_pending_join")
|
||||||
|
@patch("reflector.views.rooms.get_async_redis_client")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_joined_with_session_id_creates_session(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_redis_client,
|
||||||
|
mock_delete_pending,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_redis,
|
||||||
|
):
|
||||||
|
"""session_id in /joined -> create session + update num_clients."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_redis_client.return_value = mock_redis
|
||||||
|
mock_sessions_ctrl.batch_upsert_sessions = AsyncMock()
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(
|
||||||
|
return_value=[MagicMock()] # 1 active session
|
||||||
|
)
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
body = JoinedRequest(
|
||||||
|
connection_id="conn-1",
|
||||||
|
session_id="session-abc",
|
||||||
|
user_name="Alice",
|
||||||
|
)
|
||||||
|
result = await meeting_joined(
|
||||||
|
"test-room", "meeting-123", body, user={"sub": "user-1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
|
||||||
|
# Session created via upsert
|
||||||
|
mock_sessions_ctrl.batch_upsert_sessions.assert_called_once()
|
||||||
|
sessions = mock_sessions_ctrl.batch_upsert_sessions.call_args.args[0]
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0].session_id == "session-abc"
|
||||||
|
assert sessions[0].meeting_id == "meeting-123"
|
||||||
|
assert sessions[0].room_id == "room-456"
|
||||||
|
assert sessions[0].user_name == "Alice"
|
||||||
|
assert sessions[0].user_id == "user-1"
|
||||||
|
assert sessions[0].id == "meeting-123:session-abc"
|
||||||
|
|
||||||
|
# num_clients updated
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_called_once_with(
|
||||||
|
"meeting-123", num_clients=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reconciliation poll still queued
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.delete_pending_join")
|
||||||
|
@patch("reflector.views.rooms.get_async_redis_client")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_joined_without_session_id_backward_compat(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_redis_client,
|
||||||
|
mock_delete_pending,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_redis,
|
||||||
|
):
|
||||||
|
"""No session_id in /joined -> no session create, still queues poll."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_redis_client.return_value = mock_redis
|
||||||
|
|
||||||
|
body = JoinedRequest(connection_id="conn-1")
|
||||||
|
result = await meeting_joined(
|
||||||
|
"test-room", "meeting-123", body, user={"sub": "user-1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
mock_sessions_ctrl.batch_upsert_sessions.assert_not_called()
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.delete_pending_join")
|
||||||
|
@patch("reflector.views.rooms.get_async_redis_client")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_joined_anonymous_user_sets_null_user_id(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_redis_client,
|
||||||
|
mock_delete_pending,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_redis,
|
||||||
|
):
|
||||||
|
"""Anonymous user -> session.user_id is None, user_name defaults to 'Anonymous'."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_redis_client.return_value = mock_redis
|
||||||
|
mock_sessions_ctrl.batch_upsert_sessions = AsyncMock()
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(return_value=[MagicMock()])
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
body = JoinedRequest(connection_id="conn-1", session_id="session-abc")
|
||||||
|
result = await meeting_joined("test-room", "meeting-123", body, user=None)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
sessions = mock_sessions_ctrl.batch_upsert_sessions.call_args.args[0]
|
||||||
|
assert sessions[0].user_id is None
|
||||||
|
assert sessions[0].user_name == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_leave_with_session_id_closes_session(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_request_with_session_id,
|
||||||
|
):
|
||||||
|
"""session_id in /leave -> close session + update num_clients."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
|
||||||
|
existing_session = DailyParticipantSession(
|
||||||
|
id="meeting-123:session-abc",
|
||||||
|
meeting_id="meeting-123",
|
||||||
|
room_id="room-456",
|
||||||
|
session_id="session-abc",
|
||||||
|
user_id="user-1",
|
||||||
|
user_name="Alice",
|
||||||
|
joined_at=datetime.now(timezone.utc) - timedelta(minutes=5),
|
||||||
|
left_at=None,
|
||||||
|
)
|
||||||
|
mock_sessions_ctrl.get_open_session = AsyncMock(return_value=existing_session)
|
||||||
|
mock_sessions_ctrl.batch_close_sessions = AsyncMock()
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(return_value=[])
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
result = await meeting_leave(
|
||||||
|
"test-room", "meeting-123", mock_request_with_session_id, user={"sub": "user-1"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
|
||||||
|
# Session closed
|
||||||
|
mock_sessions_ctrl.batch_close_sessions.assert_called_once()
|
||||||
|
closed_ids = mock_sessions_ctrl.batch_close_sessions.call_args.args[0]
|
||||||
|
assert closed_ids == ["meeting-123:session-abc"]
|
||||||
|
|
||||||
|
# num_clients updated
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_called_once_with(
|
||||||
|
"meeting-123", num_clients=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# No poll — direct close is authoritative, poll would race with API latency
|
||||||
|
mock_poll_task.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
async def test_leave_without_session_id_falls_back_to_poll(
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_request_empty_body,
|
||||||
|
):
|
||||||
|
"""No session_id in /leave -> just queues poll as before."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
|
||||||
|
result = await meeting_leave(
|
||||||
|
"test-room", "meeting-123", mock_request_empty_body, user=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.delete_pending_join")
|
||||||
|
@patch("reflector.views.rooms.get_async_redis_client")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_duplicate_joined_is_idempotent(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_redis_client,
|
||||||
|
mock_delete_pending,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_redis,
|
||||||
|
):
|
||||||
|
"""Calling /joined twice with same session_id -> upsert both times, no error."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_redis_client.return_value = mock_redis
|
||||||
|
mock_sessions_ctrl.batch_upsert_sessions = AsyncMock()
|
||||||
|
mock_sessions_ctrl.get_active_by_meeting = AsyncMock(return_value=[MagicMock()])
|
||||||
|
mock_meetings_ctrl.update_meeting = AsyncMock()
|
||||||
|
|
||||||
|
body = JoinedRequest(
|
||||||
|
connection_id="conn-1", session_id="session-abc", user_name="Alice"
|
||||||
|
)
|
||||||
|
await meeting_joined("test-room", "meeting-123", body, user={"sub": "user-1"})
|
||||||
|
await meeting_joined("test-room", "meeting-123", body, user={"sub": "user-1"})
|
||||||
|
|
||||||
|
assert mock_sessions_ctrl.batch_upsert_sessions.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("reflector.views.rooms.poll_daily_room_presence_task")
|
||||||
|
@patch("reflector.views.rooms.meetings_controller")
|
||||||
|
@patch("reflector.views.rooms.rooms_controller")
|
||||||
|
@patch("reflector.views.rooms.daily_participant_sessions_controller")
|
||||||
|
async def test_leave_already_closed_session_is_noop(
|
||||||
|
mock_sessions_ctrl,
|
||||||
|
mock_rooms_ctrl,
|
||||||
|
mock_meetings_ctrl,
|
||||||
|
mock_poll_task,
|
||||||
|
mock_room,
|
||||||
|
mock_meeting,
|
||||||
|
mock_request_with_session_id,
|
||||||
|
):
|
||||||
|
"""/leave for already-closed session -> no close attempted, just poll."""
|
||||||
|
mock_rooms_ctrl.get_by_name = AsyncMock(return_value=mock_room)
|
||||||
|
mock_meetings_ctrl.get_by_id = AsyncMock(return_value=mock_meeting)
|
||||||
|
mock_sessions_ctrl.get_open_session = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
result = await meeting_leave(
|
||||||
|
"test-room", "meeting-123", mock_request_with_session_id, user=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
mock_sessions_ctrl.batch_close_sessions.assert_not_called()
|
||||||
|
mock_meetings_ctrl.update_meeting.assert_not_called()
|
||||||
|
mock_poll_task.apply_async.assert_called_once()
|
||||||
@@ -91,7 +91,7 @@ const useFrame = (
|
|||||||
cbs: {
|
cbs: {
|
||||||
onLeftMeeting: () => void;
|
onLeftMeeting: () => void;
|
||||||
onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void;
|
onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void;
|
||||||
onJoinMeeting: () => void;
|
onJoinMeeting: (sessionId: string | null) => void;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE);
|
const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE);
|
||||||
@@ -142,7 +142,8 @@ const useFrame = (
|
|||||||
console.error("frame is null in joined-meeting callback");
|
console.error("frame is null in joined-meeting callback");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cbs.onJoinMeeting();
|
const local = frame.participants()?.local;
|
||||||
|
cbs.onJoinMeeting(local?.session_id ?? null);
|
||||||
};
|
};
|
||||||
frame.on("joined-meeting", joinCb);
|
frame.on("joined-meeting", joinCb);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -193,6 +194,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
const joiningMutation = useMeetingJoining();
|
const joiningMutation = useMeetingJoining();
|
||||||
const joinedMutation = useMeetingJoined();
|
const joinedMutation = useMeetingJoined();
|
||||||
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
|
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Generate a stable connection ID for this component instance
|
// Generate a stable connection ID for this component instance
|
||||||
// Used to track pending joins per browser tab (prevents key collision for anonymous users)
|
// Used to track pending joins per browser tab (prevents key collision for anonymous users)
|
||||||
@@ -243,8 +245,17 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
const roomUrl = joinedMeeting?.room_url;
|
const roomUrl = joinedMeeting?.room_url;
|
||||||
|
|
||||||
const handleLeave = useCallback(() => {
|
const handleLeave = useCallback(() => {
|
||||||
|
if (meeting?.id && roomName) {
|
||||||
|
const payload = sessionIdRef.current
|
||||||
|
? { session_id: sessionIdRef.current }
|
||||||
|
: {};
|
||||||
|
navigator.sendBeacon(
|
||||||
|
buildMeetingLeaveUrl(roomName, meeting.id),
|
||||||
|
JSON.stringify(payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
router.push("/browse");
|
router.push("/browse");
|
||||||
}, [router]);
|
}, [router, roomName, meeting?.id]);
|
||||||
|
|
||||||
// Trigger presence recheck on dirty disconnects (tab close, navigation away)
|
// Trigger presence recheck on dirty disconnects (tab close, navigation away)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -253,7 +264,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
// sendBeacon guarantees delivery even if tab closes mid-request
|
// sendBeacon guarantees delivery even if tab closes mid-request
|
||||||
const url = buildMeetingLeaveUrl(roomName, meeting.id);
|
const url = buildMeetingLeaveUrl(roomName, meeting.id);
|
||||||
navigator.sendBeacon(url, JSON.stringify({}));
|
const payload = sessionIdRef.current
|
||||||
|
? { session_id: sessionIdRef.current }
|
||||||
|
: {};
|
||||||
|
navigator.sendBeacon(url, JSON.stringify(payload));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
@@ -271,97 +285,106 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFrameJoinMeeting = useCallback(() => {
|
const handleFrameJoinMeeting = useCallback(
|
||||||
// Signal that WebRTC connection is established
|
(sessionId: string | null) => {
|
||||||
// This clears the pending join intent, confirming successful connection
|
sessionIdRef.current = sessionId;
|
||||||
joinedMutation.mutate(
|
|
||||||
{
|
// Signal that WebRTC connection is established
|
||||||
params: {
|
// This clears the pending join intent and creates session record directly
|
||||||
path: {
|
joinedMutation.mutate(
|
||||||
room_name: roomName,
|
{
|
||||||
meeting_id: meeting.id,
|
params: {
|
||||||
|
path: {
|
||||||
|
room_name: roomName,
|
||||||
|
meeting_id: meeting.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
connection_id: connectionId,
|
||||||
|
session_id: sessionId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {
|
{
|
||||||
connection_id: connectionId,
|
onError: (error: unknown) => {
|
||||||
|
// Non-blocking: log but don't fail - this is cleanup, not critical
|
||||||
|
console.warn("Failed to signal joined:", error);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
{
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
// Non-blocking: log but don't fail - this is cleanup, not critical
|
|
||||||
console.warn("Failed to signal joined:", error);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (meeting.recording_type === "cloud") {
|
if (meeting.recording_type === "cloud") {
|
||||||
console.log("Starting dual recording via REST API", {
|
console.log("Starting dual recording via REST API", {
|
||||||
cloudInstanceId,
|
cloudInstanceId,
|
||||||
rawTracksInstanceId,
|
rawTracksInstanceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start both cloud and raw-tracks via backend REST API (with retry on 404)
|
// Start both cloud and raw-tracks via backend REST API (with retry on 404)
|
||||||
// Daily.co needs time to register call as "hosting" for REST API
|
// Daily.co needs time to register call as "hosting" for REST API
|
||||||
const startRecordingWithRetry = (
|
const startRecordingWithRetry = (
|
||||||
type: DailyRecordingType,
|
type: DailyRecordingType,
|
||||||
instanceId: NonEmptyString,
|
instanceId: NonEmptyString,
|
||||||
attempt: number = 1,
|
attempt: number = 1,
|
||||||
) => {
|
) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
startRecordingMutation.mutate(
|
startRecordingMutation.mutate(
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
meeting_id: meeting.id,
|
meeting_id: meeting.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type,
|
||||||
|
instanceId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {
|
{
|
||||||
type,
|
onError: (error: any) => {
|
||||||
instanceId,
|
const errorText = error?.detail || error?.message || "";
|
||||||
},
|
const is404NotHosting = errorText.includes(
|
||||||
},
|
"does not seem to be hosting a call",
|
||||||
{
|
|
||||||
onError: (error: any) => {
|
|
||||||
const errorText = error?.detail || error?.message || "";
|
|
||||||
const is404NotHosting = errorText.includes(
|
|
||||||
"does not seem to be hosting a call",
|
|
||||||
);
|
|
||||||
const isActiveStream = errorText.includes(
|
|
||||||
"has an active stream",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is404NotHosting && attempt < RECORDING_START_MAX_RETRIES) {
|
|
||||||
console.log(
|
|
||||||
`${type}: Call not hosting yet, retry ${attempt + 1}/${RECORDING_START_MAX_RETRIES} in ${RECORDING_START_DELAY_MS}ms...`,
|
|
||||||
);
|
);
|
||||||
startRecordingWithRetry(type, instanceId, attempt + 1);
|
const isActiveStream = errorText.includes(
|
||||||
} else if (isActiveStream) {
|
"has an active stream",
|
||||||
console.log(
|
|
||||||
`${type}: Recording already active (started by another participant)`,
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
console.error(`Failed to start ${type} recording:`, error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, RECORDING_START_DELAY_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start both recordings
|
if (
|
||||||
startRecordingWithRetry("cloud", cloudInstanceId);
|
is404NotHosting &&
|
||||||
startRecordingWithRetry("raw-tracks", rawTracksInstanceId);
|
attempt < RECORDING_START_MAX_RETRIES
|
||||||
}
|
) {
|
||||||
}, [
|
console.log(
|
||||||
meeting.recording_type,
|
`${type}: Call not hosting yet, retry ${attempt + 1}/${RECORDING_START_MAX_RETRIES} in ${RECORDING_START_DELAY_MS}ms...`,
|
||||||
meeting.id,
|
);
|
||||||
roomName,
|
startRecordingWithRetry(type, instanceId, attempt + 1);
|
||||||
connectionId,
|
} else if (isActiveStream) {
|
||||||
joinedMutation,
|
console.log(
|
||||||
startRecordingMutation,
|
`${type}: Recording already active (started by another participant)`,
|
||||||
cloudInstanceId,
|
);
|
||||||
rawTracksInstanceId,
|
} else {
|
||||||
]);
|
console.error(`Failed to start ${type} recording:`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, RECORDING_START_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start both recordings
|
||||||
|
startRecordingWithRetry("cloud", cloudInstanceId);
|
||||||
|
startRecordingWithRetry("raw-tracks", rawTracksInstanceId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
meeting.recording_type,
|
||||||
|
meeting.id,
|
||||||
|
roomName,
|
||||||
|
connectionId,
|
||||||
|
joinedMutation,
|
||||||
|
startRecordingMutation,
|
||||||
|
cloudInstanceId,
|
||||||
|
rawTracksInstanceId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const recordingIconUrl = useMemo(
|
const recordingIconUrl = useMemo(
|
||||||
() => new URL("/recording-icon.svg", window.location.origin),
|
() => new URL("/recording-icon.svg", window.location.origin),
|
||||||
|
|||||||
@@ -773,6 +773,7 @@ export function useRoomActiveMeetings(roomName: string | null) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!roomName,
|
enabled: !!roomName,
|
||||||
|
refetchInterval: 5000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
www/app/reflector-api.d.ts
vendored
13
www/app/reflector-api.d.ts
vendored
@@ -372,11 +372,12 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Meeting Leave
|
* Meeting Leave
|
||||||
* @description Trigger presence recheck when user leaves meeting.
|
* @description Trigger presence update when user leaves meeting.
|
||||||
*
|
*
|
||||||
* Called on tab close/navigation via sendBeacon(). Immediately queues presence
|
* When session_id is provided in the body, closes the session directly
|
||||||
* poll to detect dirty disconnects faster than 30s periodic poll.
|
* for instant presence update. Falls back to polling when session_id
|
||||||
* Daily.co webhooks handle clean disconnects, but tab close/crash need this.
|
* is not available (e.g., sendBeacon without frame access).
|
||||||
|
* Called on tab close/navigation via sendBeacon().
|
||||||
*/
|
*/
|
||||||
post: operations["v1_meeting_leave"];
|
post: operations["v1_meeting_leave"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1579,6 +1580,10 @@ export interface components {
|
|||||||
* @description A non-empty string
|
* @description A non-empty string
|
||||||
*/
|
*/
|
||||||
connection_id: string;
|
connection_id: string;
|
||||||
|
/** Session Id */
|
||||||
|
session_id?: string | null;
|
||||||
|
/** User Name */
|
||||||
|
user_name?: string | null;
|
||||||
};
|
};
|
||||||
/** JoinedResponse */
|
/** JoinedResponse */
|
||||||
JoinedResponse: {
|
JoinedResponse: {
|
||||||
|
|||||||
Reference in New Issue
Block a user