76 KiB
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:
- Vendor lock-in risk - Single point of failure for core functionality
- Cost optimization limitations - Cannot leverage competitive pricing
- Feature constraints - Limited to Whereby's feature set
- Scalability concerns - Dependent on Whereby's infrastructure reliability
Business Goals
- Risk Mitigation: Enable platform switching without code changes
- Cost Flexibility: Allow deployment-specific provider selection
- Feature Expansion: Prepare for future multi-track diarization (Daily.co raw-tracks)
- 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 │
│ └─ <whereby-embed> 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
- Single Responsibility: Each provider implements only platform-specific logic
- Open/Closed: New providers can be added without modifying existing code
- Liskov Substitution: All providers are interchangeable via base interface
- Dependency Inversion: Business logic depends on abstraction, not implementations
- 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:
# 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:
# 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
- <whereby-embed> 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:
# 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/platform_types.py (separate file to avoid circular imports):
"""Platform type definitions.
Separate file to prevent circular import issues when db models and
video platform code need to reference the Platform type.
"""
from typing import Literal
Platform = Literal["whereby", "daily"]
Create server/reflector/video_platforms/models.py:
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
from reflector.platform_types import Platform
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
"""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
"""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
"""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
"""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
"""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
"""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:
# 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:
# 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/<alembic-id>_add_platform_support.py
Note: Alembic generates revision IDs automatically (e.g., 1e49625677e4_add_platform_support.py)
cd server
uv run alembic revision -m "add_platform_support"
Migration content:
"""add_platform_support
Adds platform field to room and meeting tables to support multi-provider architecture.
Revision ID: <auto-generated>
Revises: <latest-revision>
Create Date: <auto-generated>
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '<auto-generated>'
down_revision = '<latest-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
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
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:
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
"""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
# NOTE: Daily.co always uses "raw-tracks" for better transcription quality
# (multiple WebM files instead of single MP4)
if room.recording_type != "none":
data["properties"]["enable_recording"] = "raw-tracks"
else:
data["properties"]["enable_recording"] = False
# Configure S3 bucket for recordings
# NOTE: Not checking room.recording_type - figure out later if conditional needed
assert self.config.s3_bucket, "S3 bucket must be configured"
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,
}
# 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)
async def create_meeting_token(self, room_name: str, enable_recording: bool) -> str:
"""Create JWT meeting token with optional auto-recording.
Daily.co supports token-based meeting configuration, which allows
per-participant settings like auto-starting cloud recording.
This is used instead of room-level recording config for more control.
"""
data = {"properties": {"room_name": room_name}}
if enable_recording:
data["properties"]["start_cloud_recording"] = True
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/meeting-tokens",
headers=self.headers,
json=data,
timeout=self.TIMEOUT,
)
response.raise_for_status()
return response.json()["token"]
Token-Based Auto-Recording (Critical Addition)
After creating a Daily.co meeting, append JWT token to URLs for auto-recording:
# In rooms.py after create_meeting
if meeting.platform == "daily" and room.recording_trigger != "none":
client = create_platform_client(meeting.platform)
token = await client.create_meeting_token(
meeting.room_name, enable_recording=True
)
meeting.room_url += f"?t={token}"
meeting.host_room_url += f"?t={token}"
Why tokens instead of room config:
- Room-level
enable_recordingonly enables the capability - Token with
start_cloud_recording: trueactually starts it - Provides per-participant control (future: host-only recording)
Step 3.2: Register Daily.co Client
Update server/reflector/video_platforms/__init__.py:
# 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
"""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:
from reflector.views import daily
app.include_router(daily.router, prefix="/v1/daily", tags=["daily"])
Step 3.4: Create Recording Processing Task
⚠️ NEXT STEP - NOT YET IMPLEMENTED
The process_recording_from_url task described below is the next implementation step.
It handles downloading Daily.co recordings from webhook URLs into the existing transcription pipeline.
Update server/reflector/worker/process.py:
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
'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 <DailyRoom meeting={meeting} />
}
// Default to Whereby
return <WherebyRoom meeting={meeting} />
}
File: www/app/[roomName]/components/DailyRoom.tsx
'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<HTMLDivElement>(null)
const callFrameRef = useRef<DailyCall | null>(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,
})
}
}
if (showConsent) {
return (
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
textAlign="center"
maxW="500px"
p={8}
bg="white"
borderRadius="md"
boxShadow="lg"
>
<Text fontSize="xl" fontWeight="bold" mb={4}>
Recording Consent Required
</Text>
<Text mb={6}>
This meeting will be recorded and transcribed. Do you consent to
participate?
</Text>
<Button colorScheme="blue" onClick={handleConsent} mr={4}>
I Consent
</Button>
<Button onClick={() => router.push('/browse')}>
Decline
</Button>
</Box>
)
}
if (isLoading) {
return (
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
>
<Text>Loading meeting...</Text>
</Box>
)
}
return (
<Box
ref={containerRef}
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
/>
)
}
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:
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 <RoomContainer meeting={meeting} />
}
Step 3.6: Update Frontend Dependencies
Update www/package.json:
cd www
yarn add @daily-co/daily-js@^0.81.0
Step 3.7: Update Environment Configuration
Update server/env.example:
# 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
"""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
"""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:
- Daily.co account with API credentials
- Webhook endpoint configured in Daily.co dashboard
- Database migration applied
Test Scenario 1: Whereby Still Works
# 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
# 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
# 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
- 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=dailyfor 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/platform_types.py(Platform literal type - separate to avoid circular imports)server/reflector/video_platforms/__init__.pyserver/reflector/video_platforms/base.pyserver/reflector/video_platforms/models.pyserver/reflector/video_platforms/registry.pyserver/reflector/video_platforms/factory.pyserver/reflector/video_platforms/whereby.pyserver/reflector/video_platforms/daily.pyserver/reflector/video_platforms/mock.pyserver/reflector/views/daily.pyserver/migrations/versions/<alembic-id>_add_platform_support.py(e.g.,1e49625677e4_...)
Backend Files (Modified)
server/reflector/settings.pyserver/reflector/views/rooms.pyserver/reflector/db/rooms.pyserver/reflector/db/meetings.pyserver/reflector/worker/process.pyserver/reflector/app.pyserver/env.example
Frontend Files (New)
www/app/[roomName]/components/RoomContainer.tsxwww/app/[roomName]/components/DailyRoom.tsxwww/app/[roomName]/components/WherebyRoom.tsx
Frontend Files (Modified)
www/app/[roomName]/page.tsxwww/package.json
Test Files (New)
server/tests/test_video_platforms.pyserver/tests/test_daily_webhook.pyserver/tests/utils/video_platform_test_utils.py
Appendix B: Environment Variables Reference
# 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
# 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.
Summary
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.
Key Implementation Principles:
- Abstraction before extension (Phase 2 before Phase 3)
- Feature flags for gradual rollout
- Comprehensive testing at each phase
- Documentation alongside code
- Monitor metrics throughout 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 document should be sufficient for a senior engineer to implement the feature independently with high confidence in the final result.