diff --git a/server/env.example b/server/env.example index ff0f4211..1d0ec29f 100644 --- a/server/env.example +++ b/server/env.example @@ -71,3 +71,27 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run ## Sentry DSN configuration #SENTRY_DSN= + +## ======================================================= +## Video Platform Configuration +## ======================================================= + +## Whereby (existing provider) +#WHEREBY_API_KEY=your-whereby-api-key +#WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret +#AWS_WHEREBY_ACCESS_KEY_ID=your-aws-key +#AWS_WHEREBY_ACCESS_KEY_SECRET=your-aws-secret +#AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-west-2.amazonaws.com/... + +## Daily.co (new provider) +#DAILY_API_KEY=your-daily-api-key +#DAILY_WEBHOOK_SECRET=your-daily-webhook-secret +#DAILY_SUBDOMAIN=your-subdomain +#AWS_DAILY_S3_BUCKET=your-daily-bucket +#AWS_DAILY_S3_REGION=us-west-2 +#AWS_DAILY_ROLE_ARN=arn:aws:iam::ACCOUNT:role/DailyRecording + +## Platform Selection (Feature Flags) +#DAILY_MIGRATION_ENABLED=false # Enable Daily.co support +#DAILY_MIGRATION_ROOM_IDS=[] # Specific rooms to use Daily +#DEFAULT_VIDEO_PLATFORM=whereby # Default platform for new rooms diff --git a/server/migrations/versions/1e49625677e4_add_platform_support.py b/server/migrations/versions/1e49625677e4_add_platform_support.py new file mode 100644 index 00000000..bab81fb5 --- /dev/null +++ b/server/migrations/versions/1e49625677e4_add_platform_support.py @@ -0,0 +1,50 @@ +"""add_platform_support + +Revision ID: 1e49625677e4 +Revises: dc035ff72fd5 +Create Date: 2025-10-08 13:17:29.943612 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1e49625677e4" +down_revision: Union[str, None] = "dc035ff72fd5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add platform field with default 'whereby' for backward compatibility.""" + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "platform", + sa.String(), + nullable=False, + server_default="whereby", + ) + ) + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "platform", + sa.String(), + nullable=False, + server_default="whereby", + ) + ) + + +def downgrade() -> None: + """Remove platform field.""" + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_column("platform") + + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("platform") diff --git a/server/reflector/app.py b/server/reflector/app.py index 8c8724a6..427ac3d8 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup from reflector.logger import logger from reflector.metrics import metrics_init from reflector.settings import settings +from reflector.views.daily import router as daily_router from reflector.views.meetings import router as meetings_router from reflector.views.rooms import router as rooms_router from reflector.views.rtc_offer import router as rtc_offer_router @@ -94,6 +95,7 @@ app.include_router(user_router, prefix="/v1") app.include_router(user_ws_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1") +app.include_router(daily_router, prefix="/v1/daily") add_pagination(app) # prepare celery diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 12a0c187..4181df82 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import JSONB from reflector.db import get_database, metadata from reflector.db.rooms import Room +from reflector.platform_types import Platform from reflector.utils import generate_uuid4 meetings = sa.Table( @@ -55,6 +56,12 @@ meetings = sa.Table( ), ), sa.Column("calendar_metadata", JSONB), + sa.Column( + "platform", + sa.String, + nullable=False, + server_default="whereby", + ), sa.Index("idx_meeting_room_id", "room_id"), sa.Index("idx_meeting_calendar_event", "calendar_event_id"), ) @@ -94,13 +101,14 @@ class Meeting(BaseModel): is_locked: bool = False room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" - recording_trigger: Literal[ + recording_trigger: Literal[ # whereby-specific "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" num_clients: int = 0 is_active: bool = True calendar_event_id: str | None = None calendar_metadata: dict[str, Any] | None = None + platform: Platform = "whereby" class MeetingController: @@ -115,6 +123,7 @@ class MeetingController: room: Room, calendar_event_id: str | None = None, calendar_metadata: dict[str, Any] | None = None, + platform: Platform = "whereby", ): meeting = Meeting( id=id, @@ -130,6 +139,7 @@ class MeetingController: recording_trigger=room.recording_trigger, calendar_event_id=calendar_event_id, calendar_metadata=calendar_metadata, + platform=platform, ) query = meetings.insert().values(**meeting.model_dump()) await get_database().execute(query) diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index 396c818a..452293c8 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -1,7 +1,7 @@ import secrets from datetime import datetime, timezone from sqlite3 import IntegrityError -from typing import Literal +from typing import Literal, Optional import sqlalchemy from fastapi import HTTPException @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from sqlalchemy.sql import false, or_ from reflector.db import get_database, metadata +from reflector.platform_types import Platform from reflector.utils import generate_uuid4 rooms = sqlalchemy.Table( @@ -50,6 +51,12 @@ rooms = sqlalchemy.Table( ), sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)), sqlalchemy.Column("ics_last_etag", sqlalchemy.Text), + sqlalchemy.Column( + "platform", + sqlalchemy.String, + nullable=False, + server_default="whereby", + ), sqlalchemy.Index("idx_room_is_shared", "is_shared"), sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), ) @@ -66,7 +73,7 @@ class Room(BaseModel): is_locked: bool = False room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" - recording_trigger: Literal[ + recording_trigger: Literal[ # whereby-specific "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" is_shared: bool = False @@ -77,6 +84,7 @@ class Room(BaseModel): ics_enabled: bool = False ics_last_sync: datetime | None = None ics_last_etag: str | None = None + platform: Platform = "whereby" class RoomController: @@ -130,6 +138,7 @@ class RoomController: ics_url: str | None = None, ics_fetch_interval: int = 300, ics_enabled: bool = False, + platform: Optional[Platform] = None, ): """ Add a new room @@ -153,6 +162,7 @@ class RoomController: ics_url=ics_url, ics_fetch_interval=ics_fetch_interval, ics_enabled=ics_enabled, + platform=platform or "whereby", ) query = rooms.insert().values(**room.model_dump()) try: diff --git a/server/reflector/platform_types.py b/server/reflector/platform_types.py new file mode 100644 index 00000000..fe71269f --- /dev/null +++ b/server/reflector/platform_types.py @@ -0,0 +1,9 @@ +"""Platform type definitions. + +This module exists solely to define the Platform literal type without any imports, +preventing circular import issues when used across the codebase. +""" + +from typing import Literal + +Platform = Literal["whereby", "daily"] diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 9659f648..9f565082 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -1,6 +1,7 @@ from pydantic.types import PositiveInt from pydantic_settings import BaseSettings, SettingsConfigDict +from reflector.platform_types import Platform from reflector.utils.string import NonEmptyString @@ -129,6 +130,19 @@ class Settings(BaseSettings): AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None SQS_POLLING_TIMEOUT_SECONDS: int = 60 + # Daily.co integration + DAILY_API_KEY: str | None = None + DAILY_WEBHOOK_SECRET: str | None = None + DAILY_SUBDOMAIN: str | None = None + AWS_DAILY_S3_BUCKET: str | None = None + AWS_DAILY_S3_REGION: str = "us-west-2" + AWS_DAILY_ROLE_ARN: str | None = None + + # Platform Migration Feature Flags + DAILY_MIGRATION_ENABLED: bool = False + DAILY_MIGRATION_ROOM_IDS: list[str] = [] + DEFAULT_VIDEO_PLATFORM: Platform = "whereby" + # Zulip integration ZULIP_REALM: str | None = None ZULIP_API_KEY: str | None = None diff --git a/server/reflector/video_platforms/__init__.py b/server/reflector/video_platforms/__init__.py new file mode 100644 index 00000000..ded6244c --- /dev/null +++ b/server/reflector/video_platforms/__init__.py @@ -0,0 +1,17 @@ +# Video Platform Abstraction Layer +""" +This module provides an abstraction layer for different video conferencing platforms. +It allows seamless switching between providers (Whereby, Daily.co, etc.) without +changing the core application logic. +""" + +from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig +from .registry import get_platform_client, register_platform + +__all__ = [ + "VideoPlatformClient", + "VideoPlatformConfig", + "MeetingData", + "get_platform_client", + "register_platform", +] diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py new file mode 100644 index 00000000..c902bd6c --- /dev/null +++ b/server/reflector/video_platforms/base.py @@ -0,0 +1,85 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional + +from pydantic import BaseModel + +from reflector.platform_types import Platform + +if TYPE_CHECKING: + from reflector.db.rooms import Room + +RecordingType = Literal["none", "local", "cloud"] + + +class MeetingData(BaseModel): + meeting_id: str + room_name: str + room_url: str + host_room_url: str + platform: Platform + extra_data: Dict[str, Any] = {} + + +class VideoPlatformConfig(BaseModel): + """Configuration for a video platform.""" + + api_key: str + webhook_secret: str + api_url: Optional[str] = None + subdomain: Optional[str] = None + s3_bucket: Optional[str] = None + s3_region: Optional[str] = None + aws_role_arn: Optional[str] = None + aws_access_key_id: Optional[str] = None + aws_access_key_secret: Optional[str] = None + + +class VideoPlatformClient(ABC): + """Abstract base class for video platform integrations.""" + + PLATFORM_NAME: Platform + + def __init__(self, config: VideoPlatformConfig): + self.config = config + + @abstractmethod + async def create_meeting( + self, room_name_prefix: str, end_date: datetime, room: "Room" + ) -> MeetingData: + """Create a new meeting room.""" + pass + + @abstractmethod + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """Get session information for a room.""" + pass + + @abstractmethod + async def delete_room(self, room_name: str) -> bool: + """Delete a room. Returns True if successful.""" + pass + + @abstractmethod + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """Upload a logo to the room. Returns True if successful.""" + pass + + @abstractmethod + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + """Verify webhook signature for security.""" + pass + + def format_recording_config(self, room: "Room") -> Dict[str, Any]: + """Format recording configuration for the platform. + Can be overridden by specific implementations.""" + if room.recording_type == "cloud" and self.config.s3_bucket: + return { + "type": room.recording_type, + "bucket": self.config.s3_bucket, + "region": self.config.s3_region, + "trigger": room.recording_trigger, + } + return {"type": room.recording_type} diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py new file mode 100644 index 00000000..a3bfc78d --- /dev/null +++ b/server/reflector/video_platforms/daily.py @@ -0,0 +1,180 @@ +import hmac +from datetime import datetime +from hashlib import sha256 +from http import HTTPStatus +from typing import Any, Dict, Optional + +import httpx + +from reflector.db.rooms import Room + +from .base import ( + MeetingData, + Platform, + RecordingType, + VideoPlatformClient, + VideoPlatformConfig, +) + + +class DailyClient(VideoPlatformClient): + PLATFORM_NAME: Platform = "daily" + TIMEOUT = 10 + BASE_URL = "https://api.daily.co/v1" + TIMESTAMP_FORMAT = "%Y%m%d%H%M%S" + RECORDING_NONE: RecordingType = "none" + RECORDING_CLOUD: RecordingType = "cloud" + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self.headers = { + "Authorization": f"Bearer {config.api_key}", + "Content-Type": "application/json", + } + + async def create_meeting( + self, room_name_prefix: str, end_date: datetime, room: Room + ) -> MeetingData: + """Create a Daily.co room.""" + timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT) + if room_name_prefix: + room_name = f"{room_name_prefix}-{timestamp}" + else: + room_name = f"room-{timestamp}" + + data = { + "name": room_name, + "privacy": "private" if room.is_locked else "public", + "properties": { + "enable_recording": "raw-tracks" + if room.recording_type != self.RECORDING_NONE + else False, + "enable_chat": True, + "enable_screenshare": True, + "start_video_off": False, + "start_audio_off": False, + "exp": int(end_date.timestamp()), + }, + } + + # Configure S3 bucket for recordings + if room.recording_type != self.RECORDING_NONE and self.config.s3_bucket: + data["properties"]["recordings_bucket"] = { + "bucket_name": self.config.s3_bucket, + "bucket_region": self.config.s3_region, + "assume_role_arn": self.config.aws_role_arn, + "allow_api_access": True, + } + + from reflector.logger import logger + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/rooms", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + if response.status_code >= 400: + logger.error( + "Daily.co API error", + status_code=response.status_code, + response_body=response.text, + request_data=data, + ) + response.raise_for_status() + result = response.json() + + # Format response to match our standard + room_url = result["url"] + + return MeetingData( + meeting_id=result["id"], + room_name=result["name"], + room_url=room_url, + host_room_url=room_url, + platform=self.PLATFORM_NAME, + extra_data=result, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """Get Daily.co room information.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.BASE_URL}/rooms/{room_name}", + headers=self.headers, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + return response.json() + + async def get_room_presence(self, room_name: str) -> Dict[str, Any]: + """Get real-time participant data - Daily.co specific feature.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.BASE_URL}/rooms/{room_name}/presence", + headers=self.headers, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + return response.json() + + async def delete_room(self, room_name: str) -> bool: + """Delete a Daily.co room.""" + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self.BASE_URL}/rooms/{room_name}", + headers=self.headers, + timeout=self.TIMEOUT, + ) + # Daily.co returns 200 for success, 404 if room doesn't exist + return response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND) + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """Daily.co doesn't support custom logos per room - this is a no-op.""" + return True + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + """Verify Daily.co webhook signature. + + Daily.co uses: + - X-Webhook-Signature header + - X-Webhook-Timestamp header + - Signature format: HMAC-SHA256(base64_decode(secret), timestamp + '.' + body) + - Result is base64 encoded + """ + if not signature or not timestamp: + return False + + try: + import base64 + + secret_bytes = base64.b64decode(self.config.webhook_secret) + + signed_content = timestamp.encode() + b"." + body + + expected = hmac.new(secret_bytes, signed_content, sha256).digest() + expected_b64 = base64.b64encode(expected).decode() + + return hmac.compare_digest(expected_b64, signature) + except Exception: + return False + + async def create_meeting_token(self, room_name: str, enable_recording: bool) -> str: + """Create meeting token for auto-recording.""" + data = {"properties": {"room_name": room_name}} + + if enable_recording: + data["properties"]["start_cloud_recording"] = True + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/meeting-tokens", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + return response.json()["token"] diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py new file mode 100644 index 00000000..2fda0c8e --- /dev/null +++ b/server/reflector/video_platforms/factory.py @@ -0,0 +1,80 @@ +"""Factory for creating video platform clients based on configuration.""" + +from typing import Optional + +from reflector.settings import settings + +from .base import Platform, VideoPlatformClient, VideoPlatformConfig +from .registry import get_platform_client + + +def get_platform_config(platform: Platform) -> VideoPlatformConfig: + """Get configuration for a specific platform.""" + if platform == "whereby": + if not settings.WHEREBY_API_KEY: + raise ValueError( + "WHEREBY_API_KEY is required when platform='whereby'. " + "Set WHEREBY_API_KEY environment variable." + ) + return VideoPlatformConfig( + api_key=settings.WHEREBY_API_KEY, + webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "", + api_url=settings.WHEREBY_API_URL, + s3_bucket=settings.RECORDING_STORAGE_AWS_BUCKET_NAME, + s3_region=settings.RECORDING_STORAGE_AWS_REGION, + aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID, + aws_access_key_secret=settings.AWS_WHEREBY_ACCESS_KEY_SECRET, + ) + elif platform == "daily": + if not settings.DAILY_API_KEY: + raise ValueError( + "DAILY_API_KEY is required when platform='daily'. " + "Set DAILY_API_KEY environment variable." + ) + if not settings.DAILY_SUBDOMAIN: + raise ValueError( + "DAILY_SUBDOMAIN is required when platform='daily'. " + "Set DAILY_SUBDOMAIN environment variable." + ) + return VideoPlatformConfig( + api_key=settings.DAILY_API_KEY, + webhook_secret=settings.DAILY_WEBHOOK_SECRET or "", + subdomain=settings.DAILY_SUBDOMAIN, + s3_bucket=settings.AWS_DAILY_S3_BUCKET, + s3_region=settings.AWS_DAILY_S3_REGION, + aws_role_arn=settings.AWS_DAILY_ROLE_ARN, + ) + else: + raise ValueError(f"Unknown platform: {platform}") + + +def create_platform_client(platform: Platform) -> VideoPlatformClient: + """Create a video platform client instance.""" + config = get_platform_config(platform) + return get_platform_client(platform, config) + + +def get_platform_for_room( + room_id: Optional[str] = None, room_platform: Optional[Platform] = None +) -> Platform: + """Determine which platform to use for a room. + + Priority order (highest to lowest): + 1. DAILY_MIGRATION_ROOM_IDS - env var override for testing/migration + 2. room_platform - database persisted platform choice + 3. DEFAULT_VIDEO_PLATFORM - env var fallback + """ + # If Daily migration is disabled, always use Whereby + if not settings.DAILY_MIGRATION_ENABLED: + return "whereby" + + # Highest priority: If room is in migration list, use Daily (env var override) + if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS: + return "daily" + + # Second priority: Use room's persisted platform from database + if room_platform: + return room_platform + + # Fallback: Use default platform from env var + return settings.DEFAULT_VIDEO_PLATFORM diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py new file mode 100644 index 00000000..f50777d1 --- /dev/null +++ b/server/reflector/video_platforms/registry.py @@ -0,0 +1,39 @@ +from typing import Dict, Type + +from .base import Platform, VideoPlatformClient, VideoPlatformConfig + +# Registry of available video platforms +_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} + + +def register_platform(name: Platform, client_class: Type[VideoPlatformClient]): + """Register a video platform implementation.""" + _PLATFORMS[name] = client_class + + +def get_platform_client( + platform: Platform, config: VideoPlatformConfig +) -> VideoPlatformClient: + """Get a video platform client instance.""" + if platform not in _PLATFORMS: + raise ValueError(f"Unknown video platform: {platform}") + + client_class = _PLATFORMS[platform] + return client_class(config) + + +def get_available_platforms() -> list[Platform]: + """Get list of available platform names.""" + return list(_PLATFORMS.keys()) + + +# Auto-register built-in platforms +def _register_builtin_platforms(): + from .daily import DailyClient # noqa: PLC0415 + from .whereby import WherebyClient # noqa: PLC0415 + + register_platform("whereby", WherebyClient) + register_platform("daily", DailyClient) + + +_register_builtin_platforms() diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py new file mode 100644 index 00000000..9202a065 --- /dev/null +++ b/server/reflector/video_platforms/whereby.py @@ -0,0 +1,140 @@ +import hmac +import json +import re +import time +from datetime import datetime +from hashlib import sha256 +from typing import Any, Dict, Optional + +import httpx + +from reflector.db.rooms import Room + +from .base import MeetingData, Platform, VideoPlatformClient, VideoPlatformConfig + + +class WherebyClient(VideoPlatformClient): + """Whereby video platform implementation.""" + + PLATFORM_NAME: Platform = "whereby" + TIMEOUT = 10 # seconds + MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self.headers = { + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {config.api_key}", + } + + async def create_meeting( + self, room_name_prefix: str, end_date: datetime, room: Room + ) -> MeetingData: + """Create a Whereby meeting.""" + data = { + "isLocked": room.is_locked, + "roomNamePrefix": room_name_prefix, + "roomNamePattern": "uuid", + "roomMode": room.room_mode, + "endDate": end_date.isoformat(), + "fields": ["hostRoomUrl"], + } + + # Add recording configuration if cloud recording is enabled + if room.recording_type == "cloud": + data["recording"] = { + "type": room.recording_type, + "destination": { + "provider": "s3", + "bucket": self.config.s3_bucket, + "accessKeyId": self.config.aws_access_key_id, + "accessKeySecret": self.config.aws_access_key_secret, + "fileFormat": "mp4", + }, + "startTrigger": room.recording_trigger, + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.config.api_url}/meetings", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + result = response.json() + + return MeetingData( + meeting_id=result["meetingId"], + room_name=result["roomName"], + room_url=result["roomUrl"], + host_room_url=result["hostRoomUrl"], + platform=self.PLATFORM_NAME, + extra_data=result, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """Get Whereby room session information.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.config.api_url}/insights/room-sessions?roomName={room_name}", + headers=self.headers, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + return response.json() + + async def delete_room(self, room_name: str) -> bool: + """Whereby doesn't support room deletion - meetings expire automatically.""" + return True + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """Upload logo to Whereby room.""" + async with httpx.AsyncClient() as client: + with open(logo_path, "rb") as f: + response = await client.put( + f"{self.config.api_url}/rooms/{room_name}/theme/logo", + headers={ + "Authorization": f"Bearer {self.config.api_key}", + }, + timeout=self.TIMEOUT, + files={"image": f}, + ) + response.raise_for_status() + return True + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + """Verify Whereby webhook signature.""" + if not signature: + return False + + matches = re.match(r"t=(.*),v1=(.*)", signature) + if not matches: + return False + + ts, sig = matches.groups() + + # Check timestamp to prevent replay attacks + current_time = int(time.time() * 1000) + diff_time = current_time - int(ts) * 1000 + if diff_time >= self.MAX_ELAPSED_TIME: + return False + + # Verify signature + body_dict = json.loads(body) + signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}" + hmac_obj = hmac.new( + self.config.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + sha256, + ) + expected_signature = hmac_obj.hexdigest() + + try: + return hmac.compare_digest( + expected_signature.encode("utf-8"), sig.encode("utf-8") + ) + except Exception: + return False diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py new file mode 100644 index 00000000..e9f4f05e --- /dev/null +++ b/server/reflector/views/daily.py @@ -0,0 +1,185 @@ +"""Daily.co webhook handler endpoint.""" + +from typing import Any, Dict + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from reflector.db.meetings import meetings_controller +from reflector.logger import logger +from reflector.video_platforms.factory import create_platform_client + +router = APIRouter() + + +class DailyWebhookEvent(BaseModel): + """Daily webhook event structure.""" + + version: str + type: str + id: str + payload: Dict[str, Any] + event_ts: float + + +@router.post("/webhook") +async def webhook(request: Request): + """Handle Daily webhook events. + + Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook + state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py + """ + body = await request.body() + signature = request.headers.get("X-Webhook-Signature", "") + timestamp = request.headers.get("X-Webhook-Timestamp", "") + + client = create_platform_client("daily") + if not client.verify_webhook_signature(body, signature, timestamp): + logger.warning( + "Invalid webhook signature", + signature=signature, + timestamp=timestamp, + has_body=bool(body), + ) + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + # Parse the JSON body + import json + + try: + body_json = json.loads(body) + except json.JSONDecodeError: + raise HTTPException(status_code=422, detail="Invalid JSON") + + # Handle Daily's test event during webhook creation + if body_json.get("test") == "test": + logger.info("Received Daily webhook test event") + return {"status": "ok"} + + # Parse as actual event + try: + event = DailyWebhookEvent(**body_json) + except Exception as e: + logger.error("Failed to parse webhook event", error=str(e), body=body.decode()) + raise HTTPException(status_code=422, detail="Invalid event format") + + # Handle participant events + if event.type == "participant.joined": + await _handle_participant_joined(event) + elif event.type == "participant.left": + await _handle_participant_left(event) + elif event.type == "recording.started": + await _handle_recording_started(event) + elif event.type == "recording.ready-to-download": + await _handle_recording_ready(event) + elif event.type == "recording.error": + await _handle_recording_error(event) + + return {"status": "ok"} + + +async def _handle_participant_joined(event: DailyWebhookEvent): + """Handle participant joined event.""" + room_name = event.payload.get("room") + if not room_name: + logger.warning("participant.joined: no room in payload", payload=event.payload) + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + current_count = getattr(meeting, "num_clients", 0) + await meetings_controller.update_meeting( + meeting.id, num_clients=current_count + 1 + ) + logger.info( + "Participant joined", + meeting_id=meeting.id, + room_name=room_name, + num_clients=current_count + 1, + recording_type=meeting.recording_type, + recording_trigger=meeting.recording_trigger, + ) + else: + logger.warning("participant.joined: meeting not found", room_name=room_name) + + +async def _handle_participant_left(event: DailyWebhookEvent): + """Handle participant left event.""" + room_name = event.payload.get("room") + if not room_name: + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + current_count = getattr(meeting, "num_clients", 0) + await meetings_controller.update_meeting( + meeting.id, num_clients=max(0, current_count - 1) + ) + + +async def _handle_recording_started(event: DailyWebhookEvent): + """Handle recording started event.""" + # Daily.co inconsistency: participant.* uses "room", recording.* uses "room_name" + room_name = event.payload.get("room_name") or event.payload.get("room") + if not room_name: + logger.warning( + "recording.started: no room_name in payload", payload=event.payload + ) + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + logger.info( + "Recording started", + meeting_id=meeting.id, + room_name=room_name, + recording_id=event.payload.get("recording_id"), + platform="daily", + ) + else: + logger.warning("recording.started: meeting not found", room_name=room_name) + + +async def _handle_recording_ready(event: DailyWebhookEvent): + """Handle recording ready for download event.""" + room_name = event.payload.get("room") + recording_id = event.payload.get("recording_id") + download_link = event.payload.get("download_link") + + if not room_name or not download_link: + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + try: + from reflector.worker.process import process_recording_from_url + + process_recording_from_url.delay( + recording_url=download_link, + meeting_id=meeting.id, + recording_id=recording_id or event.id, + ) + except ImportError: + logger.warning( + "Could not queue recording processing", + meeting_id=meeting.id, + room_name=room_name, + platform="daily", + ) + + +async def _handle_recording_error(event: DailyWebhookEvent): + """Handle recording error event.""" + room_name = event.payload.get("room") + error = event.payload.get("error", "Unknown error") + + if room_name: + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + logger.error( + "Recording error", + meeting_id=meeting.id, + room_name=room_name, + error=error, + platform="daily", + ) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 70e3f9e4..c46f900e 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -17,7 +17,11 @@ from reflector.db.rooms import rooms_controller from reflector.redis_cache import RedisAsyncLock from reflector.services.ics_sync import ics_sync_service from reflector.settings import settings -from reflector.whereby import create_meeting, upload_logo +from reflector.video_platforms.base import Platform +from reflector.video_platforms.factory import ( + create_platform_client, + get_platform_for_room, +) from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) @@ -41,6 +45,7 @@ class Room(BaseModel): ics_enabled: bool = False ics_last_sync: Optional[datetime] = None ics_last_etag: Optional[str] = None + platform: Platform = "whereby" class RoomDetails(Room): @@ -68,6 +73,7 @@ class Meeting(BaseModel): is_active: bool = True calendar_event_id: str | None = None calendar_metadata: dict[str, Any] | None = None + platform: Platform = "whereby" class CreateRoom(BaseModel): @@ -85,6 +91,7 @@ class CreateRoom(BaseModel): ics_url: Optional[str] = None ics_fetch_interval: int = 300 ics_enabled: bool = False + platform: Optional[Platform] = None class UpdateRoom(BaseModel): @@ -102,6 +109,7 @@ class UpdateRoom(BaseModel): ics_url: Optional[str] = None ics_fetch_interval: Optional[int] = None ics_enabled: Optional[bool] = None + platform: Optional[Platform] = None class CreateRoomMeeting(BaseModel): @@ -251,6 +259,7 @@ async def rooms_create( ics_url=room.ics_url, ics_fetch_interval=room.ics_fetch_interval, ics_enabled=room.ics_enabled, + platform=room.platform, ) @@ -315,20 +324,27 @@ async def rooms_create_meeting( if meeting is None: end_date = current_time + timedelta(hours=8) - whereby_meeting = await create_meeting("", end_date=end_date, room=room) + # Determine which platform to use + platform = get_platform_for_room(room.id, room.platform) + client = create_platform_client(platform) - await upload_logo(whereby_meeting["roomName"], "./images/logo.png") + # Create meeting via platform abstraction + meeting_data = await client.create_meeting( + room.name, end_date=end_date, room=room + ) + + # Upload logo if supported by platform + await client.upload_logo(meeting_data.room_name, "./images/logo.png") meeting = await meetings_controller.create( - id=whereby_meeting["meetingId"], - room_name=whereby_meeting["roomName"], - room_url=whereby_meeting["roomUrl"], - host_room_url=whereby_meeting["hostRoomUrl"], - start_date=parse_datetime_with_timezone( - whereby_meeting["startDate"] - ), - end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]), + id=meeting_data.meeting_id, + room_name=meeting_data.room_name, + room_url=meeting_data.room_url, + host_room_url=meeting_data.host_room_url, + start_date=current_time, + end_date=end_date, room=room, + platform=platform, ) except LockError: logger.warning("Failed to acquire lock for room %s within timeout", room_name) @@ -336,6 +352,16 @@ async def rooms_create_meeting( status_code=503, detail="Meeting creation in progress, please try again" ) + if meeting.platform == "daily" and room.recording_trigger != "none": + client = create_platform_client(meeting.platform) + token = await client.create_meeting_token( + meeting.room_name, enable_recording=True + ) + meeting = meeting.model_copy() + meeting.room_url += f"?t={token}" + if meeting.host_room_url: + meeting.host_room_url += f"?t={token}" + if user_id != room.user_id: meeting.host_room_url = "" diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index faf62f4a..600a3d90 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -10,7 +10,7 @@ from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms_controller from reflector.redis_cache import RedisAsyncLock from reflector.services.ics_sync import SyncStatus, ics_sync_service -from reflector.whereby import create_meeting, upload_logo +from reflector.video_platforms.factory import create_platform_client logger = structlog.wrap_logger(get_task_logger(__name__)) @@ -104,20 +104,24 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room try: end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) - whereby_meeting = await create_meeting( + # Use platform abstraction to create meeting + platform = room.platform + client = create_platform_client(platform) + + meeting_data = await client.create_meeting( "", end_date=end_date, room=room, ) - await upload_logo(whereby_meeting["roomName"], "./images/logo.png") + await client.upload_logo(meeting_data.room_name, "./images/logo.png") meeting = await meetings_controller.create( - id=whereby_meeting["meetingId"], - room_name=whereby_meeting["roomName"], - room_url=whereby_meeting["roomUrl"], - host_room_url=whereby_meeting["hostRoomUrl"], - start_date=datetime.fromisoformat(whereby_meeting["startDate"]), - end_date=datetime.fromisoformat(whereby_meeting["endDate"]), + id=meeting_data.meeting_id, + room_name=meeting_data.room_name, + room_url=meeting_data.room_url, + host_room_url=meeting_data.host_room_url, + start_date=event.start_time, + end_date=end_date, room=room, calendar_event_id=event.id, calendar_metadata={ @@ -125,6 +129,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room "description": event.description, "attendees": event.attendees, }, + platform=platform, ) logger.info( diff --git a/server/scripts/recreate_daily_webhook.py b/server/scripts/recreate_daily_webhook.py new file mode 100644 index 00000000..cd1ec689 --- /dev/null +++ b/server/scripts/recreate_daily_webhook.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Recreate Daily.co webhook (fixes circuit-breaker FAILED state).""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import httpx + +from reflector.settings import settings + + +async def recreate_webhook(webhook_url: str): + """Delete all webhooks and create new one.""" + if not settings.DAILY_API_KEY: + print("Error: DAILY_API_KEY not set") + return 1 + + headers = { + "Authorization": f"Bearer {settings.DAILY_API_KEY}", + "Content-Type": "application/json", + } + + async with httpx.AsyncClient() as client: + # List existing webhooks + resp = await client.get("https://api.daily.co/v1/webhooks", headers=headers) + resp.raise_for_status() + webhooks = resp.json() + + # Delete all existing webhooks + for wh in webhooks: + uuid = wh["uuid"] + print(f"Deleting webhook {uuid} (state: {wh['state']})") + await client.delete( + f"https://api.daily.co/v1/webhooks/{uuid}", headers=headers + ) + + # Create new webhook + webhook_data = { + "url": webhook_url, + "eventTypes": [ + "participant.joined", + "participant.left", + "recording.started", + "recording.ready-to-download", + "recording.error", + ], + "hmac": settings.DAILY_WEBHOOK_SECRET, + } + + resp = await client.post( + "https://api.daily.co/v1/webhooks", headers=headers, json=webhook_data + ) + resp.raise_for_status() + result = resp.json() + + print(f"Created webhook {result['uuid']} (state: {result['state']})") + print(f"URL: {result['url']}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python recreate_daily_webhook.py ") + print( + "Example: python recreate_daily_webhook.py https://example.com/v1/daily/webhook" + ) + sys.exit(1) + + sys.exit(asyncio.run(recreate_webhook(sys.argv[1]))) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index a70604ae..57e312a8 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -6,6 +6,16 @@ from unittest.mock import patch import pytest +@pytest.fixture(scope="session", autouse=True) +def register_mock_platform(): + from mocks.mock_platform import MockPlatformClient + + from reflector.video_platforms.registry import register_platform + + register_platform("whereby", MockPlatformClient) + yield + + @pytest.fixture(scope="session", autouse=True) def settings_configuration(): # theses settings are linked to monadical for pytest-recording diff --git a/server/tests/mocks/__init__.py b/server/tests/mocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/tests/mocks/mock_platform.py b/server/tests/mocks/mock_platform.py new file mode 100644 index 00000000..0da4e7d7 --- /dev/null +++ b/server/tests/mocks/mock_platform.py @@ -0,0 +1,111 @@ +import uuid +from datetime import datetime +from typing import Any, Dict, Literal, Optional + +from reflector.db.rooms import Room +from reflector.video_platforms.base import ( + MeetingData, + VideoPlatformClient, + VideoPlatformConfig, +) + +MockPlatform = Literal["mock"] + + +class MockPlatformClient(VideoPlatformClient): + PLATFORM_NAME: MockPlatform = "mock" + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self._rooms: Dict[str, Dict[str, Any]] = {} + self._webhook_calls: list[Dict[str, Any]] = [] + + async def create_meeting( + self, room_name_prefix: str, end_date: datetime, room: Room + ) -> MeetingData: + meeting_id = str(uuid.uuid4()) + room_name = f"{room_name_prefix}-{meeting_id[:8]}" + room_url = f"https://mock.video/{room_name}" + host_room_url = f"{room_url}?host=true" + + self._rooms[room_name] = { + "id": meeting_id, + "name": room_name, + "url": room_url, + "host_url": host_room_url, + "end_date": end_date, + "room": room, + "participants": [], + "is_active": True, + } + + return MeetingData.model_construct( + meeting_id=meeting_id, + room_name=room_name, + room_url=room_url, + host_room_url=host_room_url, + platform="whereby", + extra_data={"mock": True}, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + if room_name not in self._rooms: + return {"error": "Room not found"} + + room_data = self._rooms[room_name] + return { + "roomName": room_name, + "sessions": [ + { + "sessionId": room_data["id"], + "startTime": datetime.utcnow().isoformat(), + "participants": room_data["participants"], + "isActive": room_data["is_active"], + } + ], + } + + async def delete_room(self, room_name: str) -> bool: + if room_name in self._rooms: + self._rooms[room_name]["is_active"] = False + return True + return False + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + if room_name in self._rooms: + self._rooms[room_name]["logo_path"] = logo_path + return True + return False + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + return signature == "valid" + + def add_participant( + self, room_name: str, participant_id: str, participant_name: str + ): + if room_name in self._rooms: + self._rooms[room_name]["participants"].append( + { + "id": participant_id, + "name": participant_name, + "joined_at": datetime.utcnow().isoformat(), + } + ) + + def trigger_webhook(self, event_type: str, data: Dict[str, Any]): + self._webhook_calls.append( + { + "type": event_type, + "data": data, + "timestamp": datetime.utcnow().isoformat(), + } + ) + + def get_webhook_calls(self) -> list[Dict[str, Any]]: + return self._webhook_calls.copy() + + def clear_data(self): + self._rooms.clear() + self._webhook_calls.clear() diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx new file mode 100644 index 00000000..19ebd60e --- /dev/null +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react"; +import { toaster } from "../../components/ui/toaster"; +import { useRouter } from "next/navigation"; +import { useRecordingConsent } from "../../recordingConsentContext"; +import { useMeetingAudioConsent } from "../../lib/apiHooks"; +import { FaBars } from "react-icons/fa6"; +import DailyIframe, { DailyCall } from "@daily-co/daily-js"; +import type { components } from "../../reflector-api"; +import { useAuth } from "../../lib/AuthProvider"; + +type Meeting = components["schemas"]["Meeting"]; + +const CONSENT_BUTTON_TOP_OFFSET = "56px"; +const TOAST_CHECK_INTERVAL_MS = 100; + +interface DailyRoomProps { + meeting: Meeting; +} + +function ConsentDialogButton({ meetingId }: { meetingId: string }) { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ), + }); + + toastId.then((id) => { + const checkToastStatus = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + clearInterval(checkToastStatus); + } + }, TOAST_CHECK_INTERVAL_MS); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const cleanup = () => { + toastId.then((id) => toaster.dismiss(id)); + document.removeEventListener("keydown", handleKeyDown); + }; + + return cleanup; + }, [meetingId, handleConsent, modalOpen]); + + if ( + !consentState.ready || + hasConsent(meetingId) || + audioConsentMutation.isPending + ) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: Meeting["recording_type"], +) => { + return recordingType === "cloud"; +}; + +export default function DailyRoom({ meeting }: DailyRoomProps) { + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + const [callFrame, setCallFrame] = useState(null); + const containerRef = useRef(null); + + const roomUrl = meeting?.host_room_url || meeting?.room_url; + + const isLoading = status === "loading"; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !roomUrl || !containerRef.current) return; + + let frame: DailyCall | null = null; + let destroyed = false; + + const createAndJoin = async () => { + try { + const existingFrame = DailyIframe.getCallInstance(); + if (existingFrame) { + await existingFrame.destroy(); + } + + frame = DailyIframe.createFrame(containerRef.current!, { + iframeStyle: { + width: "100vw", + height: "100vh", + border: "none", + }, + showLeaveButton: true, + showFullscreenButton: true, + }); + + if (destroyed) { + await frame.destroy(); + return; + } + + frame.on("left-meeting", handleLeave); + await frame.join({ url: roomUrl }); + + if (!destroyed) { + setCallFrame(frame); + } + } catch (error) { + console.error("Error creating Daily frame:", error); + } + }; + + createAndJoin(); + + return () => { + destroyed = true; + if (frame) { + frame.destroy().catch((e) => { + console.error("Error destroying frame:", e); + }); + } + }; + }, [roomUrl, isLoading, handleLeave]); + + if (!roomUrl) { + return null; + } + + return ( + +
+ {recordingTypeRequiresConsent(meeting.recording_type) && ( + + )} + + ); +} diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx new file mode 100644 index 00000000..bfcd82f7 --- /dev/null +++ b/www/app/[roomName]/components/RoomContainer.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { roomMeetingUrl } from "../../lib/routes"; +import { useCallback, useEffect, useState, use } from "react"; +import { Box, Text, Spinner } from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; +import { + useRoomGetByName, + useRoomsCreateMeeting, + useRoomGetMeeting, +} from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; +import MeetingSelection from "../MeetingSelection"; +import useRoomDefaultMeeting from "../useRoomDefaultMeeting"; +import WherebyRoom from "./WherebyRoom"; +import DailyRoom from "./DailyRoom"; +import { useAuth } from "../../lib/AuthProvider"; +import { useError } from "../../(errors)/errorContext"; +import { parseNonEmptyString } from "../../lib/utils"; +import { printApiError } from "../../api/_error"; + +type Meeting = components["schemas"]["Meeting"]; + +export type RoomDetails = { + params: Promise<{ + roomName: string; + meetingId?: string; + }>; +}; + +function LoadingSpinner() { + return ( + + + + ); +} + +export default function RoomContainer(details: RoomDetails) { + const params = use(details.params); + const roomName = parseNonEmptyString( + params.roomName, + true, + "panic! params.roomName is required", + ); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + const { setError } = useError(); + + const roomQuery = useRoomGetByName(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + + const pageMeetingId = params.meetingId; + + const defaultMeeting = useRoomDefaultMeeting( + room && !room.ics_enabled && !pageMeetingId ? roomName : null, + ); + + const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + + const meeting = explicitMeeting.data || defaultMeeting.response; + + const isLoading = + status === "loading" || + roomQuery.isLoading || + defaultMeeting?.loading || + explicitMeeting.isLoading || + createMeetingMutation.isPending; + + const errors = [ + explicitMeeting.error, + defaultMeeting.error, + roomQuery.error, + createMeetingMutation.error, + ].filter(Boolean); + + const isOwner = + isAuthenticated && room ? auth.user?.id === room.user_id : false; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + router.push( + roomMeetingUrl( + roomName, + parseNonEmptyString( + selectedMeeting.id, + true, + "panic! selectedMeeting.id is required", + ), + ), + ); + }; + + const handleCreateUnscheduled = async () => { + try { + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + body: { + allow_duplicated: room ? room.ics_enabled : false, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + if (isLoading) { + return ; + } + + if (!room) { + return ( + + Room not found + + ); + } + + if (room.ics_enabled && !params.meetingId) { + return ( + + ); + } + + if (errors.length > 0) { + return ( + + {errors.map((error, i) => ( + + {printApiError(error)} + + ))} + + ); + } + + if (!meeting) { + return ; + } + + const platform = meeting.platform; + + if (!platform) { + return ( + + Meeting platform not configured + + ); + } + + switch (platform) { + case "daily": + return ; + case "whereby": + return ; + default: { + const _exhaustive: never = platform; + return ( + + Unknown platform: {platform} + + ); + } + } +} diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx new file mode 100644 index 00000000..d2427807 --- /dev/null +++ b/www/app/[roomName]/components/WherebyRoom.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, RefObject } from "react"; +import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react"; +import { toaster } from "../../components/ui/toaster"; +import { useRouter } from "next/navigation"; +import { useRecordingConsent } from "../../recordingConsentContext"; +import { useMeetingAudioConsent } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; +import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../../lib/AuthProvider"; +import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient"; +import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils"; + +type Meeting = components["schemas"]["Meeting"]; + +interface WherebyRoomProps { + meeting: Meeting; +} + +const useConsentWherebyFocusManagement = ( + acceptButtonRef: RefObject, + wherebyRef: RefObject, +) => { + const currentFocusRef = useRef(null); + useEffect(() => { + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } else { + console.error( + "accept button ref not available yet for focus management - seems to be illegal state", + ); + } + + const handleWherebyReady = () => { + console.log("whereby ready - refocusing consent button"); + currentFocusRef.current = document.activeElement as HTMLElement; + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } + }; + + if (wherebyRef.current) { + wherebyRef.current.addEventListener("ready", handleWherebyReady); + } else { + console.warn( + "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", + ); + } + + return () => { + wherebyRef.current?.removeEventListener("ready", handleWherebyReady); + currentFocusRef.current?.focus(); + }; + }, []); +}; + +const useConsentDialog = ( + meetingId: string, + wherebyRef: RefObject, +) => { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => { + const AcceptButton = () => { + const buttonRef = useRef(null); + useConsentWherebyFocusManagement(buttonRef, wherebyRef); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + toastId.then((id) => { + const checkToastStatus = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + clearInterval(checkToastStatus); + } + }, 100); + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const cleanup = () => { + toastId.then((id) => toaster.dismiss(id)); + document.removeEventListener("keydown", handleKeyDown); + }; + + return cleanup; + }, [meetingId, handleConsent, wherebyRef, modalOpen]); + + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; +}; + +function ConsentDialogButton({ + meetingId, + wherebyRef, +}: { + meetingId: NonEmptyString; + wherebyRef: React.RefObject; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, wherebyRef); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + return recordingType === "cloud"; +}; + +export default function WherebyRoom({ meeting }: WherebyRoomProps) { + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + + const wherebyRoomUrl = getWherebyUrl(meeting); + const recordingType = meeting.recording_type; + const meetingId = meeting.id; + + const isLoading = status === "loading"; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) + return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + if (!wherebyRoomUrl || !wherebyLoaded) { + return null; + } + + return ( + <> + + {recordingType && + recordingTypeRequiresConsent(recordingType) && + meetingId && ( + + )} + + ); +} diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index 1aaca4c7..87651a50 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -1,3 +1,3 @@ -import Room from "./room"; +import RoomContainer from "./components/RoomContainer"; -export default Room; +export default RoomContainer; diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index e1709d69..b4cfbeb2 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -4,6 +4,23 @@ */ export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/metrics": { parameters: { query?: never; @@ -644,6 +661,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Webhook + * @description Handle Daily webhook events. + */ + post: operations["v1_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -750,6 +787,8 @@ export interface components { * @default false */ ics_enabled: boolean; + /** Platform */ + platform?: ("whereby" | "daily") | null; }; /** CreateRoomMeeting */ CreateRoomMeeting: { @@ -775,6 +814,22 @@ export interface components { target_language: string; source_kind?: components["schemas"]["SourceKind"] | null; }; + /** + * DailyWebhookEvent + * @description Daily webhook event structure. + */ + DailyWebhookEvent: { + /** Type */ + type: string; + /** Id */ + id: string; + /** Ts */ + ts: number; + /** Data */ + data: { + [key: string]: unknown; + }; + }; /** DeletionStatus */ DeletionStatus: { /** Status */ @@ -1091,6 +1146,12 @@ export interface components { calendar_metadata?: { [key: string]: unknown; } | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; }; /** MeetingConsentRequest */ MeetingConsentRequest: { @@ -1177,6 +1238,12 @@ export interface components { ics_last_sync?: string | null; /** Ics Last Etag */ ics_last_etag?: string | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; }; /** RoomDetails */ RoomDetails: { @@ -1223,6 +1290,12 @@ export interface components { ics_last_sync?: string | null; /** Ics Last Etag */ ics_last_etag?: string | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; /** Webhook Url */ webhook_url: string | null; /** Webhook Secret */ @@ -1403,6 +1476,8 @@ export interface components { ics_fetch_interval?: number | null; /** Ics Enabled */ ics_enabled?: boolean | null; + /** Platform */ + platform?: ("whereby" | "daily") | null; }; /** UpdateTranscript */ UpdateTranscript: { @@ -1509,6 +1584,26 @@ export interface components { } export type $defs = Record; export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; metrics: { parameters: { query?: never; @@ -2983,4 +3078,37 @@ export interface operations { }; }; }; + v1_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DailyWebhookEvent"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/www/package.json b/www/package.json index 5169dbe2..f4412db0 100644 --- a/www/package.json +++ b/www/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@chakra-ui/react": "^3.24.2", + "@daily-co/daily-js": "^0.84.0", "@emotion/react": "^11.14.0", "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 6c0a3d83..92667b7e 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: "@chakra-ui/react": specifier: ^3.24.2 version: 3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@daily-co/daily-js": + specifier: ^0.84.0 + version: 0.84.0 "@emotion/react": specifier: ^11.14.0 version: 11.14.0(@types/react@18.2.20)(react@18.3.1) @@ -487,6 +490,13 @@ packages: } engines: { node: ">=12" } + "@daily-co/daily-js@0.84.0": + resolution: + { + integrity: sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==, + } + engines: { node: ">=10.0.0" } + "@emnapi/core@1.4.5": resolution: { @@ -2293,6 +2303,13 @@ packages: } engines: { node: ">=18" } + "@sentry-internal/browser-utils@8.55.0": + resolution: + { + integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==, + } + engines: { node: ">=14.18" } + "@sentry-internal/feedback@10.11.0": resolution: { @@ -2300,6 +2317,13 @@ packages: } engines: { node: ">=18" } + "@sentry-internal/feedback@8.55.0": + resolution: + { + integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==, + } + engines: { node: ">=14.18" } + "@sentry-internal/replay-canvas@10.11.0": resolution: { @@ -2307,6 +2331,13 @@ packages: } engines: { node: ">=18" } + "@sentry-internal/replay-canvas@8.55.0": + resolution: + { + integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==, + } + engines: { node: ">=14.18" } + "@sentry-internal/replay@10.11.0": resolution: { @@ -2314,6 +2345,13 @@ packages: } engines: { node: ">=18" } + "@sentry-internal/replay@8.55.0": + resolution: + { + integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==, + } + engines: { node: ">=14.18" } + "@sentry/babel-plugin-component-annotate@4.3.0": resolution: { @@ -2328,6 +2366,13 @@ packages: } engines: { node: ">=18" } + "@sentry/browser@8.55.0": + resolution: + { + integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==, + } + engines: { node: ">=14.18" } + "@sentry/bundler-plugin-core@4.3.0": resolution: { @@ -2421,6 +2466,13 @@ packages: } engines: { node: ">=18" } + "@sentry/core@8.55.0": + resolution: + { + integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==, + } + engines: { node: ">=14.18" } + "@sentry/nextjs@10.11.0": resolution: { @@ -4029,6 +4081,12 @@ packages: } engines: { node: ">=8" } + bowser@2.12.1: + resolution: + { + integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==, + } + brace-expansion@1.1.12: resolution: { @@ -9288,6 +9346,14 @@ snapshots: "@jridgewell/trace-mapping": 0.3.9 optional: true + "@daily-co/daily-js@0.84.0": + dependencies: + "@babel/runtime": 7.28.2 + "@sentry/browser": 8.55.0 + bowser: 2.12.1 + dequal: 2.0.3 + events: 3.3.0 + "@emnapi/core@1.4.5": dependencies: "@emnapi/wasi-threads": 1.0.4 @@ -10506,20 +10572,38 @@ snapshots: dependencies: "@sentry/core": 10.11.0 + "@sentry-internal/browser-utils@8.55.0": + dependencies: + "@sentry/core": 8.55.0 + "@sentry-internal/feedback@10.11.0": dependencies: "@sentry/core": 10.11.0 + "@sentry-internal/feedback@8.55.0": + dependencies: + "@sentry/core": 8.55.0 + "@sentry-internal/replay-canvas@10.11.0": dependencies: "@sentry-internal/replay": 10.11.0 "@sentry/core": 10.11.0 + "@sentry-internal/replay-canvas@8.55.0": + dependencies: + "@sentry-internal/replay": 8.55.0 + "@sentry/core": 8.55.0 + "@sentry-internal/replay@10.11.0": dependencies: "@sentry-internal/browser-utils": 10.11.0 "@sentry/core": 10.11.0 + "@sentry-internal/replay@8.55.0": + dependencies: + "@sentry-internal/browser-utils": 8.55.0 + "@sentry/core": 8.55.0 + "@sentry/babel-plugin-component-annotate@4.3.0": {} "@sentry/browser@10.11.0": @@ -10530,6 +10614,14 @@ snapshots: "@sentry-internal/replay-canvas": 10.11.0 "@sentry/core": 10.11.0 + "@sentry/browser@8.55.0": + dependencies: + "@sentry-internal/browser-utils": 8.55.0 + "@sentry-internal/feedback": 8.55.0 + "@sentry-internal/replay": 8.55.0 + "@sentry-internal/replay-canvas": 8.55.0 + "@sentry/core": 8.55.0 + "@sentry/bundler-plugin-core@4.3.0": dependencies: "@babel/core": 7.28.3 @@ -10590,6 +10682,8 @@ snapshots: "@sentry/core@10.11.0": {} + "@sentry/core@8.55.0": {} + "@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)": dependencies: "@opentelemetry/api": 1.9.0 @@ -11967,6 +12061,8 @@ snapshots: binary-extensions@2.3.0: {} + bowser@2.12.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2