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 (