diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 56da86025..2e9c42fa7 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -5,7 +5,7 @@ ### 1. Platform Abstraction Layer (`server/reflector/video_platforms/`) - **base.py**: Abstract interface defining all platform operations - **whereby.py**: Whereby implementation wrapping existing functionality -- **daily.py**: Daily.co client implementation (ready for testing when credentials available) +- **daily.py**: Daily client implementation (ready for testing when credentials available) - **mock.py**: Mock implementation for unit testing - **registry.py**: Platform registration and discovery - **factory.py**: Factory methods for creating platform clients @@ -26,12 +26,12 @@ - **Room Creation**: Now assigns platform based on feature flags - **Meeting Creation**: Uses platform abstraction instead of direct Whereby calls - **Response Models**: Include platform field -- **Webhook Handler**: Added Daily.co webhook endpoint at `/v1/daily_webhook` +- **Webhook Handler**: Added Daily webhook endpoint at `/v1/daily/webhook` ### 5. Frontend Components (`www/app/[roomName]/components/`) - **RoomContainer.tsx**: Platform-agnostic container that routes to appropriate component - **WherebyRoom.tsx**: Extracted existing Whereby functionality with consent management -- **DailyRoom.tsx**: Daily.co implementation using DailyIframe +- **DailyRoom.tsx**: Daily implementation using DailyIframe - **Dependencies**: Added `@daily-co/daily-js` and `@daily-co/daily-react` ## How It Works @@ -81,7 +81,7 @@ ### 6. Testing & Validation (`server/tests/`) - **test_video_platforms.py**: Comprehensive unit tests for all platform clients -- **test_daily_webhook.py**: Integration tests for Daily.co webhook handling +- **test_daily_webhook.py**: Integration tests for Daily webhook handling - **utils/video_platform_test_utils.py**: Testing utilities and helpers - **Mock Testing**: Full test coverage using mock platform client - **Webhook Testing**: HMAC signature validation and event processing tests @@ -109,7 +109,7 @@ curl -X POST https://api.daily.co/v1/webhook-endpoints \ -H "Authorization: Bearer ${DAILY_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://yourdomain.com/v1/daily_webhook", + "url": "https://yourdomain.com/v1/daily/webhook", "events": [ "participant.joined", "participant.left", @@ -166,7 +166,7 @@ Daily.co uses HMAC-SHA256 for webhook verification: import hmac import hashlib -def verify_daily_webhook(body: bytes, signature: str, secret: str) -> bool: +def verify_webhook_signature(body: bytes, signature: str) -> bool: expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` @@ -231,7 +231,7 @@ print(f"Created meeting: {meeting.room_url}") ```python # Test webhook payload processing -from reflector.views.daily import daily_webhook +from reflector.views.daily import webhook from reflector.worker.process import process_recording_from_url # Simulate webhook event diff --git a/PLAN.md b/PLAN.md index 134712427..6ab90da6e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,287 +1,2452 @@ -# Daily.co Migration Plan - Feature Parity Approach - -## Overview - -This plan outlines a systematic migration from Whereby to Daily.co, focusing on **1:1 feature parity** without introducing new capabilities. The goal is to improve code quality, developer experience, and platform reliability while maintaining the exact same user experience and processing pipeline. - -## Migration Principles - -1. **No Breaking Changes**: Existing recordings and workflows must continue to work -2. **Feature Parity First**: Match current functionality exactly before adding improvements -3. **Gradual Rollout**: Use feature flags to control migration per room/user -4. **Minimal Risk**: Keep changes isolated and reversible - -## Phase 1: Foundation - -### 1.1 Environment Setup -**Owner**: Backend Developer - -- [ ] Create Daily.co account and obtain API credentials (PENDING - User to provide) -- [x] Add environment variables to `.env` files: - ```bash - DAILY_API_KEY=your-api-key - DAILY_WEBHOOK_SECRET=your-webhook-secret - DAILY_SUBDOMAIN=your-subdomain - AWS_DAILY_ROLE_ARN=arn:aws:iam::xxx:role/daily-recording - ``` -- [ ] Set up Daily.co webhook endpoint in dashboard (PENDING - Credentials needed) -- [ ] Configure S3 bucket permissions for Daily.co (PENDING - Credentials needed) - -### 1.2 Database Migration -**Owner**: Backend Developer - -- [x] Create Alembic migration: - ```python - # server/migrations/versions/20250801180012_add_platform_support.py - def upgrade(): - op.add_column('rooms', sa.Column('platform', sa.String(), server_default='whereby')) - op.add_column('meetings', sa.Column('platform', sa.String(), server_default='whereby')) - ``` -- [ ] Run migration on development database (USER TO RUN: `uv run alembic upgrade head`) -- [x] Update models to include platform field - -### 1.3 Feature Flag System -**Owner**: Full-stack Developer - -- [x] Implement feature flag in backend settings: - ```python - DAILY_MIGRATION_ENABLED = env.bool("DAILY_MIGRATION_ENABLED", False) - DAILY_MIGRATION_ROOM_IDS = env.list("DAILY_MIGRATION_ROOM_IDS", []) - ``` -- [x] Add platform selection logic to room creation -- [ ] Create admin UI to toggle platform per room (FUTURE - Not in Phase 1) - -### 1.4 Daily.co API Client -**Owner**: Backend Developer - -- [x] Create `server/reflector/video_platforms/` with core functionality: - - `create_meeting()` - Match Whereby's meeting creation - - `get_room_sessions()` - Room status checking - - `delete_room()` - Cleanup functionality -- [x] Add comprehensive error handling -- [ ] Write unit tests for API client (Phase 4) - -## Phase 2: Backend Integration - -### 2.1 Webhook Handler -**Owner**: Backend Developer - -- [x] Create `server/reflector/views/daily.py` webhook endpoint -- [x] Implement HMAC signature verification -- [x] Handle events: - - `participant.joined` - - `participant.left` - - `recording.started` - - `recording.ready-to-download` -- [x] Map Daily.co events to existing database updates -- [x] Register webhook router in main app -- [ ] Add webhook tests with mocked events (Phase 4) - -### 2.2 Room Management Updates -**Owner**: Backend Developer - -- [x] Update `server/reflector/views/rooms.py`: - ```python - # Uses platform abstraction layer - platform = get_platform_for_room(room.id) - client = create_platform_client(platform) - meeting_data = await client.create_meeting(...) - ``` -- [x] Ensure room URLs are stored correctly -- [x] Update meeting status checks to support both platforms -- [ ] Test room creation/deletion for both platforms (Phase 4) - -## Phase 3: Frontend Migration - -### 3.1 Daily.co React Setup -**Owner**: Frontend Developer - -- [x] Install Daily.co packages: - ```bash - yarn add @daily-co/daily-react @daily-co/daily-js - ``` -- [x] Create platform-agnostic components structure -- [x] Set up TypeScript interfaces for meeting data - -### 3.2 Room Component Refactor -**Owner**: Frontend Developer - -- [x] Create platform-agnostic room component: - ```tsx - // www/app/[roomName]/components/RoomContainer.tsx - export default function RoomContainer({ params }) { - const platform = meeting.response.platform || "whereby"; - if (platform === 'daily') { - return +# Technical Specification: Multi-Provider Video Platform Integration + +**Version:** 1.0 +**Status:** Ready for Implementation +**Target:** Steps 1-3 of Video Platform Migration +**Estimated Effort:** 12-16 hours (senior engineer) +**Branch Base:** Current `main` branch + +--- + +## Executive Summary + +This document provides a comprehensive technical specification for implementing multi-provider video platform support in Reflector, focusing on abstracting the existing Whereby integration and adding Daily.co as a second provider. The implementation follows a phased approach ensuring zero downtime, backward compatibility, and feature parity between providers. + +**Scope:** +- **Phase 1:** Extract existing Whereby implementation into reusable patterns +- **Phase 2:** Create provider abstraction layer maintaining current functionality +- **Phase 3:** Implement Daily.co provider with feature parity to Whereby + +**Out of Scope:** +- Multi-track audio processing (Phase 4 - future work) +- Jitsi integration (Phase 5 - future work) +- Platform selection UI (controlled via environment variables) +- Advanced Daily.co features (presence API, raw-tracks recording) + +--- + +## Business Context + +### Problem Statement + +Reflector currently has a hard dependency on Whereby for video conferencing. This creates: +1. **Vendor lock-in risk** - Single point of failure for core functionality +2. **Cost optimization limitations** - Cannot leverage competitive pricing +3. **Feature constraints** - Limited to Whereby's feature set +4. **Scalability concerns** - Dependent on Whereby's infrastructure reliability + +### Business Goals + +1. **Risk Mitigation:** Enable platform switching without code changes +2. **Cost Flexibility:** Allow deployment-specific provider selection +3. **Feature Expansion:** Prepare for future multi-track diarization (Daily.co raw-tracks) +4. **Architectural Cleanliness:** Establish patterns for future provider additions (Jitsi) + +### Success Criteria + +- ✅ Existing Whereby installations continue working unchanged +- ✅ New installations can choose Daily.co via environment variable +- ✅ Zero data migration required for existing deployments +- ✅ Recording processing pipeline unchanged +- ✅ Transcription quality identical between providers +- ✅ <2% performance overhead from abstraction layer +- ✅ Test coverage >90% for platform abstraction + +--- + +## Architecture Overview + +### Current Architecture (Whereby-only) + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend │ +│ └─ RoomPage.tsx │ +│ └─ web component │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Backend │ +│ └─ rooms.py (direct Whereby API calls) │ +│ └─ whereby.py (webhook handler) │ +│ └─ process.py (S3-based recording ingestion) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ External Services │ +│ ├─ Whereby API │ +│ ├─ Whereby Webhooks → SQS → recordings │ +│ └─ S3 (Whereby uploads directly) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Target Architecture (Multi-provider) + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend │ +│ └─ RoomContainer.tsx (platform router) │ +│ ├─ WherebyRoom.tsx │ +│ └─ DailyRoom.tsx │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Backend - Platform Abstraction Layer │ +│ ├─ VideoPlatformClient (ABC) │ +│ ├─ PlatformFactory │ +│ ├─ PlatformRegistry │ +│ └─ Platform Implementations: │ +│ ├─ WherebyClient │ +│ ├─ DailyClient │ +│ └─ MockClient (testing) │ +└─────────────────────────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ +┌───────────────────────┐ ┌──────────────────────┐ +│ Whereby Services │ │ Daily.co Services │ +│ ├─ Whereby API │ │ ├─ Daily.co API │ +│ ├─ Webhooks │ │ ├─ Webhooks │ +│ └─ S3 Direct Upload │ │ └─ Download URLs │ +└───────────────────────┘ └──────────────────────┘ +``` + +### Key Architectural Principles + +1. **Single Responsibility:** Each provider implements only platform-specific logic +2. **Open/Closed:** New providers can be added without modifying existing code +3. **Liskov Substitution:** All providers are interchangeable via base interface +4. **Dependency Inversion:** Business logic depends on abstraction, not implementations +5. **Interface Segregation:** Platform interface contains only universally needed methods + +--- + +## Phase 1: Analysis & Extraction (2 hours) + +### Objective +Understand current Whereby implementation patterns to inform abstraction design. + +### Step 1.1: Audit Current Whereby Implementation + +**Files to analyze:** + +```bash +# Backend +server/reflector/views/rooms.py # Room/meeting creation logic +server/reflector/views/whereby.py # Webhook handler +server/reflector/worker/process.py # Recording processing + +# Frontend +www/app/[roomName]/page.tsx # Room page component +www/app/(app)/rooms/page.tsx # Room creation form + +# Database +server/reflector/db/rooms.py # Room model +server/reflector/db/meetings.py # Meeting model +server/reflector/db/recordings.py # Recording model +``` + +**Create analysis document:** + +```markdown +# Whereby Integration Analysis + +## API Calls Made +1. Create meeting: POST to whereby.dev/v1/meetings +2. Required fields: endDate, roomMode, fields +3. Response structure: { meetingId, roomUrl, hostRoomUrl } + +## Webhook Events Received +1. room.client.joined - participant count++ +2. room.client.left - participant count-- + +## Recording Flow +1. Whereby uploads MP4 to S3 bucket (direct) +2. S3 event → SQS queue +3. Worker polls SQS → downloads from S3 +4. Processing pipeline: transcription → diarization → summarization + +## Data Stored +- Room: whereby-specific fields (if any) +- Meeting: meetingId, roomUrl, hostRoomUrl +- Recording: S3 bucket, object_key + +## Frontend Integration +- web component +- SDK loaded via dynamic import +- Custom focus management for consent dialog +- Events: leave, ready +``` + +### Step 1.2: Identify Abstraction Points + +**Create abstraction requirements document:** + +```markdown +# Platform Abstraction Requirements + +## Must Abstract +1. Meeting creation (different APIs, different request/response formats) +2. Webhook signature verification (different algorithms/formats) +3. Recording ingestion (S3 direct vs download URL) +4. Frontend room component (web component vs iframe) + +## Can Remain Concrete +1. Recording processing pipeline (same for all providers) +2. Transcription/diarization (provider-agnostic) +3. Database schema (add platform field, rest unchanged) +4. User consent flow (same UI/UX) + +## Platform-Specific Differences +| Feature | Whereby | Daily.co | +|---------|---------|----------| +| Meeting creation | REST API | REST API | +| Authentication | API key header | Bearer token | +| Recording delivery | S3 upload | Download URL | +| Frontend SDK | Web component | iframe/React SDK | +| Webhook signature | HMAC + timestamp | HMAC only | +| Room expiration | Automatic | Manual or via exp field | +``` + +### Step 1.3: Define Standard Data Models + +**Create `server/reflector/video_platforms/models.py`:** + +```python +from datetime import datetime +from typing import Literal, Optional +from pydantic import BaseModel, Field + + +Platform = Literal["whereby", "daily"] + + +class MeetingData(BaseModel): + """Standardized meeting data returned by all providers.""" + + platform: Platform + meeting_id: str = Field(description="Platform-specific meeting identifier") + room_url: str = Field(description="URL for participants to join") + host_room_url: str = Field(description="URL for hosts (may be same as room_url)") + room_name: str = Field(description="Human-readable room name") + start_date: Optional[datetime] = None + end_date: datetime + + class Config: + json_schema_extra = { + "example": { + "platform": "whereby", + "meeting_id": "12345678", + "room_url": "https://subdomain.whereby.com/room-20251008120000", + "host_room_url": "https://subdomain.whereby.com/room-20251008120000?roomKey=abc123", + "room_name": "room-20251008120000", + "end_date": "2025-10-08T14:00:00Z" + } + } + + +class VideoPlatformConfig(BaseModel): + """Platform-agnostic configuration model.""" + + api_key: str + webhook_secret: Optional[str] = None + subdomain: Optional[str] = None # Whereby/Daily subdomain + s3_bucket: Optional[str] = None + s3_region: str = "us-west-2" + # Whereby uses access keys, Daily uses IAM role + aws_access_key: Optional[str] = None + aws_secret_key: Optional[str] = None + aws_role_arn: Optional[str] = None + + +class RecordingType: + """Recording type constants.""" + NONE = "none" + LOCAL = "local" + CLOUD = "cloud" +``` + +### Deliverables +- [ ] Whereby integration analysis document +- [ ] Abstraction requirements document +- [ ] Standard data models in `models.py` +- [ ] List of files requiring modification + +--- + +## Phase 2: Platform Abstraction Layer (4-5 hours) + +### Objective +Create a clean abstraction layer without breaking existing Whereby functionality. + +### Step 2.1: Create Base Abstraction + +**File: `server/reflector/video_platforms/__init__.py`** + +```python +"""Video platform abstraction layer.""" + +from .base import VideoPlatformClient +from .models import Platform, MeetingData, VideoPlatformConfig +from .factory import create_platform_client, get_platform_config +from .registry import register_platform, get_platform_client_class + +__all__ = [ + "VideoPlatformClient", + "Platform", + "MeetingData", + "VideoPlatformConfig", + "create_platform_client", + "get_platform_config", + "register_platform", + "get_platform_client_class", +] +``` + +**File: `server/reflector/video_platforms/base.py`** + +```python +"""Abstract base class for video platform clients.""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from datetime import datetime + +from .models import MeetingData, Platform, VideoPlatformConfig + +# Import Room with TYPE_CHECKING to avoid circular imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from reflector.db.rooms import Room + + +class VideoPlatformClient(ABC): + """ + Abstract base class for video platform integrations. + + All video platform providers (Whereby, Daily.co, etc.) must implement + this interface to ensure consistent behavior across the application. + + Design Principles: + - Methods should be platform-agnostic in their contracts + - Return standardized data models (MeetingData) + - Raise HTTPException for errors (FastAPI integration) + - Use async/await for all I/O operations + """ + + PLATFORM_NAME: Platform # Must be set by subclasses + + def __init__(self, config: VideoPlatformConfig): + """ + Initialize the platform client with configuration. + + Args: + config: Platform configuration with API keys, webhooks, etc. + """ + self.config = config + + @abstractmethod + async def create_meeting( + self, + room_name_prefix: str, + end_date: datetime, + room: "Room", + ) -> MeetingData: + """ + Create a new meeting room on the platform. + + Args: + room_name_prefix: Prefix for generating unique room name + end_date: When the meeting should expire + room: Room database model with configuration + + Returns: + MeetingData with platform-specific meeting details + + Raises: + HTTPException: On API errors or validation failures + + Implementation Notes: + - Generate room name as: {prefix}-YYYYMMDDHHMMSS + - Configure recording based on room.recording_type + - Set privacy based on room.is_locked + - Use room.room_size if platform supports capacity limits + """ + pass + + @abstractmethod + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """ + Get current session information for a room. + + Args: + room_name: The room identifier + + Returns: + Platform-specific session data + + Raises: + HTTPException: If room not found or API error + """ + pass + + @abstractmethod + async def delete_room(self, room_name: str) -> bool: + """ + Delete a room from the platform. + + Args: + room_name: The room identifier + + Returns: + True if deleted successfully or already doesn't exist + + Raises: + HTTPException: On API errors (except 404) + + Implementation Notes: + - Some platforms (Whereby) auto-expire rooms and don't support deletion + - Return True for 404 (idempotent operation) + """ + pass + + @abstractmethod + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """ + Upload a custom logo for a room (if supported). + + Args: + room_name: The room identifier + logo_path: Path to logo file + + Returns: + True if uploaded successfully + + Implementation Notes: + - Not all platforms support per-room logos + - Return True immediately if not supported (graceful degradation) + """ + pass + + @abstractmethod + def verify_webhook_signature( + self, + body: bytes, + signature: str, + timestamp: Optional[str] = None, + ) -> bool: + """ + Verify webhook request authenticity using HMAC signature. + + Args: + body: Raw request body bytes + signature: Signature from request header + timestamp: Optional timestamp for replay attack prevention + + Returns: + True if signature is valid + + Implementation Notes: + - Use constant-time comparison (hmac.compare_digest) + - Whereby: signature format is "t={timestamp},v1={sig}" + - Daily.co: signature is simple HMAC hex digest + - Implement timestamp freshness check if platform supports it + """ + pass +``` + +### Step 2.2: Create Platform Registry + +**File: `server/reflector/video_platforms/registry.py`** + +```python +"""Platform registration and discovery system.""" + +from typing import Dict, Type +from .base import VideoPlatformClient +from .models import Platform + + +# Global registry of available platform clients +_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} + + +def register_platform( + platform_name: Platform, + client_class: Type[VideoPlatformClient], +) -> None: + """ + Register a video platform client implementation. + + Args: + platform_name: Unique platform identifier ("whereby", "daily") + client_class: Client class implementing VideoPlatformClient + + Example: + register_platform("whereby", WherebyClient) + """ + if platform_name in _PLATFORMS: + raise ValueError(f"Platform '{platform_name}' already registered") + + # Validate that the class implements the interface + if not issubclass(client_class, VideoPlatformClient): + raise TypeError( + f"Client class must inherit from VideoPlatformClient, " + f"got {client_class}" + ) + + _PLATFORMS[platform_name] = client_class + + +def get_platform_client_class(platform: Platform) -> Type[VideoPlatformClient]: + """ + Retrieve a registered platform client class. + + Args: + platform: Platform identifier + + Returns: + Client class for the specified platform + + Raises: + ValueError: If platform not registered + """ + if platform not in _PLATFORMS: + available = ", ".join(_PLATFORMS.keys()) + raise ValueError( + f"Unknown platform '{platform}'. " + f"Available platforms: {available}" + ) + + return _PLATFORMS[platform] + + +def get_available_platforms() -> list[Platform]: + """Get list of all registered platforms.""" + return list(_PLATFORMS.keys()) +``` + +### Step 2.3: Create Platform Factory + +**File: `server/reflector/video_platforms/factory.py`** + +```python +"""Factory functions for creating platform clients.""" + +from typing import Optional +from reflector import settings +from .base import VideoPlatformClient +from .models import Platform, VideoPlatformConfig +from .registry import get_platform_client_class + + +def get_platform_config(platform: Platform) -> VideoPlatformConfig: + """ + Build platform-specific configuration from settings. + + Args: + platform: Platform identifier + + Returns: + VideoPlatformConfig with platform-specific values + + Raises: + ValueError: If required settings are missing + """ + if platform == "whereby": + if not settings.WHEREBY_API_KEY: + raise ValueError("WHEREBY_API_KEY is required for Whereby platform") + + return VideoPlatformConfig( + api_key=settings.WHEREBY_API_KEY, + webhook_secret=settings.WHEREBY_WEBHOOK_SECRET, + subdomain=None, # Whereby doesn't use subdomains + s3_bucket=settings.AWS_WHEREBY_S3_BUCKET, + s3_region=settings.AWS_S3_REGION or "us-west-2", + aws_access_key=settings.AWS_WHEREBY_ACCESS_KEY_ID, + aws_secret_key=settings.AWS_WHEREBY_ACCESS_KEY_SECRET, + ) + + elif platform == "daily": + if not settings.DAILY_API_KEY: + raise ValueError("DAILY_API_KEY is required for Daily.co platform") + + return VideoPlatformConfig( + api_key=settings.DAILY_API_KEY, + webhook_secret=settings.DAILY_WEBHOOK_SECRET, + subdomain=settings.DAILY_SUBDOMAIN, + s3_bucket=settings.AWS_DAILY_S3_BUCKET, + s3_region=settings.AWS_DAILY_S3_REGION or "us-west-2", + aws_role_arn=settings.AWS_DAILY_ROLE_ARN, + ) + + else: + raise ValueError(f"Unknown platform: {platform}") + + +def create_platform_client(platform: Platform) -> VideoPlatformClient: + """ + Create and configure a platform client instance. + + Args: + platform: Platform identifier + + Returns: + Configured client instance + + Example: + client = create_platform_client("whereby") + meeting = await client.create_meeting(...) + """ + config = get_platform_config(platform) + client_class = get_platform_client_class(platform) + return client_class(config) + + +def get_platform_for_room(room_id: Optional[str] = None) -> Platform: + """ + Determine which platform to use for a room. + + This implements the platform selection strategy using feature flags. + + Args: + room_id: Optional room ID for room-specific overrides + + Returns: + Platform to use + + Platform Selection Logic: + 1. If DAILY_MIGRATION_ENABLED=False → always use "whereby" + 2. If room_id in DAILY_MIGRATION_ROOM_IDS → use "daily" + 3. Otherwise → use DEFAULT_VIDEO_PLATFORM + + Example Environment Variables: + DAILY_MIGRATION_ENABLED=true + DAILY_MIGRATION_ROOM_IDS=["room-abc", "room-xyz"] + DEFAULT_VIDEO_PLATFORM=whereby + """ + # If Daily migration is disabled, always use Whereby + if not settings.DAILY_MIGRATION_ENABLED: + return "whereby" + + # If specific room is in migration list, use Daily + if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS: + return "daily" + + # Otherwise use the configured default + return settings.DEFAULT_VIDEO_PLATFORM +``` + +### Step 2.4: Create Mock Implementation for Testing + +**File: `server/reflector/video_platforms/mock.py`** + +```python +"""Mock video platform client for testing.""" + +import hmac +from datetime import datetime, timedelta +from typing import Any, Dict, Optional +from hashlib import sha256 + +from .base import VideoPlatformClient +from .models import MeetingData, Platform + +# Import with TYPE_CHECKING to avoid circular imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from reflector.db.rooms import Room + + +class MockClient(VideoPlatformClient): + """ + Mock video platform client for unit testing. + + This client simulates a video platform without making real API calls. + Useful for testing business logic without external dependencies. + """ + + PLATFORM_NAME: Platform = "whereby" # Pretend to be Whereby for backward compat + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._rooms: Dict[str, Dict[str, Any]] = {} + self._participants: Dict[str, int] = {} + + async def create_meeting( + self, + room_name_prefix: str, + end_date: datetime, + room: "Room", + ) -> MeetingData: + """Create a mock meeting.""" + room_name = f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}" + + self._rooms[room_name] = { + "name": room_name, + "end_date": end_date, + "room": room, + } + self._participants[room_name] = 0 + + return MeetingData( + platform=self.PLATFORM_NAME, + meeting_id=f"mock-{room_name}", + room_url=f"https://mock.example.com/{room_name}", + host_room_url=f"https://mock.example.com/{room_name}?host=true", + room_name=room_name, + end_date=end_date, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """Get mock room session data.""" + if room_name not in self._rooms: + raise ValueError(f"Room {room_name} not found") + + return { + "room_name": room_name, + "participants": self._participants.get(room_name, 0), + "created": self._rooms[room_name].get("created", datetime.now()), + } + + async def delete_room(self, room_name: str) -> bool: + """Delete mock room.""" + if room_name in self._rooms: + del self._rooms[room_name] + del self._participants[room_name] + return True + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """Mock logo upload (always succeeds).""" + return True + + def verify_webhook_signature( + self, + body: bytes, + signature: str, + timestamp: Optional[str] = None, + ) -> bool: + """Mock signature verification (accepts 'valid' as signature).""" + return signature == "valid" + + # Test helper methods + def add_participant(self, room_name: str) -> None: + """Add a participant to a room (test helper).""" + if room_name in self._participants: + self._participants[room_name] += 1 + + def remove_participant(self, room_name: str) -> None: + """Remove a participant from a room (test helper).""" + if room_name in self._participants and self._participants[room_name] > 0: + self._participants[room_name] -= 1 + + def clear_data(self) -> None: + """Clear all mock data (test helper).""" + self._rooms.clear() + self._participants.clear() +``` + +### Step 2.5: Implement Whereby Client Wrapper + +**File: `server/reflector/video_platforms/whereby.py`** + +```python +"""Whereby platform client implementation.""" + +import re +import hmac +import json +from datetime import datetime +from hashlib import sha256 +from typing import Any, Dict, Optional + +import httpx +from fastapi import HTTPException + +from .base import VideoPlatformClient +from .models import MeetingData, Platform + +# Import with TYPE_CHECKING to avoid circular imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from reflector.db.rooms import Room + + +class WherebyClient(VideoPlatformClient): + """ + Whereby video platform client. + + Wraps the existing Whereby API integration into the platform abstraction. + + API Documentation: https://docs.whereby.com/ + """ + + PLATFORM_NAME: Platform = "whereby" + BASE_URL = "https://api.whereby.dev/v1" + TIMEOUT = 10 + SIGNATURE_MAX_AGE = 60 # seconds + + def __init__(self, config): + 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 Whereby meeting room. + + See: https://docs.whereby.com/reference/whereby-rest-api-reference#create-meeting + """ + room_name = f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}" + + # Build request payload + data = { + "endDate": end_date.isoformat(), + "fields": ["hostRoomUrl"], + "roomNamePrefix": f"/{room_name}", + } + + # Configure room mode based on lock status + if room.is_locked: + data["roomMode"] = "normal" + else: + data["roomMode"] = "group" + + # Configure recording if enabled + if room.recording_type == "cloud" and self.config.s3_bucket: + data["recording"] = { + "type": "cloud", + "destination": { + "provider": "s3", + "config": { + "bucket": self.config.s3_bucket, + "region": self.config.s3_region, + "accessKeyId": self.config.aws_access_key, + "secretAccessKey": self.config.aws_secret_key, + } + } + } + + # Make API request + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/meetings", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + result = response.json() + + # Transform to standard format + return MeetingData( + platform=self.PLATFORM_NAME, + meeting_id=result["meetingId"], + room_url=result["roomUrl"], + host_room_url=result.get("hostRoomUrl", result["roomUrl"]), + room_name=room_name, + end_date=end_date, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """Get Whereby room session data.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.BASE_URL}/meetings/{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 rooms auto-expire, so deletion is a no-op. + + Returns True to maintain interface compatibility. + """ + return True + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """ + Upload custom logo to Whereby room. + + Note: This requires reading the logo file and making a multipart request. + Implementation depends on logo storage strategy. + """ + # TODO: Implement logo upload if needed + # For now, return True (feature not critical) + return True + + def verify_webhook_signature( + self, + body: bytes, + signature: str, + timestamp: Optional[str] = None, + ) -> bool: + """ + Verify Whereby webhook signature. + + Whereby signature format: "t={timestamp},v1={signature}" + Algorithm: HMAC-SHA256(webhook_secret, timestamp + "." + body) + + See: https://docs.whereby.com/reference/whereby-rest-api-reference#webhook-signatures + """ + if not self.config.webhook_secret: + raise ValueError("webhook_secret is required for signature verification") + + # Parse signature format: t={timestamp},v1={signature} + matches = re.match(r"t=(.*),v1=(.*)", signature) + if not matches: + return False + + sig_timestamp, sig_hash = matches.groups() + + # Check timestamp freshness (prevent replay attacks) + try: + ts = int(sig_timestamp) + now = int(datetime.now().timestamp()) + if abs(now - ts) > self.SIGNATURE_MAX_AGE: + return False + except (ValueError, TypeError): + return False + + # Compute expected signature + message = f"{sig_timestamp}.{body.decode('utf-8')}" + expected_sig = hmac.new( + self.config.webhook_secret.encode(), + message.encode(), + sha256 + ).hexdigest() + + # Constant-time comparison + return hmac.compare_digest(expected_sig, sig_hash) +``` + +### Step 2.6: Register Whereby Client + +**Add to `server/reflector/video_platforms/__init__.py`:** + +```python +# Auto-register built-in platforms +from .whereby import WherebyClient +from .mock import MockClient + +register_platform("whereby", WherebyClient) +``` + +### Step 2.7: Update Settings + +**File: `server/reflector/settings.py`** + +Add Daily.co settings and feature flags: + +```python +# Existing Whereby settings (already present) +WHEREBY_API_URL: str = "https://api.whereby.dev/v1" +WHEREBY_API_KEY: str | None = None +WHEREBY_WEBHOOK_SECRET: str | None = None +AWS_WHEREBY_S3_BUCKET: str | None = None +AWS_WHEREBY_ACCESS_KEY_ID: str | None = None +AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None + +# NEW: Daily.co API 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 + +# NEW: Platform Migration Feature Flags +DAILY_MIGRATION_ENABLED: bool = False # Conservative default +DAILY_MIGRATION_ROOM_IDS: list[str] = [] # Specific rooms for gradual rollout +DEFAULT_VIDEO_PLATFORM: Literal["whereby", "daily"] = "whereby" # Default to Whereby +``` + +### Step 2.8: Update Database Schema + +**Create migration: `server/migrations/versions/YYYYMMDDHHMMSS_add_platform_support.py`** + +```bash +cd server +uv run alembic revision -m "add_platform_support" +``` + +**Migration content:** + +```python +"""add_platform_support + +Adds platform field to room and meeting tables to support multi-provider architecture. + +Revision ID: +Revises: +Create Date: +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '' +down_revision = '' +branch_labels = None +depends_on = None + + +def upgrade(): + """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(): + """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") +``` + +**Update models:** + +**File: `server/reflector/db/rooms.py`** + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from reflector.video_platforms.models import Platform + +class Room: + # ... existing fields ... + + # NEW: Platform field + platform: "Platform" = sqlalchemy.Column( + sqlalchemy.String, + nullable=False, + server_default="whereby", + ) +``` + +**File: `server/reflector/db/meetings.py`** + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from reflector.video_platforms.models import Platform + +class Meeting: + # ... existing fields ... + + # NEW: Platform field + platform: "Platform" = sqlalchemy.Column( + sqlalchemy.String, + nullable=False, + server_default="whereby", + ) +``` + +### Step 2.9: Refactor Room/Meeting Creation + +**File: `server/reflector/views/rooms.py`** + +Replace direct Whereby API calls with platform abstraction: + +```python +from reflector.video_platforms import ( + create_platform_client, + get_platform_for_room, +) + +# OLD CODE (remove): +# from reflector import whereby +# meeting_data = whereby.create_meeting(...) + +# NEW CODE: +@router.post("/rooms", response_model=RoomResponse) +async def create_room(room_data: RoomCreate): + """Create a new room.""" + + # Determine platform for new room + platform = get_platform_for_room() + + # Create room in database + room = Room( + name=room_data.name, + is_locked=room_data.is_locked, + recording_type=room_data.recording_type, + platform=platform, # NEW: Store platform + # ... other fields ... + ) + await room.save() + + return RoomResponse.from_orm(room) + + +@router.post("/rooms/{room_name}/meeting", response_model=MeetingResponse) +async def create_meeting(room_name: str, meeting_data: MeetingCreate): + """Create a new meeting in a room.""" + + # Get room + room = await Room.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + # Use platform abstraction instead of direct Whereby calls + platform = get_platform_for_room(room.id) # Respects feature flags + client = create_platform_client(platform) + + # Create meeting via platform client + meeting_data = await client.create_meeting( + room_name_prefix=room.name, + end_date=meeting_data.end_date, + room=room, + ) + + # Create database record + meeting = Meeting( + room_id=room.id, + platform=meeting_data.platform, # NEW: Store platform + meeting_id=meeting_data.meeting_id, + room_url=meeting_data.room_url, + host_room_url=meeting_data.host_room_url, + # ... other fields ... + ) + await meeting.save() + + # Upload logo if configured (platform handles graceful degradation) + if room.logo_path: + await client.upload_logo(meeting_data.room_name, room.logo_path) + + return MeetingResponse.from_orm(meeting) +``` + +### Deliverables +- [ ] Platform abstraction layer (`base.py`, `registry.py`, `factory.py`, `models.py`) +- [ ] Whereby client wrapper (`whereby.py`) +- [ ] Mock client for testing (`mock.py`) +- [ ] Database migration for platform field +- [ ] Updated room/meeting models +- [ ] Refactored room creation logic +- [ ] Updated settings with feature flags + +--- + +## Phase 3: Daily.co Implementation (4-5 hours) + +### Objective +Implement Daily.co provider with feature parity to Whereby. + +### Step 3.1: Implement Daily.co Client + +**File: `server/reflector/video_platforms/daily.py`** + +```python +"""Daily.co platform client implementation.""" + +import hmac +from datetime import datetime +from hashlib import sha256 +from typing import Any, Dict, Optional + +import httpx +from fastapi import HTTPException + +from .base import VideoPlatformClient +from .models import MeetingData, Platform + +# Import with TYPE_CHECKING to avoid circular imports +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from reflector.db.rooms import Room + + +class DailyClient(VideoPlatformClient): + """ + Daily.co video platform client. + + API Documentation: https://docs.daily.co/reference/rest-api + """ + + PLATFORM_NAME: Platform = "daily" + BASE_URL = "https://api.daily.co/v1" + TIMEOUT = 10 + + def __init__(self, config): + 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. + + See: https://docs.daily.co/reference/rest-api/rooms/create-room + """ + room_name = f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}" + + # Build request payload + data = { + "name": room_name, + "privacy": "private" if room.is_locked else "public", + "properties": { + "exp": int(end_date.timestamp()), + "enable_chat": True, + "enable_screenshare": True, + "start_video_off": False, + "start_audio_off": False, + } + } + + # Configure recording if enabled + if room.recording_type == "cloud": + data["properties"]["enable_recording"] = "cloud" + + # Configure S3 recording destination if bucket configured + if self.config.s3_bucket and self.config.aws_role_arn: + 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, + } + elif room.recording_type == "local": + data["properties"]["enable_recording"] = "local" + else: + data["properties"]["enable_recording"] = False + + # Make API request + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.BASE_URL}/rooms", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + result = response.json() + + # Build room URL + room_url = result["url"] + + # Daily.co doesn't have separate host URLs + host_room_url = room_url + + # Transform to standard format + return MeetingData( + platform=self.PLATFORM_NAME, + meeting_id=result["id"], # Daily.co room ID + room_url=room_url, + host_room_url=host_room_url, + room_name=result["name"], + end_date=end_date, + ) + + async def get_room_sessions(self, room_name: str) -> Dict[str, Any]: + """ + Get Daily.co room information. + + See: https://docs.daily.co/reference/rest-api/rooms/get-room-info + """ + 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 presence (Daily.co-specific feature). + + See: https://docs.daily.co/reference/rest-api/rooms/get-room-presence + + Note: This method is NOT in the base interface since it's platform-specific. + Only call this if you know you're using Daily.co. + """ + 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. + + See: https://docs.daily.co/reference/rest-api/rooms/delete-room + """ + async with httpx.AsyncClient() as client: + response = await client.delete( + f"{self.BASE_URL}/rooms/{room_name}", + headers=self.headers, + timeout=self.TIMEOUT, + ) + + # Accept both 200 (deleted) and 404 (already gone) as success + if response.status_code in (200, 404): + return True + + response.raise_for_status() + return False + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + """ + Daily.co doesn't support per-room logos. + + Return True for interface compatibility (graceful degradation). + """ + return True + + def verify_webhook_signature( + self, + body: bytes, + signature: str, + timestamp: Optional[str] = None, + ) -> bool: + """ + Verify Daily.co webhook signature. + + Daily.co signature format: Simple HMAC-SHA256 hex digest + Header: X-Daily-Signature + Algorithm: HMAC-SHA256(webhook_secret, body) + + See: https://docs.daily.co/reference/rest-api/webhooks#webhook-signatures + """ + if not self.config.webhook_secret: + raise ValueError("webhook_secret is required for signature verification") + + # Compute expected signature + expected_sig = hmac.new( + self.config.webhook_secret.encode(), + body, + sha256 + ).hexdigest() + + # Constant-time comparison + return hmac.compare_digest(expected_sig, signature) +``` + +### Step 3.2: Register Daily.co Client + +**Update `server/reflector/video_platforms/__init__.py`:** + +```python +# Auto-register built-in platforms +from .whereby import WherebyClient +from .daily import DailyClient +from .mock import MockClient + +register_platform("whereby", WherebyClient) +register_platform("daily", DailyClient) +``` + +### Step 3.3: Create Daily.co Webhook Handler + +**File: `server/reflector/views/daily.py`** + +```python +"""Daily.co webhook endpoint.""" + +from fastapi import APIRouter, HTTPException, Header, Request +from pydantic import BaseModel, Field +from typing import Literal, Optional +from datetime import datetime + +from reflector import settings +from reflector.logger import logger +from reflector.db.meetings import Meeting +from reflector.video_platforms import create_platform_client + + +router = APIRouter() + + +# Webhook event models +class DailyWebhookEvent(BaseModel): + """Base Daily.co webhook event.""" + type: str + payload: dict + id: str + timestamp: datetime + + +class ParticipantPayload(BaseModel): + """Participant event payload.""" + room: str + participant_id: str + user_name: Optional[str] = None + + +class RecordingPayload(BaseModel): + """Recording event payload.""" + room: str + recording_id: str + download_url: Optional[str] = None + error: Optional[str] = None + + +@router.post("/webhook") +async def daily_webhook( + request: Request, + x_daily_signature: str = Header(None), +): + """ + Handle Daily.co webhook events. + + Supported events: + - participant.joined - Update participant count + - participant.left - Update participant count + - recording.started - Log recording start + - recording.ready-to-download - Trigger processing + - recording.error - Log recording errors + + See: https://docs.daily.co/reference/rest-api/webhooks + """ + # Get raw body for signature verification + body = await request.body() + + # Verify signature + if not x_daily_signature: + raise HTTPException(status_code=400, detail="Missing X-Daily-Signature header") + + client = create_platform_client("daily") + if not client.verify_webhook_signature(body, x_daily_signature): + logger.warning("Daily.co webhook signature verification failed") + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse event + try: + event = DailyWebhookEvent.parse_raw(body) + except Exception as e: + logger.error(f"Failed to parse Daily.co webhook: {e}") + raise HTTPException(status_code=400, detail="Invalid event format") + + logger.info(f"Daily.co webhook event: {event.type} (room: {event.payload.get('room')})") + + # Handle event by type + 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) + + else: + logger.warning(f"Unhandled Daily.co event type: {event.type}") + + return {"status": "ok"} + + +async def handle_participant_joined(event: DailyWebhookEvent): + """Handle participant joining a room.""" + room_name = event.payload.get("room") + if not room_name: + return + + # Find active meeting for this room + meeting = await Meeting.get_active_by_room_name(room_name) + if not meeting: + logger.warning(f"No active meeting found for room: {room_name}") + return + + # Increment participant count + meeting.num_clients = (meeting.num_clients or 0) + 1 + await meeting.save() + + logger.info(f"Participant joined {room_name}, count: {meeting.num_clients}") + + +async def handle_participant_left(event: DailyWebhookEvent): + """Handle participant leaving a room.""" + room_name = event.payload.get("room") + if not room_name: + return + + # Find active meeting for this room + meeting = await Meeting.get_active_by_room_name(room_name) + if not meeting: + return + + # Decrement participant count (don't go below 0) + meeting.num_clients = max(0, (meeting.num_clients or 1) - 1) + await meeting.save() + + logger.info(f"Participant left {room_name}, count: {meeting.num_clients}") + + +async def handle_recording_started(event: DailyWebhookEvent): + """Handle recording start.""" + room_name = event.payload.get("room") + recording_id = event.payload.get("recording_id") + + logger.info(f"Recording started for room {room_name}: {recording_id}") + + +async def handle_recording_ready(event: DailyWebhookEvent): + """Handle recording ready for download.""" + room_name = event.payload.get("room") + recording_id = event.payload.get("recording_id") + download_url = event.payload.get("download_url") + + if not download_url: + logger.error(f"Recording ready but no download URL: {recording_id}") + return + + # Find meeting for this room + meeting = await Meeting.get_by_room_name(room_name) + if not meeting: + logger.error(f"No meeting found for room: {room_name}") + return + + logger.info(f"Recording ready: {recording_id}, triggering processing") + + # Trigger background processing + from reflector.worker.process import process_recording_from_url + process_recording_from_url.delay( + recording_url=download_url, + meeting_id=str(meeting.id), + recording_id=recording_id, + ) + + +async def handle_recording_error(event: DailyWebhookEvent): + """Handle recording errors.""" + room_name = event.payload.get("room") + recording_id = event.payload.get("recording_id") + error = event.payload.get("error") + + logger.error( + f"Recording error for room {room_name}: {error}", + extra={"recording_id": recording_id} + ) +``` + +**Register router in `server/reflector/app.py`:** + +```python +from reflector.views import daily + +app.include_router(daily.router, prefix="/v1/daily", tags=["daily"]) +``` + +### Step 3.4: Create Recording Processing Task + +**Update `server/reflector/worker/process.py`:** + +```python +from celery import shared_task +import httpx +from pathlib import Path +import tempfile + +from reflector.db.recordings import Recording +from reflector.db.meetings import Meeting +from reflector.logger import logger + + +@shared_task +@asynctask +async def process_recording_from_url( + recording_url: str, + meeting_id: str, + recording_id: str, +): + """ + Download and process a recording from a URL (Daily.co). + + This task is triggered by Daily.co webhooks when a recording is ready. + + Args: + recording_url: HTTPS URL to download recording from + meeting_id: Database ID of the meeting + recording_id: Platform-specific recording identifier + """ + logger.info(f"Processing recording from URL: {recording_id}") + + # Get meeting + meeting = await Meeting.get(meeting_id) + if not meeting: + logger.error(f"Meeting not found: {meeting_id}") + return + + # Download recording to temporary file + try: + async with httpx.AsyncClient() as client: + response = await client.get( + recording_url, + timeout=300, # 5 minutes for large files + ) + response.raise_for_status() + + # Save to temporary file + with tempfile.NamedTemporaryFile( + suffix=".mp4", + delete=False, + ) as tmp_file: + tmp_file.write(response.content) + local_path = tmp_file.name + + logger.info(f"Downloaded recording to: {local_path}") + + except Exception as e: + logger.error(f"Failed to download recording: {e}") + return + + try: + # Validate audio stream exists + import ffmpeg + probe = ffmpeg.probe(local_path) + audio_streams = [ + s for s in probe["streams"] + if s["codec_type"] == "audio" + ] + + if not audio_streams: + logger.error(f"No audio stream in recording: {recording_id}") + return + + # Create recording record + recording = Recording( + meeting_id=meeting_id, + bucket="daily-recordings", # Logical bucket name + object_key=recording_id, # Store Daily.co recording ID + local_path=local_path, + status="downloaded", + ) + await recording.save() + + logger.info(f"Created recording record: {recording.id}") + + # Trigger main processing pipeline + from reflector.worker.pipeline import task_pipeline_process + task_pipeline_process.delay(transcript_id=str(recording.transcript_id)) + + except Exception as e: + logger.error(f"Failed to process recording: {e}") + # Clean up temporary file on error + Path(local_path).unlink(missing_ok=True) + raise +``` + +### Step 3.5: Create Frontend Components + +**File: `www/app/[roomName]/components/RoomContainer.tsx`** + +```typescript +'use client' + +import { useEffect, useState } from 'react' +import WherebyRoom from './WherebyRoom' +import DailyRoom from './DailyRoom' +import { Meeting } from '@/app/api/types.gen' + +interface RoomContainerProps { + meeting: Meeting +} + +export default function RoomContainer({ meeting }: RoomContainerProps) { + // Determine platform from meeting response + const platform = meeting.platform || 'whereby' // Default for backward compat + + // Route to appropriate platform component + if (platform === 'daily') { + return + } + + // Default to Whereby + return +} +``` + +**File: `www/app/[roomName]/components/DailyRoom.tsx`** + +```typescript +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import DailyIframe, { DailyCall } from '@daily-co/daily-js' +import { Meeting } from '@/app/api/types.gen' +import { Box, Button, Text, useToast } from '@chakra-ui/react' +import { useSessionStatus } from '@/hooks/useSessionStatus' +import { api } from '@/app/api/client' + +interface DailyRoomProps { + meeting: Meeting +} + +export default function DailyRoom({ meeting }: DailyRoomProps) { + const router = useRouter() + const toast = useToast() + const containerRef = useRef(null) + const callFrameRef = useRef(null) + const [isLoading, setIsLoading] = useState(true) + const [showConsent, setShowConsent] = useState(false) + + const { sessionId } = useSessionStatus(meeting.id) + + useEffect(() => { + if (!containerRef.current) return + + // Check if recording requires consent + if (meeting.recording_type === 'cloud' && !sessionId) { + setShowConsent(true) + setIsLoading(false) + return + } + + // Create Daily.co iframe + const frame = DailyIframe.createFrame(containerRef.current, { + showLeaveButton: true, + showFullscreenButton: true, + iframeStyle: { + position: 'absolute', + width: '100%', + height: '100%', + border: 'none', + }, + }) + + callFrameRef.current = frame + + // Join meeting + frame.join({ url: meeting.room_url }) + .then(() => { + setIsLoading(false) + }) + .catch((error) => { + console.error('Failed to join Daily.co meeting:', error) + toast({ + title: 'Failed to join meeting', + description: error.message, + status: 'error', + duration: 5000, + }) + setIsLoading(false) + }) + + // Handle leave event + frame.on('left-meeting', () => { + router.push('/browse') + }) + + // Cleanup + return () => { + if (callFrameRef.current) { + callFrameRef.current.destroy() + callFrameRef.current = null + } + } + }, [meeting, sessionId, router, toast]) + + const handleConsent = async () => { + try { + await api.v1MeetingAudioConsent({ + path: { meeting_id: meeting.id }, + body: { consent: true }, + }) + setShowConsent(false) + // Trigger re-render to join meeting + window.location.reload() + } catch (error) { + toast({ + title: 'Failed to record consent', + description: 'Please try again', + status: 'error', + duration: 3000, + }) } - return } - ``` -- [x] Implement `DailyRoom` component with: - - Call initialization using DailyIframe - - Recording consent flow - - Leave meeting handling -- [x] Extract `WherebyRoom` component maintaining existing functionality -- [x] Simplified focus management (Daily.co handles this internally) - -### 3.3 Consent Dialog Integration -**Owner**: Frontend Developer - -- [x] Adapt consent dialog for Daily.co (uses same API endpoints) -- [x] Ensure recording status is properly tracked -- [x] Maintain consistent consent UI across both platforms -- [ ] Test consent flow with Daily.co recordings (Phase 4) - -## Phase 4: Testing & Validation - -### 4.1 Unit Testing ✅ -**Owner**: Backend Developer - -- [x] Create comprehensive unit tests for all platform clients -- [x] Test mock platform client with full coverage -- [x] Test platform factory and registry functionality -- [x] Test webhook signature verification for all platforms -- [x] Test meeting lifecycle operations (create, delete, sessions) - -### 4.2 Integration Testing ✅ -**Owner**: Backend Developer - -- [x] Create webhook integration tests with mocked HTTP client -- [x] Test Daily.co webhook event processing -- [x] Test participant join/leave event handling -- [x] Test recording start/ready event processing -- [x] Test webhook signature validation with HMAC -- [x] Test error handling for malformed events - -### 4.3 Test Utilities ✅ -**Owner**: Backend Developer - -- [x] Create video platform test helper utilities -- [x] Create webhook event generators for testing -- [x] Create platform-agnostic test scenarios -- [x] Implement mock data factories for consistent testing - -### 4.4 Ready for Live Testing -**Owner**: QA + Development Team - -- [ ] Test complete flow with actual Daily.co credentials: - - Room creation - - Join meeting - - Recording consent - - Recording to S3 - - Webhook processing - - Transcript generation -- [ ] Verify S3 paths are compatible -- [ ] Check recording format (MP4) matches -- [ ] Ensure processing pipeline works unchanged - -## Phase 5: Gradual Rollout - -### 5.1 Internal Testing -**Owner**: Development Team + if (showConsent) { + return ( + + + Recording Consent Required + + + This meeting will be recorded and transcribed. Do you consent to + participate? + + + + + ) + } + + if (isLoading) { + return ( + + Loading meeting... + + ) + } + + return ( + + ) +} +``` + +**File: `www/app/[roomName]/components/WherebyRoom.tsx`** + +Extract existing room page logic into this component (no changes to functionality). + +**Update `www/app/[roomName]/page.tsx`:** + +```typescript +import RoomContainer from './components/RoomContainer' +import { api } from '@/app/api/client' + +export default async function RoomPage({ params }: { params: { roomName: string } }) { + const meeting = await api.v1GetActiveMeeting({ + path: { room_name: params.roomName } + }) + + return +} +``` + +### Step 3.6: Update Frontend Dependencies + +**Update `www/package.json`:** + +```bash +cd www +yarn add @daily-co/daily-js@^0.81.0 +``` + +### Step 3.7: Update Environment Configuration + +**Update `server/env.example`:** + +```bash +# Video Platform Configuration +# Whereby (existing provider) +WHEREBY_API_KEY=your-whereby-api-key +WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret +AWS_WHEREBY_S3_BUCKET=your-whereby-bucket +AWS_WHEREBY_ACCESS_KEY_ID=your-aws-key +AWS_WHEREBY_ACCESS_KEY_SECRET=your-aws-secret + +# 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 +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 +``` + +### Deliverables +- [ ] Daily.co client implementation +- [ ] Daily.co webhook handler +- [ ] Recording download task +- [ ] Frontend platform routing +- [ ] DailyRoom component +- [ ] WherebyRoom component extraction +- [ ] Updated environment configuration +- [ ] Frontend dependencies installed + +--- + +## Testing Strategy (3-4 hours) + +### Unit Tests + +**File: `server/tests/test_video_platforms.py`** + +```python +"""Unit tests for video platform abstraction.""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import Mock, patch, AsyncMock + +from reflector.video_platforms import ( + create_platform_client, + get_platform_config, + register_platform, + get_available_platforms, +) +from reflector.video_platforms.whereby import WherebyClient +from reflector.video_platforms.daily import DailyClient +from reflector.video_platforms.mock import MockClient + + +@pytest.fixture +def mock_room(): + """Create a mock room object.""" + room = Mock() + room.id = "room-123" + room.name = "test-room" + room.is_locked = False + room.recording_type = "cloud" + room.room_size = 10 + return room + + +def test_platform_registry(): + """Test platform registration and discovery.""" + platforms = get_available_platforms() + assert "whereby" in platforms + assert "daily" in platforms + + +def test_create_whereby_client(): + """Test Whereby client creation.""" + with patch("reflector.settings") as mock_settings: + mock_settings.WHEREBY_API_KEY = "test-key" + mock_settings.WHEREBY_WEBHOOK_SECRET = "test-secret" + + client = create_platform_client("whereby") + assert isinstance(client, WherebyClient) + assert client.PLATFORM_NAME == "whereby" + + +def test_create_daily_client(): + """Test Daily.co client creation.""" + with patch("reflector.settings") as mock_settings: + mock_settings.DAILY_API_KEY = "test-key" + mock_settings.DAILY_WEBHOOK_SECRET = "test-secret" + + client = create_platform_client("daily") + assert isinstance(client, DailyClient) + assert client.PLATFORM_NAME == "daily" + + +@pytest.mark.asyncio +async def test_whereby_signature_verification(): + """Test Whereby webhook signature verification.""" + config = VideoPlatformConfig( + api_key="test", + webhook_secret="test-secret", + ) + client = WherebyClient(config) + + # Generate valid signature + timestamp = str(int(datetime.now().timestamp())) + body = b'{"event": "test"}' + message = f"{timestamp}.{body.decode()}" + + import hmac + from hashlib import sha256 + signature = hmac.new( + b"test-secret", + message.encode(), + sha256 + ).hexdigest() + + sig_header = f"t={timestamp},v1={signature}" + + assert client.verify_webhook_signature(body, sig_header) + + +@pytest.mark.asyncio +async def test_daily_signature_verification(): + """Test Daily.co webhook signature verification.""" + config = VideoPlatformConfig( + api_key="test", + webhook_secret="test-secret", + ) + client = DailyClient(config) + + # Generate valid signature + body = b'{"event": "test"}' + + import hmac + from hashlib import sha256 + signature = hmac.new( + b"test-secret", + body, + sha256 + ).hexdigest() + + assert client.verify_webhook_signature(body, signature) + + +@pytest.mark.asyncio +async def test_mock_client_lifecycle(mock_room): + """Test mock client create/delete lifecycle.""" + config = VideoPlatformConfig(api_key="test") + client = MockClient(config) + + # Create meeting + end_date = datetime.now() + timedelta(hours=1) + meeting = await client.create_meeting("test", end_date, mock_room) + + assert meeting.platform == "whereby" # Mock pretends to be Whereby + assert "test-" in meeting.room_name + + # Get sessions + sessions = await client.get_room_sessions(meeting.room_name) + assert sessions["room_name"] == meeting.room_name + assert sessions["participants"] == 0 + + # Add participant + client.add_participant(meeting.room_name) + sessions = await client.get_room_sessions(meeting.room_name) + assert sessions["participants"] == 1 + + # Delete room + result = await client.delete_room(meeting.room_name) + assert result is True + + # Room should be gone + with pytest.raises(ValueError): + await client.get_room_sessions(meeting.room_name) +``` + +**File: `server/tests/test_daily_webhook.py`** + +```python +"""Integration tests for Daily.co webhook handler.""" + +import pytest +import hmac +from hashlib import sha256 +from datetime import datetime +from fastapi.testclient import TestClient + +from reflector.app import app +from reflector.db.meetings import Meeting + + +client = TestClient(app) + + +def create_webhook_signature(body: bytes, secret: str) -> str: + """Create Daily.co webhook signature.""" + return hmac.new(secret.encode(), body, sha256).hexdigest() + + +@pytest.mark.asyncio +async def test_participant_joined(mock_meeting): + """Test participant joined event.""" + webhook_secret = "test-secret" + + event_data = { + "type": "participant.joined", + "id": "evt_123", + "timestamp": datetime.now().isoformat(), + "payload": { + "room": mock_meeting.room_name, + "participant_id": "user_123", + } + } + + body = json.dumps(event_data).encode() + signature = create_webhook_signature(body, webhook_secret) + + with patch("reflector.settings.DAILY_WEBHOOK_SECRET", webhook_secret): + response = client.post( + "/v1/daily/webhook", + content=body, + headers={"X-Daily-Signature": signature} + ) + + assert response.status_code == 200 + + # Verify participant count increased + meeting = await Meeting.get(mock_meeting.id) + assert meeting.num_clients == 1 + + +@pytest.mark.asyncio +async def test_recording_ready(mock_meeting): + """Test recording ready event triggers processing.""" + webhook_secret = "test-secret" + + event_data = { + "type": "recording.ready-to-download", + "id": "evt_456", + "timestamp": datetime.now().isoformat(), + "payload": { + "room": mock_meeting.room_name, + "recording_id": "rec_789", + "download_url": "https://daily.co/recordings/rec_789.mp4", + } + } + + body = json.dumps(event_data).encode() + signature = create_webhook_signature(body, webhook_secret) + + with patch("reflector.settings.DAILY_WEBHOOK_SECRET", webhook_secret): + with patch("reflector.worker.process.process_recording_from_url.delay") as mock_task: + response = client.post( + "/v1/daily/webhook", + content=body, + headers={"X-Daily-Signature": signature} + ) + + assert response.status_code == 200 + mock_task.assert_called_once() + + +def test_invalid_signature(): + """Test webhook rejects invalid signature.""" + event_data = {"type": "participant.joined"} + body = json.dumps(event_data).encode() + + response = client.post( + "/v1/daily/webhook", + content=body, + headers={"X-Daily-Signature": "invalid"} + ) + + assert response.status_code == 401 +``` + +### Integration Tests + +**Test Checklist:** +- [ ] Platform factory creates correct client types +- [ ] Whereby client wrapper calls work +- [ ] Daily.co client API calls work (mocked) +- [ ] Webhook signature verification (both platforms) +- [ ] Recording download task executes +- [ ] Frontend components render correctly +- [ ] Platform routing works in RoomContainer + +### Manual Testing Procedure + +**Prerequisites:** +1. Daily.co account with API credentials +2. Webhook endpoint configured in Daily.co dashboard +3. Database migration applied + +**Test Scenario 1: Whereby Still Works** +```bash +# Set environment +export DEFAULT_VIDEO_PLATFORM=whereby +export DAILY_MIGRATION_ENABLED=false + +# Create room and meeting +# Verify Whereby embed loads +# Verify recording works +# Verify transcription pipeline runs +``` + +**Test Scenario 2: Daily.co New Installation** +```bash +# Set environment +export DEFAULT_VIDEO_PLATFORM=daily +export DAILY_MIGRATION_ENABLED=true +export DAILY_API_KEY=your-key +export DAILY_WEBHOOK_SECRET=your-secret + +# Create room and meeting +# Verify Daily.co iframe loads +# Verify participant events update count +# Start recording +# Verify webhook fires +# Verify recording downloads +# Verify transcription pipeline runs +``` + +**Test Scenario 3: Gradual Migration** +```bash +# Set environment +export DAILY_MIGRATION_ENABLED=true +export DAILY_MIGRATION_ROOM_IDS=["specific-room-id"] +export DEFAULT_VIDEO_PLATFORM=whereby + +# Create two rooms +# Verify one uses Daily, one uses Whereby +# Verify both work independently +``` + +--- + +## Rollout Plan + +### Phase 1: Development Testing (Week 1) +- [ ] Deploy to development environment +- [ ] Run full test suite +- [ ] Manual testing of both providers +- [ ] Performance benchmarking + +### Phase 2: Staging Validation (Week 2) +- [ ] Deploy to staging with `DAILY_MIGRATION_ENABLED=false` +- [ ] Verify no regressions in Whereby functionality - [ ] Enable Daily.co for internal test rooms -- [ ] Monitor logs and error rates -- [ ] Fix any issues discovered -- [ ] Verify recordings process correctly - -### 5.2 Beta Rollout -**Owner**: DevOps + Product - -- [ ] Select beta users/rooms -- [ ] Enable Daily.co via feature flag -- [ ] Monitor metrics: - - Error rates - - Recording success - - User feedback -- [ ] Create rollback plan - -### 5.3 Full Migration -**Owner**: DevOps + Product - -- [ ] Gradually increase Daily.co usage -- [ ] Monitor all metrics -- [ ] Plan Whereby sunset timeline -- [ ] Update documentation - -## Success Criteria - -### Technical Metrics -- [x] Comprehensive test coverage (>95% for platform abstraction) -- [x] Mock testing confirms API integration patterns work -- [x] Webhook processing tested with realistic event payloads -- [x] Error handling validated for all failure scenarios -- [ ] Live API error rate < 0.1% (pending credentials) -- [ ] Live webhook delivery rate > 99.9% (pending credentials) -- [ ] Recording success rate matches Whereby (pending credentials) - -### User Experience -- [x] Platform-agnostic components maintain existing UX -- [x] Recording consent flow preserved across platforms -- [x] Participant tracking architecture unchanged -- [ ] Live call quality validation (pending credentials) -- [ ] Live user acceptance testing (pending credentials) - -### Code Quality ✅ -- [x] Removed 70+ lines of focus management code in WherebyRoom extraction -- [x] Improved TypeScript coverage with platform interfaces -- [x] Better error handling with platform abstraction -- [x] Cleaner React component structure with platform routing - -## Rollback Plan - -If issues arise during migration: - -1. **Immediate**: Disable Daily.co feature flag -2. **Short-term**: Revert frontend components via git -3. **Database**: Platform field defaults to 'whereby' -4. **Full rollback**: Remove Daily.co code (isolated in separate files) - -## Post-Migration Opportunities - -Once feature parity is achieved and stable: - -1. **Raw-tracks recording** for better diarization -2. **Real-time transcription** via Daily.co API -3. **Advanced analytics** and participant insights -4. **Custom UI** improvements -5. **Performance optimizations** - -## Phase Dependencies - -- ✅ Backend Integration requires Foundation to be complete -- ✅ Frontend Migration can start after Backend API client is ready -- ✅ Testing requires both Backend and Frontend to be complete -- ⏳ Rollout begins after successful testing (pending Daily.co credentials) - -## Risk Matrix - -| Risk | Probability | Impact | Mitigation | -|------|-------------|---------|------------| -| API differences | Low | Medium | Abstraction layer | -| Recording format issues | Low | High | Extensive testing | -| User confusion | Low | Low | Gradual rollout | -| Performance degradation | Low | Medium | Monitoring | - -## Communication Plan - -1. **Week 1**: Announce migration plan to team -2. **Week 2**: Update on development progress -3. **Beta Launch**: Email to beta users -4. **Full Launch**: User notification (if UI changes) -5. **Post-Launch**: Success metrics report +- [ ] Validate recording pipeline end-to-end + +### Phase 3: Production Gradual Rollout (Weeks 3-6) +- [ ] Deploy to production with `DEFAULT_VIDEO_PLATFORM=whereby` +- [ ] Enable Daily.co for 1-2 beta customers +- [ ] Monitor error rates, recording success, transcription quality +- [ ] Gradually expand to more customers +- [ ] Collect feedback and iterate + +### Phase 4: Full Migration (Week 7+) +- [ ] Set `DEFAULT_VIDEO_PLATFORM=daily` for new installations +- [ ] Maintain Whereby support for existing customers +- [ ] Document platform selection in admin guide + +--- + +## Risk Analysis + +### Technical Risks + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Database migration fails on production | HIGH | LOW | Test migration on production copy first | +| Recording format incompatibility | HIGH | LOW | Both use MP4, validate with test recordings | +| Webhook signature fails | MEDIUM | LOW | Comprehensive tests, staging validation | +| Performance degradation from abstraction | MEDIUM | LOW | Benchmark before/after, <2% overhead target | +| Frontend component bugs | MEDIUM | MEDIUM | Extract Whereby logic first, test independently | +| Circular import issues | LOW | MEDIUM | Use TYPE_CHECKING pattern consistently | + +### Business Risks + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Customer complaints about new UI | MEDIUM | LOW | UI should be identical, consent flow same | +| Recording processing failures | HIGH | LOW | Same pipeline, tested with mock recordings | +| Whereby customers affected | HIGH | LOW | Feature flag off by default, no changes to Whereby flow | +| Cost overruns from dual providers | LOW | LOW | Provider selection controlled, not running both | + +--- + +## Success Metrics + +### Implementation Metrics +- [ ] Test coverage >90% for platform abstraction +- [ ] Zero failing tests in CI +- [ ] Database migration applies cleanly on staging +- [ ] All linting passes +- [ ] Documentation complete + +### Functional Metrics +- [ ] Whereby installations unaffected (0% regression) +- [ ] Daily.co meetings create successfully (>99%) +- [ ] Recording download success rate >98% +- [ ] Transcription quality equivalent between providers +- [ ] Webhook delivery rate >99.5% + +### Performance Metrics +- [ ] Meeting creation latency <500ms (both providers) +- [ ] Abstraction overhead <2% +- [ ] Frontend bundle size increase <50KB +- [ ] No memory leaks in long-running meetings + +--- + +## Documentation Requirements + +### Code Documentation +- [ ] Docstrings on all public methods +- [ ] Architecture decision records (ADR) for abstraction pattern +- [ ] Inline comments for complex logic + +### User Documentation +- [ ] Update README with provider configuration +- [ ] Admin guide for platform selection +- [ ] Troubleshooting guide for common issues + +### Developer Documentation +- [ ] Architecture diagram updated +- [ ] Contributing guide updated with platform addition process +- [ ] API documentation regenerated + +--- + +## Appendix A: File Checklist + +### Backend Files (New) +- [ ] `server/reflector/video_platforms/__init__.py` +- [ ] `server/reflector/video_platforms/base.py` +- [ ] `server/reflector/video_platforms/models.py` +- [ ] `server/reflector/video_platforms/registry.py` +- [ ] `server/reflector/video_platforms/factory.py` +- [ ] `server/reflector/video_platforms/whereby.py` +- [ ] `server/reflector/video_platforms/daily.py` +- [ ] `server/reflector/video_platforms/mock.py` +- [ ] `server/reflector/views/daily.py` +- [ ] `server/migrations/versions/YYYYMMDDHHMMSS_add_platform_support.py` + +### Backend Files (Modified) +- [ ] `server/reflector/settings.py` +- [ ] `server/reflector/views/rooms.py` +- [ ] `server/reflector/db/rooms.py` +- [ ] `server/reflector/db/meetings.py` +- [ ] `server/reflector/worker/process.py` +- [ ] `server/reflector/app.py` +- [ ] `server/env.example` + +### Frontend Files (New) +- [ ] `www/app/[roomName]/components/RoomContainer.tsx` +- [ ] `www/app/[roomName]/components/DailyRoom.tsx` +- [ ] `www/app/[roomName]/components/WherebyRoom.tsx` + +### Frontend Files (Modified) +- [ ] `www/app/[roomName]/page.tsx` +- [ ] `www/package.json` + +### Test Files (New) +- [ ] `server/tests/test_video_platforms.py` +- [ ] `server/tests/test_daily_webhook.py` +- [ ] `server/tests/utils/video_platform_test_utils.py` + +--- + +## Appendix B: Environment Variables Reference + +```bash +# Whereby Configuration (Existing) +WHEREBY_API_KEY= # API key from Whereby dashboard +WHEREBY_WEBHOOK_SECRET= # Webhook secret for signature verification +AWS_WHEREBY_S3_BUCKET= # S3 bucket for Whereby recordings +AWS_WHEREBY_ACCESS_KEY_ID= # AWS access key for S3 +AWS_WHEREBY_ACCESS_KEY_SECRET= # AWS secret key for S3 + +# Daily.co Configuration (New) +DAILY_API_KEY= # API key from Daily.co dashboard +DAILY_WEBHOOK_SECRET= # Webhook secret for signature verification +DAILY_SUBDOMAIN= # Your Daily.co subdomain (optional) +AWS_DAILY_S3_BUCKET= # S3 bucket for Daily.co recordings +AWS_DAILY_S3_REGION=us-west-2 # AWS region (default: us-west-2) +AWS_DAILY_ROLE_ARN= # IAM role ARN for S3 access + +# Platform Selection (New) +DAILY_MIGRATION_ENABLED=false # Master switch for Daily.co support +DAILY_MIGRATION_ROOM_IDS=[] # JSON array of specific room IDs for Daily +DEFAULT_VIDEO_PLATFORM=whereby # Default platform ("whereby" or "daily") +``` + +--- + +## Appendix C: Webhook Configuration + +### Daily.co Webhook Setup + +```bash +# Configure webhook endpoint via API +curl -X POST https://api.daily.co/v1/webhook-endpoints \ + -H "Authorization: Bearer ${DAILY_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://yourdomain.com/v1/daily/webhook", + "events": [ + "participant.joined", + "participant.left", + "recording.started", + "recording.ready-to-download", + "recording.error" + ] + }' +``` + +### Whereby Webhook Setup + +Configured via Whereby dashboard under Account Settings → Webhooks. --- -## Implementation Status: COMPLETE ✅ +## Summary -All development phases are complete and ready for live testing: +This technical specification provides a complete, step-by-step guide for implementing multi-provider video platform support in Reflector. The implementation follows clean architecture principles, maintains backward compatibility, and enables zero-downtime migration between providers. -✅ **Phase 1**: Foundation (database, config, feature flags) -✅ **Phase 2**: Backend Integration (API clients, webhooks) -✅ **Phase 3**: Frontend Migration (platform components) -✅ **Phase 4**: Testing & Validation (comprehensive test suite) +**Key Implementation Principles:** +1. Abstraction before extension (Phase 2 before Phase 3) +2. Feature flags for gradual rollout +3. Comprehensive testing at each phase +4. Documentation alongside code +5. Monitor metrics throughout rollout -**Next Steps**: Obtain Daily.co credentials and run live integration testing before gradual rollout. +**Estimated Timeline:** +- Phase 1: 2 hours (analysis) +- Phase 2: 4-5 hours (abstraction) +- Phase 3: 4-5 hours (Daily.co) +- Testing: 3-4 hours +- **Total: 13-16 hours** -This implementation prioritizes stability and risk mitigation through a phased approach. The modular design allows for easy adjustments based on live testing findings. +This document should be sufficient for a senior engineer to implement the feature independently with high confidence in the final result. diff --git a/server/reflector/app.py b/server/reflector/app.py index 2ede3bafa..96919e2b9 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -87,7 +87,7 @@ async def lifespan(app: FastAPI): app.include_router(user_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1") -app.include_router(daily_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 85b6d084a..0c4f4a6f6 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -7,6 +7,7 @@ from reflector.db import database, metadata from reflector.db.rooms import Room +from reflector.settings import Platform from reflector.utils import generate_uuid4 meetings = sa.Table( @@ -85,7 +86,7 @@ class Meeting(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" num_clients: int = 0 - platform: Literal["whereby", "daily"] = "whereby" + platform: Platform = "whereby" class MeetingController: diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index 896d6f6e9..2e276431d 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -1,6 +1,6 @@ from datetime import datetime from sqlite3 import IntegrityError -from typing import Literal +from typing import TYPE_CHECKING, Literal import sqlalchemy from fastapi import HTTPException @@ -10,6 +10,9 @@ from reflector.db import database, metadata from reflector.utils import generate_uuid4 +if TYPE_CHECKING: + from reflector.video_platforms.base import Platform + rooms = sqlalchemy.Table( "room", metadata, @@ -62,7 +65,7 @@ class Room(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" is_shared: bool = False - platform: Literal["whereby", "daily"] = "whereby" + platform: "Platform" = "whereby" class RoomController: @@ -111,7 +114,7 @@ async def add( recording_type: str, recording_trigger: str, is_shared: bool, - platform: str = "whereby", + platform: "Platform" = "whereby", ): """ Add a new room diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 4a3a35ef2..b8e581c26 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -1,5 +1,9 @@ +from typing import Literal + from pydantic_settings import BaseSettings, SettingsConfigDict +Platform = Literal["whereby", "daily"] + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -101,7 +105,7 @@ class Settings(BaseSettings): AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None SQS_POLLING_TIMEOUT_SECONDS: int = 60 - # Daily.co integration + # Daily integration DAILY_API_KEY: str | None = None DAILY_WEBHOOK_SECRET: str | None = None DAILY_SUBDOMAIN: str | None = None @@ -112,7 +116,7 @@ class Settings(BaseSettings): # Video platform migration feature flags DAILY_MIGRATION_ENABLED: bool = True DAILY_MIGRATION_ROOM_IDS: list[str] = [] - DEFAULT_VIDEO_PLATFORM: str = "daily" + DEFAULT_VIDEO_PLATFORM: Platform = "daily" # Zulip integration ZULIP_REALM: str | None = None diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py index 0c0470f38..6573722f0 100644 --- a/server/reflector/video_platforms/base.py +++ b/server/reflector/video_platforms/base.py @@ -1,21 +1,23 @@ from abc import ABC, abstractmethod from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Literal, Optional from pydantic import BaseModel from reflector.db.rooms import Room +Platform = Literal["whereby", "daily"] + +RecordingType = Literal["none", "local", "cloud"] -class MeetingData(BaseModel): - """Standardized meeting data returned by all platforms.""" +class MeetingData(BaseModel): meeting_id: str room_name: str room_url: str host_room_url: str - platform: str - extra_data: Dict[str, Any] = {} # Platform-specific data + platform: Platform + extra_data: Dict[str, Any] = {} class VideoPlatformConfig(BaseModel): @@ -35,7 +37,7 @@ class VideoPlatformConfig(BaseModel): class VideoPlatformClient(ABC): """Abstract base class for video platform integrations.""" - PLATFORM_NAME: str = "" + PLATFORM_NAME: Platform def __init__(self, config: VideoPlatformConfig): self.config = config diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py index 278cba41b..290fac9f1 100644 --- a/server/reflector/video_platforms/daily.py +++ b/server/reflector/video_platforms/daily.py @@ -1,21 +1,23 @@ 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, VideoPlatformClient, VideoPlatformConfig +from .base import MeetingData, Platform, RecordingType, VideoPlatformClient, VideoPlatformConfig class DailyClient(VideoPlatformClient): - """Daily.co video platform implementation.""" - - PLATFORM_NAME = "daily" - TIMEOUT = 10 # seconds + 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) @@ -28,14 +30,14 @@ async def create_meeting( self, room_name_prefix: str, end_date: datetime, room: Room ) -> MeetingData: """Create a Daily.co room.""" - room_name = f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}" + room_name = f"{room_name_prefix}-{datetime.now().strftime(self.TIMESTAMP_FORMAT)}" data = { "name": room_name, "privacy": "private" if room.is_locked else "public", "properties": { "enable_recording": room.recording_type - if room.recording_type != "none" + if room.recording_type != self.RECORDING_NONE else False, "enable_chat": True, "enable_screenshare": True, @@ -46,7 +48,7 @@ async def create_meeting( } # Configure S3 bucket for cloud recordings - if room.recording_type == "cloud" and self.config.s3_bucket: + if room.recording_type == self.RECORDING_CLOUD and self.config.s3_bucket: data["properties"]["recordings_bucket"] = { "bucket_name": self.config.s3_bucket, "bucket_region": self.config.s3_region, @@ -107,7 +109,7 @@ async def delete_room(self, room_name: str) -> bool: timeout=self.TIMEOUT, ) # Daily.co returns 200 for success, 404 if room doesn't exist - return response.status_code in (200, 404) + 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.""" diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py index 61e7f1e95..335c503c8 100644 --- a/server/reflector/video_platforms/factory.py +++ b/server/reflector/video_platforms/factory.py @@ -4,11 +4,11 @@ from reflector.settings import settings -from .base import VideoPlatformClient, VideoPlatformConfig +from .base import Platform, VideoPlatformClient, VideoPlatformConfig from .registry import get_platform_client -def get_platform_config(platform: str) -> VideoPlatformConfig: +def get_platform_config(platform: Platform) -> VideoPlatformConfig: """Get configuration for a specific platform.""" if platform == "whereby": return VideoPlatformConfig( @@ -32,13 +32,13 @@ def get_platform_config(platform: str) -> VideoPlatformConfig: raise ValueError(f"Unknown platform: {platform}") -def create_platform_client(platform: str) -> VideoPlatformClient: +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) -> str: +def get_platform_for_room(room_id: Optional[str] = None) -> Platform: """Determine which platform to use for a room based on feature flags.""" # If Daily migration is disabled, always use Whereby if not settings.DAILY_MIGRATION_ENABLED: diff --git a/server/reflector/video_platforms/mock.py b/server/reflector/video_platforms/mock.py index 05b84344c..ae9566330 100644 --- a/server/reflector/video_platforms/mock.py +++ b/server/reflector/video_platforms/mock.py @@ -1,18 +1,14 @@ -"""Mock video platform client for testing.""" - import uuid from datetime import datetime from typing import Any, Dict, Optional from reflector.db.rooms import Room -from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig +from .base import MeetingData, Platform, VideoPlatformClient, VideoPlatformConfig class MockPlatformClient(VideoPlatformClient): - """Mock video platform implementation for testing.""" - - PLATFORM_NAME = "mock" + PLATFORM_NAME: Platform = "whereby" def __init__(self, config: VideoPlatformConfig): super().__init__(config) @@ -41,12 +37,12 @@ async def create_meeting( "is_active": True, } - return MeetingData( + return MeetingData.model_construct( meeting_id=meeting_id, room_name=room_name, room_url=room_url, host_room_url=host_room_url, - platform=self.PLATFORM_NAME, + platform="mock", extra_data={"mock": True}, ) diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py index dcfd751d1..60987f9dc 100644 --- a/server/reflector/video_platforms/registry.py +++ b/server/reflector/video_platforms/registry.py @@ -1,18 +1,18 @@ from typing import Dict, Type -from .base import VideoPlatformClient, VideoPlatformConfig +from .base import Platform, VideoPlatformClient, VideoPlatformConfig # Registry of available video platforms -_PLATFORMS: Dict[str, Type[VideoPlatformClient]] = {} +_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} -def register_platform(name: str, client_class: Type[VideoPlatformClient]): +def register_platform(name: Platform, client_class: Type[VideoPlatformClient]): """Register a video platform implementation.""" _PLATFORMS[name.lower()] = client_class def get_platform_client( - platform: str, config: VideoPlatformConfig + platform: Platform, config: VideoPlatformConfig ) -> VideoPlatformClient: """Get a video platform client instance.""" platform_lower = platform.lower() @@ -23,7 +23,7 @@ def get_platform_client( return client_class(config) -def get_available_platforms() -> list[str]: +def get_available_platforms() -> list[Platform]: """Get list of available platform names.""" return list(_PLATFORMS.keys()) diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py index 26312df47..9202a065b 100644 --- a/server/reflector/video_platforms/whereby.py +++ b/server/reflector/video_platforms/whereby.py @@ -10,13 +10,13 @@ from reflector.db.rooms import Room -from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig +from .base import MeetingData, Platform, VideoPlatformClient, VideoPlatformConfig class WherebyClient(VideoPlatformClient): """Whereby video platform implementation.""" - PLATFORM_NAME = "whereby" + PLATFORM_NAME: Platform = "whereby" TIMEOUT = 10 # seconds MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py index 91dfe1a62..3e2a3a192 100644 --- a/server/reflector/views/daily.py +++ b/server/reflector/views/daily.py @@ -8,13 +8,14 @@ from pydantic import BaseModel from reflector.db.meetings import meetings_controller +from reflector.logger import logger from reflector.settings import settings router = APIRouter() class DailyWebhookEvent(BaseModel): - """Daily.co webhook event structure.""" + """Daily webhook event structure.""" type: str id: str @@ -22,8 +23,8 @@ class DailyWebhookEvent(BaseModel): data: Dict[str, Any] -def verify_daily_webhook_signature(body: bytes, signature: str) -> bool: - """Verify Daily.co webhook signature using HMAC-SHA256.""" +def verify_webhook_signature(body: bytes, signature: str) -> bool: + """Verify Daily webhook signature using HMAC-SHA256.""" if not signature or not settings.DAILY_WEBHOOK_SECRET: return False @@ -36,14 +37,14 @@ def verify_daily_webhook_signature(body: bytes, signature: str) -> bool: return False -@router.post("/daily_webhook") -async def daily_webhook(event: DailyWebhookEvent, request: Request): - """Handle Daily.co webhook events.""" +@router.post("/webhook") +async def webhook(event: DailyWebhookEvent, request: Request): + """Handle Daily webhook events.""" # Verify webhook signature for security body = await request.body() signature = request.headers.get("X-Daily-Signature", "") - if not verify_daily_webhook_signature(body, signature): + if not verify_webhook_signature(body, signature): raise HTTPException(status_code=401, detail="Invalid webhook signature") # Handle participant events @@ -100,7 +101,12 @@ async def _handle_recording_started(event: DailyWebhookEvent): meeting = await meetings_controller.get_by_room_name(room_name) if meeting: # Log recording start for debugging - print(f"Recording started for meeting {meeting.id} in room {room_name}") + logger.info( + "Recording started", + meeting_id=meeting.id, + room_name=room_name, + platform="daily", + ) async def _handle_recording_ready(event: DailyWebhookEvent): @@ -129,8 +135,11 @@ async def _handle_recording_ready(event: DailyWebhookEvent): ) except ImportError: # Handle case where worker tasks aren't available - print( - f"Warning: Could not queue recording processing for meeting {meeting.id}" + logger.warning( + "Could not queue recording processing", + meeting_id=meeting.id, + room_name=room_name, + platform="daily", ) @@ -142,4 +151,10 @@ async def _handle_recording_error(event: DailyWebhookEvent): if room_name: meeting = await meetings_controller.get_by_room_name(room_name) if meeting: - print(f"Recording error for meeting {meeting.id}: {error}") + 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 2f565fae6..6a4e8b4da 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -14,6 +14,7 @@ from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms_controller from reflector.settings import settings +from reflector.video_platforms.base import Platform from reflector.video_platforms.factory import ( create_platform_client, get_platform_for_room, @@ -37,7 +38,7 @@ class Room(BaseModel): recording_type: str recording_trigger: str is_shared: bool - platform: str + platform: Platform class Meeting(BaseModel): @@ -48,7 +49,7 @@ class Meeting(BaseModel): start_date: datetime end_date: datetime recording_type: Literal["none", "local", "cloud"] = "cloud" - platform: str + platform: Platform class CreateRoom(BaseModel): diff --git a/server/tests/test_daily_webhook.py b/server/tests/test_daily_webhook.py index dee885aa4..e1feaa714 100644 --- a/server/tests/test_daily_webhook.py +++ b/server/tests/test_daily_webhook.py @@ -84,10 +84,10 @@ async def test_webhook_participant_joined( with patch( "reflector.db.meetings.meetings_controller.update_meeting" - ) as mock_update: + ) as _mock_update: async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -125,7 +125,7 @@ async def test_webhook_participant_left( async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -160,10 +160,10 @@ async def test_webhook_recording_started( with patch( "reflector.db.meetings.meetings_controller.update_meeting" - ) as mock_update: + ) as _mock_update: async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -200,7 +200,7 @@ async def test_webhook_recording_ready_triggers_processing( with patch( "reflector.db.meetings.meetings_controller.update_meeting" - ) as mock_update_url: + ) as _mock_update_url: with patch( "reflector.worker.process.process_recording_from_url.delay" ) as mock_process: @@ -208,7 +208,7 @@ async def test_webhook_recording_ready_triggers_processing( app=app, base_url="http://test/v1" ) as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -233,7 +233,7 @@ async def test_webhook_invalid_signature_rejected(self, webhook_secret): async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": "invalid-signature"}, ) @@ -247,7 +247,7 @@ async def test_webhook_missing_signature_rejected(self): event_data = self.create_webhook_event("participant.joined") async with AsyncClient(app=app, base_url="http://test/v1") as ac: - response = await ac.post("/daily_webhook", json=event_data) + response = await ac.post("/daily/webhook", json=event_data) assert response.status_code == 401 assert "Missing signature" in response.json()["detail"] @@ -272,7 +272,7 @@ async def test_webhook_meeting_not_found(self, webhook_secret): async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -298,7 +298,7 @@ async def test_webhook_unknown_event_type(self, webhook_secret, mock_meeting): async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", json=event_data, headers={"X-Daily-Signature": signature}, ) @@ -315,7 +315,7 @@ async def test_webhook_malformed_json(self, webhook_secret): async with AsyncClient(app=app, base_url="http://test/v1") as ac: response = await ac.post( - "/daily_webhook", + "/daily/webhook", content="invalid json", headers={ "Content-Type": "application/json", diff --git a/server/tests/test_video_platforms.py b/server/tests/test_video_platforms.py index 1d70ea8b0..a40996faf 100644 --- a/server/tests/test_video_platforms.py +++ b/server/tests/test_video_platforms.py @@ -43,7 +43,7 @@ def test_create_whereby_client(self, config): assert isinstance(client, WherebyClient) def test_create_daily_client(self, config): - """Test creating Daily.co client.""" + """Test creating Daily client.""" client = get_platform_client("daily", config) assert isinstance(client, DailyClient) @@ -123,7 +123,7 @@ def test_verify_webhook_signature_invalid(self, mock_client): class TestDailyClient: - """Test Daily.co platform client.""" + """Test Daily platform client.""" @pytest.fixture def daily_client(self, config): diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index a6644322b..008402c71 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -8,15 +8,11 @@ import useSessionStatus from "../../lib/useSessionStatus"; import { useRecordingConsent } from "../../recordingConsentContext"; import useApi from "../../lib/useApi"; import { FaBars } from "react-icons/fa6"; -import DailyIframe from "@daily-co/daily-js"; - -interface Meeting { - id: string; - room_url: string; - host_room_url?: string; - recording_type: string; - platform?: string; -} +import DailyIframe, { DailyCall } from "@daily-co/daily-js"; +import type { Meeting, recording_type } from "../../api/types.gen"; + +const CONSENT_BUTTON_TOP_OFFSET = "56px"; +const TOAST_CHECK_INTERVAL_MS = 100; interface DailyRoomProps { meeting: Meeting; @@ -110,7 +106,7 @@ function ConsentDialogButton({ meetingId }: { meetingId: string }) { setModalOpen(false); clearInterval(checkToastStatus); } - }, 100); + }, TOAST_CHECK_INTERVAL_MS); }); // Handle escape key to close the toast @@ -137,7 +133,7 @@ function ConsentDialogButton({ meetingId }: { meetingId: string }) { return (