diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md
new file mode 100644
index 00000000..59e8c8e0
--- /dev/null
+++ b/IMPLEMENTATION_STATUS.md
@@ -0,0 +1,127 @@
+# Daily.co Migration Implementation Status
+
+## Completed Components
+
+### 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)
+- **mock.py**: Mock implementation for unit testing
+- **registry.py**: Platform registration and discovery
+- **factory.py**: Factory methods for creating platform clients
+
+### 2. Database Updates
+- **Models**: Added `platform` field to Room and Meeting tables
+- **Migration**: Created migration `20250801180012_add_platform_support.py`
+- **Controllers**: Updated to handle platform field
+
+### 3. Configuration
+- **Settings**: Added Daily.co configuration variables
+- **Feature Flags**:
+ - `DAILY_MIGRATION_ENABLED`: Master switch for migration
+ - `DAILY_MIGRATION_ROOM_IDS`: List of specific rooms to migrate
+ - `DEFAULT_VIDEO_PLATFORM`: Default platform when migration enabled
+
+### 4. Backend API Updates
+- **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`
+
+### 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
+- **Dependencies**: Added `@daily-co/daily-js` and `@daily-co/daily-react`
+
+## How It Works
+
+1. **Platform Selection**:
+ - If `DAILY_MIGRATION_ENABLED=false` → Always use Whereby
+ - If enabled and room ID in `DAILY_MIGRATION_ROOM_IDS` → Use Daily
+ - Otherwise → Use `DEFAULT_VIDEO_PLATFORM`
+
+2. **Meeting Creation Flow**:
+ ```python
+ platform = get_platform_for_room(room.id)
+ client = create_platform_client(platform)
+ meeting_data = await client.create_meeting(...)
+ ```
+
+3. **Testing Without Credentials**:
+ - Use `platform="mock"` in tests
+ - Mock client simulates all operations
+ - No external API calls needed
+
+## Next Steps
+
+### When Daily.co Credentials Available:
+
+1. **Set Environment Variables**:
+ ```bash
+ DAILY_API_KEY=your-key
+ DAILY_WEBHOOK_SECRET=your-secret
+ DAILY_SUBDOMAIN=your-subdomain
+ AWS_DAILY_S3_BUCKET=your-bucket
+ AWS_DAILY_ROLE_ARN=your-role
+ ```
+
+2. **Run Database Migration**:
+ ```bash
+ cd server
+ uv run alembic upgrade head
+ ```
+
+3. **Test Platform Creation**:
+ ```python
+ from reflector.video_platforms.factory import create_platform_client
+ client = create_platform_client("daily")
+ # Test operations...
+ ```
+
+### Remaining Implementation Tasks:
+
+1. **Webhook Handlers**:
+ - Create `/v1/daily_webhook` endpoint
+ - Map Daily.co events to database updates
+ - Handle recording notifications
+
+2. **Frontend Updates**:
+ - Create DailyRoom component
+ - Implement platform detection
+ - Update consent flow
+
+3. **Testing**:
+ - Unit tests with mock platform
+ - Integration tests with real APIs
+ - Migration rollback tests
+
+## Testing the Current Implementation
+
+Without Daily.co credentials, you can test using the mock:
+
+```python
+from reflector.video_platforms.factory import create_platform_client
+from reflector.video_platforms.base import VideoPlatformConfig
+
+# Create mock client
+config = VideoPlatformConfig(
+ api_key="test",
+ webhook_secret="test"
+)
+client = create_platform_client("mock")
+
+# Test operations
+meeting = await client.create_meeting(
+ room_name_prefix="test",
+ end_date=datetime.utcnow() + timedelta(hours=1),
+ room=room
+)
+```
+
+## Architecture Benefits
+
+1. **Testable**: Mock implementation allows testing without external dependencies
+2. **Extensible**: Easy to add new platforms (Zoom, Teams, etc.)
+3. **Gradual Migration**: Feature flags enable room-by-room migration
+4. **Rollback Ready**: Can disable Daily.co instantly via feature flag
\ No newline at end of file
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 00000000..6fdd91ba
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,261 @@
+# 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
+ }
+ 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 Integration Testing
+**Owner**: QA + Development Team
+
+- [ ] Test complete flow for both platforms:
+ - 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
+
+### 4.2 Performance Testing
+**Owner**: Backend Developer
+
+- [ ] Compare API response times
+- [ ] Measure webhook latency
+- [ ] Test with multiple concurrent rooms
+- [ ] Verify participant count accuracy
+
+### 4.3 User Acceptance Testing
+**Owner**: Product Team
+
+- [ ] Create test rooms with Daily.co
+- [ ] Have team members test call quality
+- [ ] Verify UI/UX matches expectations
+- [ ] Document any visual differences
+
+## Phase 5: Gradual Rollout
+
+### 5.1 Internal Testing
+**Owner**: Development Team
+
+- [ ] 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
+- [ ] API error rate < 0.1%
+- [ ] Webhook delivery rate > 99.9%
+- [ ] Recording success rate matches Whereby
+- [ ] No increase in processing failures
+
+### User Experience
+- [ ] No user-reported regressions
+- [ ] Call quality ratings maintained
+- [ ] Recording consent flow works smoothly
+- [ ] Participant tracking is accurate
+
+### Code Quality
+- [ ] Removed 70+ lines of focus management code
+- [ ] Improved TypeScript coverage
+- [ ] Better error handling
+- [ ] Cleaner React component structure
+
+## 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
+
+## 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
+
+---
+
+This plan prioritizes stability and risk mitigation through a phased approach. The modular implementation allows for adjustments based on findings during development.
diff --git a/REFACTOR_WHEREBY_FINDING.md b/REFACTOR_WHEREBY_FINDING.md
new file mode 100644
index 00000000..7fff04c8
--- /dev/null
+++ b/REFACTOR_WHEREBY_FINDING.md
@@ -0,0 +1,587 @@
+# Whereby to Daily.co Migration Feasibility Analysis
+
+## Executive Summary
+
+After analysis of the current Whereby integration and Daily.co's capabilities, migrating to Daily.co is technically feasible. The migration can be done in phases:
+
+1. **Phase 1**: Feature parity with current implementation (standard cloud recording)
+2. **Phase 2**: Enhanced capabilities with raw-tracks recording for improved diarization
+
+### Current Implementation Analysis
+
+Based on code review:
+- **Webhook handling**: The current webhook handler (`server/reflector/views/whereby.py`) only tracks `num_clients`, not individual participants
+- **Focus management**: The frontend has 70+ lines managing focus between Whereby embed and consent dialog
+- **Participant tracking**: No participant names or IDs are captured in the current implementation
+- **Recording type**: Cloud recording to S3 in MP4 format with mixed audio
+
+### Migration Approach
+
+**Phase 1**: 1:1 feature replacement maintaining current functionality:
+- Standard cloud recording (same as current Whereby implementation)
+- Same recording workflow: Video platform → S3 → Reflector processing
+- No changes to existing diarization or transcription pipeline
+
+**Phase 2**: Enhanced capabilities (future implementation):
+- Raw-tracks recording for speaker-separated audio
+- Improved diarization with participant-to-audio mapping
+- Per-participant transcription accuracy
+
+## Current Whereby Integration Analysis
+
+### Backend Integration
+
+#### Core API Module (`server/reflector/whereby.py`)
+- **Meeting Creation**: Creates rooms with S3 recording configuration
+- **Session Monitoring**: Tracks meeting status via room sessions API
+- **Logo Upload**: Handles branding for meetings
+- **Key Functions**:
+ ```python
+ create_meeting(room_name, logo_s3_url) -> dict
+ monitor_room_session(meeting_link) -> dict
+ upload_logo(file_stream, content_type) -> str
+ ```
+
+#### Webhook Handler (`server/reflector/views/whereby.py`)
+- **Endpoint**: `/v1/whereby_webhook`
+- **Security**: HMAC signature validation
+- **Events Handled**:
+ - `room.participant.joined`
+ - `room.participant.left`
+- **Pain Point**: Delay between actual join/leave and webhook delivery
+
+#### Room Management (`server/reflector/views/rooms.py`)
+- Creates meetings via Whereby API
+- Stores meeting data in database
+- Manages recording lifecycle
+
+### Frontend Integration
+
+#### Main Room Component (`www/app/[roomName]/page.tsx`)
+- Uses `@whereby.com/browser-sdk` (v3.3.4)
+- Implements custom `` element
+- Handles recording consent
+- Focus management for accessibility
+
+#### Configuration
+- Environment Variables:
+ - `WHEREBY_API_URL`, `WHEREBY_API_KEY`, `WHEREBY_WEBHOOK_SECRET`
+ - AWS S3 credentials for recordings
+- Recording workflow: Whereby → S3 → Reflector processing pipeline
+
+## Daily.co Capabilities Analysis
+
+### REST API Features
+
+#### Room Management
+```
+POST /rooms - Create room with configuration
+GET /rooms/:name/presence - Real-time participant data
+POST /rooms/:name/recordings/start - Start recording
+```
+
+#### Recording Options
+```json
+{
+ "enable_recording": "raw-tracks" // Key feature for diarization
+}
+```
+
+#### Webhook Events
+- `participant.joined` / `participant.left`
+- `waiting-participant.joined` / `waiting-participant.left`
+- `recording.started` / `recording.ready-to-download`
+- `recording.error`
+
+### React SDK (@daily-co/daily-react)
+
+#### Modern Hook-based Architecture
+```jsx
+// Participant tracking
+const participantIds = useParticipantIds({ filter: 'remote' });
+const [username, videoState] = useParticipantProperty(id, ['user_name', 'tracks.video.state']);
+
+// Recording management
+const { isRecording, startRecording, stopRecording } = useRecording();
+
+// Real-time participant data
+const participants = useParticipants();
+```
+
+## Feature Comparison
+
+| Feature | Whereby | Daily.co |
+|---------|---------|----------|
+| **Room Creation** | REST API | REST API |
+| **Recording Types** | Cloud (MP4) | Cloud (MP4), Local, Raw-tracks |
+| **S3 Integration** | Direct upload | Direct upload with IAM roles |
+| **Frontend Integration** | Custom element | React hooks or iframe |
+| **Webhooks** | HMAC verified | HMAC verified |
+| **Participant Data** | Via webhooks | Via webhooks + Presence API |
+| **Recording Trigger** | Automatic/manual | Automatic/manual |
+
+## Migration Plan
+
+### Phase 1: Backend API Client
+
+#### 1.1 Create Daily.co API Client (`server/reflector/daily.py`)
+
+```python
+from datetime import datetime
+import httpx
+from reflector.db.rooms import Room
+from reflector.settings import settings
+
+class DailyClient:
+ def __init__(self):
+ self.base_url = "https://api.daily.co/v1"
+ self.headers = {
+ "Authorization": f"Bearer {settings.DAILY_API_KEY}",
+ "Content-Type": "application/json"
+ }
+ self.timeout = 10
+
+ async def create_meeting(self, room_name_prefix: str, end_date: datetime, room: Room) -> dict:
+ """Create a Daily.co room matching current Whereby functionality."""
+ data = {
+ "name": f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
+ "privacy": "private" if room.is_locked else "public",
+ "properties": {
+ "enable_recording": "cloud", # Same as Whereby
+ "enable_chat": True,
+ "enable_screenshare": True,
+ "start_video_off": False,
+ "start_audio_off": False,
+ "exp": int(end_date.timestamp()),
+ "enable_recording_ui": False, # We handle consent ourselves
+ }
+ }
+
+ # Configure S3 bucket for recordings (same as Whereby)
+ if room.recording_type == "cloud":
+ data["properties"]["recording_bucket"] = {
+ "bucket_name": settings.AWS_S3_BUCKET,
+ "bucket_region": settings.AWS_REGION,
+ "assume_role_arn": settings.AWS_DAILY_ROLE_ARN,
+ "path": f"recordings/{data['name']}"
+ }
+
+ 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()
+ room_data = response.json()
+
+ # Return in Whereby-compatible format
+ return {
+ "roomUrl": room_data["url"],
+ "hostRoomUrl": room_data["url"] + "?t=" + room_data["config"]["token"],
+ "roomName": room_data["name"],
+ "meetingId": room_data["id"]
+ }
+
+ async def get_room_sessions(self, room_name: str) -> dict:
+ """Get room session data (similar to Whereby's insights)."""
+ 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()
+```
+
+#### 1.2 Update Webhook Handler (`server/reflector/views/daily.py`)
+
+```python
+import hmac
+import json
+from datetime import datetime
+from hashlib import sha256
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+from reflector.db.meetings import meetings_controller
+from reflector.settings import settings
+
+router = APIRouter()
+
+class DailyWebhookEvent(BaseModel):
+ type: str
+ id: str
+ ts: int
+ data: dict
+
+def verify_daily_webhook(body: bytes, signature: str) -> bool:
+ """Verify Daily.co webhook signature."""
+ expected = hmac.new(
+ settings.DAILY_WEBHOOK_SECRET.encode(),
+ body,
+ sha256
+ ).hexdigest()
+ return hmac.compare_digest(expected, signature)
+
+@router.post("/daily")
+async def daily_webhook(event: DailyWebhookEvent, request: Request):
+ # Verify webhook signature
+ body = await request.body()
+ signature = request.headers.get("X-Daily-Signature", "")
+
+ if not verify_daily_webhook(body, signature):
+ raise HTTPException(status_code=401, detail="Invalid webhook signature")
+
+ # Handle participant events
+ if event.type == "participant.joined":
+ meeting = await meetings_controller.get_by_room_name(event.data["room_name"])
+ if meeting:
+ # Update participant info immediately
+ await meetings_controller.add_participant(
+ meeting.id,
+ participant_id=event.data["participant"]["user_id"],
+ name=event.data["participant"]["user_name"],
+ joined_at=datetime.fromtimestamp(event.ts / 1000)
+ )
+
+ elif event.type == "participant.left":
+ meeting = await meetings_controller.get_by_room_name(event.data["room_name"])
+ if meeting:
+ await meetings_controller.remove_participant(
+ meeting.id,
+ participant_id=event.data["participant"]["user_id"],
+ left_at=datetime.fromtimestamp(event.ts / 1000)
+ )
+
+ elif event.type == "recording.ready-to-download":
+ # Process cloud recording (same as Whereby)
+ meeting = await meetings_controller.get_by_room_name(event.data["room_name"])
+ if meeting:
+ # Queue standard processing task
+ from reflector.worker.tasks import process_recording
+ process_recording.delay(
+ meeting_id=meeting.id,
+ recording_url=event.data["download_link"],
+ recording_id=event.data["recording_id"]
+ )
+
+ return {"status": "ok"}
+```
+
+### Phase 2: Frontend Components
+
+#### 2.1 Replace Whereby SDK with Daily React
+
+First, update dependencies:
+```bash
+# Remove Whereby
+yarn remove @whereby.com/browser-sdk
+
+# Add Daily.co
+yarn add @daily-co/daily-react @daily-co/daily-js
+```
+
+#### 2.2 New Room Component (`www/app/[roomName]/page.tsx`)
+
+```tsx
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ DailyProvider,
+ useDaily,
+ useParticipantIds,
+ useRecording,
+ useDailyEvent,
+ useLocalParticipant,
+} from "@daily-co/daily-react";
+import { Box, Button, Text, VStack, HStack, Spinner } from "@chakra-ui/react";
+import { toaster } from "../components/ui/toaster";
+import useRoomMeeting from "./useRoomMeeting";
+import { useRouter } from "next/navigation";
+import { notFound } from "next/navigation";
+import useSessionStatus from "../lib/useSessionStatus";
+import { useRecordingConsent } from "../recordingConsentContext";
+import DailyIframe from "@daily-co/daily-js";
+
+// Daily.co Call Interface Component
+function CallInterface() {
+ const daily = useDaily();
+ const { isRecording, startRecording, stopRecording } = useRecording();
+ const localParticipant = useLocalParticipant();
+ const participantIds = useParticipantIds({ filter: "remote" });
+
+ // Real-time participant tracking
+ useDailyEvent("participant-joined", useCallback((event) => {
+ console.log(`${event.participant.user_name} joined the call`);
+ // No need for webhooks - we have immediate access!
+ }, []));
+
+ useDailyEvent("participant-left", useCallback((event) => {
+ console.log(`${event.participant.user_name} left the call`);
+ }, []));
+
+ return (
+
+ {/* Daily.co automatically handles the video/audio UI */}
+
+
+ {/* Recording status indicator */}
+ {isRecording && (
+
+ Recording
+
+ )}
+
+ {/* Participant count with real-time data */}
+
+ Participants: {participantIds.length + 1}
+
+
+ );
+}
+
+// Main Room Component with Daily.co Integration
+export default function Room({ params }: { params: { roomName: string } }) {
+ const roomName = params.roomName;
+ const meeting = useRoomMeeting(roomName);
+ const router = useRouter();
+ const { isLoading, isAuthenticated } = useSessionStatus();
+ const [dailyUrl, setDailyUrl] = useState(null);
+ const [callFrame, setCallFrame] = useState(null);
+
+ // Initialize Daily.co call
+ useEffect(() => {
+ if (!meeting?.response?.room_url) return;
+
+ const frame = DailyIframe.createCallObject({
+ showLeaveButton: true,
+ showFullscreenButton: true,
+ });
+
+ frame.on("left-meeting", () => {
+ router.push("/browse");
+ });
+
+ setCallFrame(frame);
+ setDailyUrl(meeting.response.room_url);
+
+ return () => {
+ frame.destroy();
+ };
+ }, [meeting?.response?.room_url, router]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!dailyUrl || !callFrame) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+### Phase 3: Testing & Validation
+
+For Phase 1 (feature parity), the existing processing pipeline remains unchanged:
+
+1. Daily.co records meeting to S3 (same as Whereby)
+2. Webhook notifies when recording is ready
+3. Existing pipeline downloads and processes the MP4 file
+4. Current diarization and transcription tools continue to work
+
+Key validation points:
+- Recording format matches (MP4 with mixed audio)
+- S3 paths are compatible
+- Processing pipeline requires no changes
+- Transcript quality remains the same
+
+## Future Enhancement: Raw-Tracks Recording (Phase 2)
+
+### Raw-Tracks Processing for Enhanced Diarization
+
+Daily.co's raw-tracks recording provides individual audio streams per participant, enabling:
+
+```python
+@shared_task
+def process_daily_raw_tracks(meeting_id: str, recording_id: str, tracks: list):
+ """Process Daily.co raw-tracks with perfect speaker attribution."""
+
+ for track in tracks:
+ participant_id = track["participant_id"]
+ participant_name = track["participant_name"]
+ track_url = track["download_url"]
+
+ # Download individual participant audio
+ response = download_track(track_url)
+
+ # Process with known speaker identity
+ transcript = transcribe_audio(
+ audio_data=response.content,
+ speaker_id=participant_id,
+ speaker_name=participant_name
+ )
+
+ # Store with accurate speaker mapping
+ save_transcript_segment(
+ meeting_id=meeting_id,
+ speaker_id=participant_id,
+ text=transcript.text,
+ timestamps=transcript.timestamps
+ )
+```
+
+### Benefits of Raw-Tracks (Future)
+
+1. **Deterministic Speaker Attribution**: Each audio track is already speaker-separated
+2. **Improved Transcription Accuracy**: Clean audio without cross-talk
+3. **Parallel Processing**: Process multiple speakers simultaneously
+4. **Better Metrics**: Accurate talk-time per participant
+
+### Phase 4: Database & Configuration
+
+#### 4.1 Environment Variable Updates
+
+Update `.env` files:
+
+```bash
+# Remove Whereby variables
+# WHEREBY_API_URL=https://api.whereby.dev/v1
+# WHEREBY_API_KEY=your-whereby-key
+# WHEREBY_WEBHOOK_SECRET=your-whereby-secret
+# AWS_WHEREBY_S3_BUCKET=whereby-recordings
+# AWS_WHEREBY_ACCESS_KEY_ID=whereby-key
+# AWS_WHEREBY_ACCESS_KEY_SECRET=whereby-secret
+
+# Add Daily.co variables
+DAILY_API_KEY=your-daily-api-key
+DAILY_WEBHOOK_SECRET=your-daily-webhook-secret
+AWS_DAILY_S3_BUCKET=daily-recordings
+AWS_DAILY_ROLE_ARN=arn:aws:iam::123456789:role/daily-recording-role
+AWS_REGION=us-west-2
+```
+
+#### 4.2 Database Migration
+
+```sql
+-- Alembic migration to support Daily.co
+-- server/alembic/versions/xxx_migrate_to_daily.py
+
+def upgrade():
+ # Add platform field to support gradual migration
+ op.add_column('rooms', sa.Column('platform', sa.String(), server_default='whereby'))
+ op.add_column('meetings', sa.Column('platform', sa.String(), server_default='whereby'))
+
+ # No other schema changes needed for feature parity
+
+def downgrade():
+ op.drop_column('meetings', 'platform')
+ op.drop_column('rooms', 'platform')
+```
+
+#### 4.3 Settings Update (`server/reflector/settings.py`)
+
+```python
+class Settings(BaseSettings):
+ # Remove Whereby settings
+ # WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
+ # WHEREBY_API_KEY: str
+ # WHEREBY_WEBHOOK_SECRET: str
+ # AWS_WHEREBY_S3_BUCKET: str
+ # AWS_WHEREBY_ACCESS_KEY_ID: str
+ # AWS_WHEREBY_ACCESS_KEY_SECRET: str
+
+ # Add Daily.co settings
+ DAILY_API_KEY: str
+ DAILY_WEBHOOK_SECRET: str
+ AWS_DAILY_S3_BUCKET: str
+ AWS_DAILY_ROLE_ARN: str
+ AWS_REGION: str = "us-west-2"
+
+ # Daily.co room URL pattern
+ DAILY_ROOM_URL_PATTERN: str = "https://{subdomain}.daily.co/{room_name}"
+ DAILY_SUBDOMAIN: str = "reflector" # Your Daily.co subdomain
+```
+
+## Technical Differences
+
+### Phase 1 Implementation
+1. **Frontend**: Replace `` custom element with Daily.co React components or iframe
+2. **Backend**: Create Daily.co API client matching Whereby's functionality
+3. **Webhooks**: Map Daily.co events to existing database operations
+4. **Recording**: Maintain same MP4 format and S3 storage
+
+### Phase 2 Capabilities (Future)
+1. **Raw-tracks recording**: Individual audio streams per participant
+2. **Presence API**: Real-time participant data without webhook delays
+3. **Transcription API**: Built-in transcription services
+4. **Advanced recording options**: Multiple formats and layouts
+
+## Risks and Mitigation
+
+### Risk 1: API Differences
+- **Mitigation**: Create abstraction layer to minimize changes
+- Comprehensive testing of all endpoints
+
+### Risk 2: Recording Format Changes
+- **Mitigation**: Build adapter for raw-tracks processing
+- Maintain backward compatibility during transition
+
+### Risk 3: User Experience Changes
+- **Mitigation**: A/B testing with gradual rollout
+- Feature parity checklist before full migration
+
+## Recommendation
+
+Migration to Daily.co is technically feasible and can be implemented in phases:
+
+### Phase 1: Feature Parity
+- Replace Whereby with Daily.co maintaining exact same functionality
+- Use standard cloud recording (MP4 to S3)
+- No changes to processing pipeline
+
+### Phase 2: Enhanced Capabilities (Future)
+- Enable raw-tracks recording for improved diarization
+- Implement participant-level audio processing
+- Add real-time features using Presence API
+
+## Next Steps
+
+1. Set up Daily.co account and obtain API credentials
+2. Implement feature flag system for gradual migration
+3. Create Daily.co API client matching Whereby functionality
+4. Update frontend to support both platforms
+5. Test thoroughly before rollout
+
+---
+
+*Analysis based on current codebase review and API documentation comparison.*
\ No newline at end of file
diff --git a/server/migrations/versions/20250801180012_add_platform_support.py b/server/migrations/versions/20250801180012_add_platform_support.py
new file mode 100644
index 00000000..fd263f3e
--- /dev/null
+++ b/server/migrations/versions/20250801180012_add_platform_support.py
@@ -0,0 +1,41 @@
+"""add platform support
+
+Revision ID: 20250801180012
+Revises: b0e5f7876032
+Create Date: 2025-08-01 18:00:12.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "20250801180012"
+down_revision: Union[str, None] = "b0e5f7876032"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Add platform column to rooms table
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("platform", sa.String(), server_default="whereby", nullable=False)
+ )
+
+ # Add platform column to meeting table
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("platform", sa.String(), server_default="whereby", nullable=False)
+ )
+
+
+def downgrade() -> None:
+ # Remove platform columns
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.drop_column("platform")
+
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.drop_column("platform")
diff --git a/server/reflector/app.py b/server/reflector/app.py
index e1d07d20..2ede3baf 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger
from reflector.metrics import metrics_init
from reflector.settings import settings
+from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router
@@ -86,6 +87,7 @@ app.include_router(transcripts_process_router, prefix="/v1")
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")
add_pagination(app)
# prepare celery
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index 3153757a..85b6d084 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -41,6 +41,12 @@ meetings = sa.Table(
nullable=False,
server_default=sa.true(),
),
+ sa.Column(
+ "platform",
+ sa.String,
+ nullable=False,
+ server_default="whereby",
+ ),
sa.Index("idx_meeting_room_id", "room_id"),
)
@@ -79,6 +85,7 @@ class Meeting(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
+ platform: Literal["whereby", "daily"] = "whereby"
class MeetingController:
@@ -109,6 +116,7 @@ class MeetingController:
room_mode=room.room_mode,
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
+ platform=room.platform,
)
query = meetings.insert().values(**meeting.model_dump())
await database.execute(query)
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index 209bf5bb..896d6f6e 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -40,6 +40,9 @@ rooms = sqlalchemy.Table(
sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
),
+ sqlalchemy.Column(
+ "platform", sqlalchemy.String, nullable=False, server_default="whereby"
+ ),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
)
@@ -59,6 +62,7 @@ class Room(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
+ platform: Literal["whereby", "daily"] = "whereby"
class RoomController:
@@ -107,6 +111,7 @@ class RoomController:
recording_type: str,
recording_trigger: str,
is_shared: bool,
+ platform: str = "whereby",
):
"""
Add a new room
@@ -122,6 +127,7 @@ class RoomController:
recording_type=recording_type,
recording_trigger=recording_trigger,
is_shared=is_shared,
+ platform=platform,
)
query = rooms.insert().values(**room.model_dump())
try:
diff --git a/server/reflector/settings.py b/server/reflector/settings.py
index 30af270b..eac3f342 100644
--- a/server/reflector/settings.py
+++ b/server/reflector/settings.py
@@ -101,6 +101,18 @@ class Settings(BaseSettings):
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
SQS_POLLING_TIMEOUT_SECONDS: int = 60
+ # Daily.co integration
+ DAILY_API_KEY: str | None = None
+ DAILY_WEBHOOK_SECRET: str | None = None
+ DAILY_SUBDOMAIN: str | None = None
+ AWS_DAILY_S3_BUCKET: str | None = None
+ AWS_DAILY_ROLE_ARN: str | None = None
+
+ # Video platform migration feature flags
+ DAILY_MIGRATION_ENABLED: bool = False
+ DAILY_MIGRATION_ROOM_IDS: list[str] = []
+ DEFAULT_VIDEO_PLATFORM: str = "whereby"
+
# Zulip integration
ZULIP_REALM: str | None = None
ZULIP_API_KEY: str | None = None
diff --git a/server/reflector/video_platforms/__init__.py b/server/reflector/video_platforms/__init__.py
new file mode 100644
index 00000000..ded6244c
--- /dev/null
+++ b/server/reflector/video_platforms/__init__.py
@@ -0,0 +1,17 @@
+# Video Platform Abstraction Layer
+"""
+This module provides an abstraction layer for different video conferencing platforms.
+It allows seamless switching between providers (Whereby, Daily.co, etc.) without
+changing the core application logic.
+"""
+
+from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig
+from .registry import get_platform_client, register_platform
+
+__all__ = [
+ "VideoPlatformClient",
+ "VideoPlatformConfig",
+ "MeetingData",
+ "get_platform_client",
+ "register_platform",
+]
diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py
new file mode 100644
index 00000000..0c0470f3
--- /dev/null
+++ b/server/reflector/video_platforms/base.py
@@ -0,0 +1,82 @@
+from abc import ABC, abstractmethod
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from pydantic import BaseModel
+
+from reflector.db.rooms import Room
+
+
+class MeetingData(BaseModel):
+ """Standardized meeting data returned by all platforms."""
+
+ meeting_id: str
+ room_name: str
+ room_url: str
+ host_room_url: str
+ platform: str
+ extra_data: Dict[str, Any] = {} # Platform-specific data
+
+
+class VideoPlatformConfig(BaseModel):
+ """Configuration for a video platform."""
+
+ api_key: str
+ webhook_secret: str
+ api_url: Optional[str] = None
+ subdomain: Optional[str] = None
+ s3_bucket: Optional[str] = None
+ s3_region: Optional[str] = None
+ aws_role_arn: Optional[str] = None
+ aws_access_key_id: Optional[str] = None
+ aws_access_key_secret: Optional[str] = None
+
+
+class VideoPlatformClient(ABC):
+ """Abstract base class for video platform integrations."""
+
+ PLATFORM_NAME: str = ""
+
+ def __init__(self, config: VideoPlatformConfig):
+ self.config = config
+
+ @abstractmethod
+ async def create_meeting(
+ self, room_name_prefix: str, end_date: datetime, room: Room
+ ) -> MeetingData:
+ """Create a new meeting room."""
+ pass
+
+ @abstractmethod
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ """Get session information for a room."""
+ pass
+
+ @abstractmethod
+ async def delete_room(self, room_name: str) -> bool:
+ """Delete a room. Returns True if successful."""
+ pass
+
+ @abstractmethod
+ async def upload_logo(self, room_name: str, logo_path: str) -> bool:
+ """Upload a logo to the room. Returns True if successful."""
+ pass
+
+ @abstractmethod
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ """Verify webhook signature for security."""
+ pass
+
+ def format_recording_config(self, room: Room) -> Dict[str, Any]:
+ """Format recording configuration for the platform.
+ Can be overridden by specific implementations."""
+ if room.recording_type == "cloud" and self.config.s3_bucket:
+ return {
+ "type": room.recording_type,
+ "bucket": self.config.s3_bucket,
+ "region": self.config.s3_region,
+ "trigger": room.recording_trigger,
+ }
+ return {"type": room.recording_type}
diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py
new file mode 100644
index 00000000..f8de2400
--- /dev/null
+++ b/server/reflector/video_platforms/daily.py
@@ -0,0 +1,152 @@
+import hmac
+from datetime import datetime
+from hashlib import sha256
+from typing import Any, Dict, Optional
+
+import httpx
+
+from reflector.db.rooms import Room
+
+from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig
+
+
+class DailyClient(VideoPlatformClient):
+ """Daily.co video platform implementation."""
+
+ PLATFORM_NAME = "daily"
+ TIMEOUT = 10 # seconds
+ BASE_URL = "https://api.daily.co/v1"
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ self.headers = {
+ "Authorization": f"Bearer {config.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ async def create_meeting(
+ self, room_name_prefix: str, end_date: datetime, room: Room
+ ) -> MeetingData:
+ """Create a Daily.co room."""
+ # Generate unique room name
+ room_name = f"{room_name_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
+
+ data = {
+ "name": room_name,
+ "privacy": "private" if room.is_locked else "public",
+ "properties": {
+ "enable_recording": room.recording_type
+ if room.recording_type != "none"
+ else False,
+ "enable_chat": True,
+ "enable_screenshare": True,
+ "start_video_off": False,
+ "start_audio_off": False,
+ "exp": int(end_date.timestamp()),
+ "enable_recording_ui": False, # We handle consent ourselves
+ },
+ }
+
+ # Configure S3 bucket for cloud recordings
+ if room.recording_type == "cloud" and self.config.s3_bucket:
+ data["properties"]["recording_bucket"] = {
+ "bucket_name": self.config.s3_bucket,
+ "bucket_region": self.config.s3_region,
+ "assume_role_arn": self.config.aws_role_arn,
+ "path": f"recordings/{room_name}",
+ }
+
+ 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()
+
+ # Format response to match our standard
+ room_url = result["url"]
+ host_room_url = f"{room_url}?t={result['config']['token']}"
+
+ return MeetingData(
+ meeting_id=result["id"],
+ room_name=result["name"],
+ room_url=room_url,
+ host_room_url=host_room_url,
+ platform=self.PLATFORM_NAME,
+ extra_data=result,
+ )
+
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ """Get Daily.co room information."""
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.BASE_URL}/rooms/{room_name}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
+ """Get real-time participant data - Daily.co specific feature."""
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.BASE_URL}/rooms/{room_name}/presence",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def delete_room(self, room_name: str) -> bool:
+ """Delete a Daily.co room."""
+ async with httpx.AsyncClient() as client:
+ response = await client.delete(
+ f"{self.BASE_URL}/rooms/{room_name}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ # Daily.co returns 200 for success, 404 if room doesn't exist
+ return response.status_code in (200, 404)
+
+ async def upload_logo(self, room_name: str, logo_path: str) -> bool:
+ """Daily.co doesn't support custom logos per room - this is a no-op."""
+ return True
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ """Verify Daily.co webhook signature."""
+ expected = hmac.new(
+ self.config.webhook_secret.encode(), body, sha256
+ ).hexdigest()
+
+ try:
+ return hmac.compare_digest(expected, signature)
+ except Exception:
+ return False
+
+ async def start_recording(self, room_name: str) -> Dict[str, Any]:
+ """Start recording for a room - Daily.co specific method."""
+ data = {
+ "layout": {
+ "preset": "audio-only" # For transcription use case
+ },
+ "streaming_settings": {
+ "width": 1280,
+ "height": 720,
+ },
+ }
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.BASE_URL}/rooms/{room_name}/recordings",
+ headers=self.headers,
+ json=data,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py
new file mode 100644
index 00000000..a8ab9f08
--- /dev/null
+++ b/server/reflector/video_platforms/factory.py
@@ -0,0 +1,54 @@
+"""Factory for creating video platform clients based on configuration."""
+
+from typing import Optional
+
+from reflector.settings import settings
+
+from .base import VideoPlatformClient, VideoPlatformConfig
+from .registry import get_platform_client
+
+
+def get_platform_config(platform: str) -> VideoPlatformConfig:
+ """Get configuration for a specific platform."""
+ if platform == "whereby":
+ return VideoPlatformConfig(
+ api_key=settings.WHEREBY_API_KEY or "",
+ webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "",
+ api_url=settings.WHEREBY_API_URL,
+ s3_bucket=settings.AWS_WHEREBY_S3_BUCKET,
+ aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
+ aws_access_key_secret=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
+ )
+ elif platform == "daily":
+ return VideoPlatformConfig(
+ api_key=settings.DAILY_API_KEY or "",
+ webhook_secret=settings.DAILY_WEBHOOK_SECRET or "",
+ subdomain=settings.DAILY_SUBDOMAIN,
+ s3_bucket=settings.AWS_DAILY_S3_BUCKET,
+ s3_region=settings.AWS_REGION
+ if hasattr(settings, "AWS_REGION")
+ else "us-west-2",
+ aws_role_arn=settings.AWS_DAILY_ROLE_ARN,
+ )
+ else:
+ raise ValueError(f"Unknown platform: {platform}")
+
+
+def create_platform_client(platform: str) -> 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:
+ """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:
+ return "whereby"
+
+ # If a specific room is in the migration list, use Daily
+ if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS:
+ return "daily"
+
+ # Otherwise use the default platform
+ return settings.DEFAULT_VIDEO_PLATFORM
diff --git a/server/reflector/video_platforms/mock.py b/server/reflector/video_platforms/mock.py
new file mode 100644
index 00000000..05b84344
--- /dev/null
+++ b/server/reflector/video_platforms/mock.py
@@ -0,0 +1,124 @@
+"""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
+
+
+class MockPlatformClient(VideoPlatformClient):
+ """Mock video platform implementation for testing."""
+
+ PLATFORM_NAME = "mock"
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ # Store created rooms for testing
+ self._rooms: Dict[str, Dict[str, Any]] = {}
+ self._webhook_calls: list[Dict[str, Any]] = []
+
+ async def create_meeting(
+ self, room_name_prefix: str, end_date: datetime, room: Room
+ ) -> MeetingData:
+ """Create a mock meeting."""
+ meeting_id = str(uuid.uuid4())
+ room_name = f"{room_name_prefix}-{meeting_id[:8]}"
+ room_url = f"https://mock.video/{room_name}"
+ host_room_url = f"{room_url}?host=true"
+
+ # Store room data for later retrieval
+ self._rooms[room_name] = {
+ "id": meeting_id,
+ "name": room_name,
+ "url": room_url,
+ "host_url": host_room_url,
+ "end_date": end_date,
+ "room": room,
+ "participants": [],
+ "is_active": True,
+ }
+
+ return MeetingData(
+ meeting_id=meeting_id,
+ room_name=room_name,
+ room_url=room_url,
+ host_room_url=host_room_url,
+ platform=self.PLATFORM_NAME,
+ extra_data={"mock": True},
+ )
+
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ """Get mock room session information."""
+ if room_name not in self._rooms:
+ return {"error": "Room not found"}
+
+ room_data = self._rooms[room_name]
+ return {
+ "roomName": room_name,
+ "sessions": [
+ {
+ "sessionId": room_data["id"],
+ "startTime": datetime.utcnow().isoformat(),
+ "participants": room_data["participants"],
+ "isActive": room_data["is_active"],
+ }
+ ],
+ }
+
+ async def delete_room(self, room_name: str) -> bool:
+ """Delete a mock room."""
+ if room_name in self._rooms:
+ self._rooms[room_name]["is_active"] = False
+ return True
+ return False
+
+ async def upload_logo(self, room_name: str, logo_path: str) -> bool:
+ """Mock logo upload."""
+ if room_name in self._rooms:
+ self._rooms[room_name]["logo_path"] = logo_path
+ return True
+ return False
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ """Mock webhook signature verification."""
+ # For testing, accept signature == "valid"
+ return signature == "valid"
+
+ # Mock-specific methods for testing
+
+ def add_participant(
+ self, room_name: str, participant_id: str, participant_name: str
+ ):
+ """Add a participant to a mock room (for testing)."""
+ if room_name in self._rooms:
+ self._rooms[room_name]["participants"].append(
+ {
+ "id": participant_id,
+ "name": participant_name,
+ "joined_at": datetime.utcnow().isoformat(),
+ }
+ )
+
+ def trigger_webhook(self, event_type: str, data: Dict[str, Any]):
+ """Trigger a mock webhook event (for testing)."""
+ self._webhook_calls.append(
+ {
+ "type": event_type,
+ "data": data,
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+ )
+
+ def get_webhook_calls(self) -> list[Dict[str, Any]]:
+ """Get all webhook calls made (for testing)."""
+ return self._webhook_calls.copy()
+
+ def clear_data(self):
+ """Clear all mock data (for testing)."""
+ self._rooms.clear()
+ self._webhook_calls.clear()
diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py
new file mode 100644
index 00000000..dcfd751d
--- /dev/null
+++ b/server/reflector/video_platforms/registry.py
@@ -0,0 +1,42 @@
+from typing import Dict, Type
+
+from .base import VideoPlatformClient, VideoPlatformConfig
+
+# Registry of available video platforms
+_PLATFORMS: Dict[str, Type[VideoPlatformClient]] = {}
+
+
+def register_platform(name: str, client_class: Type[VideoPlatformClient]):
+ """Register a video platform implementation."""
+ _PLATFORMS[name.lower()] = client_class
+
+
+def get_platform_client(
+ platform: str, config: VideoPlatformConfig
+) -> VideoPlatformClient:
+ """Get a video platform client instance."""
+ platform_lower = platform.lower()
+ if platform_lower not in _PLATFORMS:
+ raise ValueError(f"Unknown video platform: {platform}")
+
+ client_class = _PLATFORMS[platform_lower]
+ return client_class(config)
+
+
+def get_available_platforms() -> list[str]:
+ """Get list of available platform names."""
+ return list(_PLATFORMS.keys())
+
+
+# Auto-register built-in platforms
+def _register_builtin_platforms():
+ from .daily import DailyClient
+ from .mock import MockPlatformClient
+ from .whereby import WherebyClient
+
+ register_platform("whereby", WherebyClient)
+ register_platform("daily", DailyClient)
+ register_platform("mock", MockPlatformClient)
+
+
+_register_builtin_platforms()
diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py
new file mode 100644
index 00000000..26312df4
--- /dev/null
+++ b/server/reflector/video_platforms/whereby.py
@@ -0,0 +1,140 @@
+import hmac
+import json
+import re
+import time
+from datetime import datetime
+from hashlib import sha256
+from typing import Any, Dict, Optional
+
+import httpx
+
+from reflector.db.rooms import Room
+
+from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig
+
+
+class WherebyClient(VideoPlatformClient):
+ """Whereby video platform implementation."""
+
+ PLATFORM_NAME = "whereby"
+ TIMEOUT = 10 # seconds
+ MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ self.headers = {
+ "Content-Type": "application/json; charset=utf-8",
+ "Authorization": f"Bearer {config.api_key}",
+ }
+
+ async def create_meeting(
+ self, room_name_prefix: str, end_date: datetime, room: Room
+ ) -> MeetingData:
+ """Create a Whereby meeting."""
+ data = {
+ "isLocked": room.is_locked,
+ "roomNamePrefix": room_name_prefix,
+ "roomNamePattern": "uuid",
+ "roomMode": room.room_mode,
+ "endDate": end_date.isoformat(),
+ "fields": ["hostRoomUrl"],
+ }
+
+ # Add recording configuration if cloud recording is enabled
+ if room.recording_type == "cloud":
+ data["recording"] = {
+ "type": room.recording_type,
+ "destination": {
+ "provider": "s3",
+ "bucket": self.config.s3_bucket,
+ "accessKeyId": self.config.aws_access_key_id,
+ "accessKeySecret": self.config.aws_access_key_secret,
+ "fileFormat": "mp4",
+ },
+ "startTrigger": room.recording_trigger,
+ }
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.config.api_url}/meetings",
+ headers=self.headers,
+ json=data,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ result = response.json()
+
+ return MeetingData(
+ meeting_id=result["meetingId"],
+ room_name=result["roomName"],
+ room_url=result["roomUrl"],
+ host_room_url=result["hostRoomUrl"],
+ platform=self.PLATFORM_NAME,
+ extra_data=result,
+ )
+
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ """Get Whereby room session information."""
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.config.api_url}/insights/room-sessions?roomName={room_name}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def delete_room(self, room_name: str) -> bool:
+ """Whereby doesn't support room deletion - meetings expire automatically."""
+ return True
+
+ async def upload_logo(self, room_name: str, logo_path: str) -> bool:
+ """Upload logo to Whereby room."""
+ async with httpx.AsyncClient() as client:
+ with open(logo_path, "rb") as f:
+ response = await client.put(
+ f"{self.config.api_url}/rooms/{room_name}/theme/logo",
+ headers={
+ "Authorization": f"Bearer {self.config.api_key}",
+ },
+ timeout=self.TIMEOUT,
+ files={"image": f},
+ )
+ response.raise_for_status()
+ return True
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ """Verify Whereby webhook signature."""
+ if not signature:
+ return False
+
+ matches = re.match(r"t=(.*),v1=(.*)", signature)
+ if not matches:
+ return False
+
+ ts, sig = matches.groups()
+
+ # Check timestamp to prevent replay attacks
+ current_time = int(time.time() * 1000)
+ diff_time = current_time - int(ts) * 1000
+ if diff_time >= self.MAX_ELAPSED_TIME:
+ return False
+
+ # Verify signature
+ body_dict = json.loads(body)
+ signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}"
+ hmac_obj = hmac.new(
+ self.config.webhook_secret.encode("utf-8"),
+ signed_payload.encode("utf-8"),
+ sha256,
+ )
+ expected_signature = hmac_obj.hexdigest()
+
+ try:
+ return hmac.compare_digest(
+ expected_signature.encode("utf-8"), sig.encode("utf-8")
+ )
+ except Exception:
+ return False
diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py
new file mode 100644
index 00000000..b4b9d081
--- /dev/null
+++ b/server/reflector/views/daily.py
@@ -0,0 +1,142 @@
+"""Daily.co webhook handler endpoint."""
+
+import hmac
+from hashlib import sha256
+from typing import Any, Dict
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from reflector.db.meetings import meetings_controller
+from reflector.settings import settings
+
+router = APIRouter()
+
+
+class DailyWebhookEvent(BaseModel):
+ """Daily.co webhook event structure."""
+
+ type: str
+ id: str
+ ts: int # Unix timestamp in milliseconds
+ data: Dict[str, Any]
+
+
+def verify_daily_webhook_signature(body: bytes, signature: str) -> bool:
+ """Verify Daily.co webhook signature using HMAC-SHA256."""
+ if not signature or not settings.DAILY_WEBHOOK_SECRET:
+ return False
+
+ try:
+ expected = hmac.new(
+ settings.DAILY_WEBHOOK_SECRET.encode(), body, sha256
+ ).hexdigest()
+ return hmac.compare_digest(expected, signature)
+ except Exception:
+ return False
+
+
+@router.post("/daily_webhook")
+async def daily_webhook(event: DailyWebhookEvent, request: Request):
+ """Handle Daily.co 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):
+ raise HTTPException(status_code=401, detail="Invalid webhook signature")
+
+ # Handle participant events
+ if event.type == "participant.joined":
+ await _handle_participant_joined(event)
+ elif event.type == "participant.left":
+ await _handle_participant_left(event)
+ elif event.type == "recording.started":
+ await _handle_recording_started(event)
+ elif event.type == "recording.ready-to-download":
+ await _handle_recording_ready(event)
+ elif event.type == "recording.error":
+ await _handle_recording_error(event)
+
+ return {"status": "ok"}
+
+
+async def _handle_participant_joined(event: DailyWebhookEvent):
+ """Handle participant joined event."""
+ room_name = event.data.get("room", {}).get("name")
+ if not room_name:
+ return
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ # Update participant count (same as Whereby)
+ current_count = getattr(meeting, "num_clients", 0)
+ await meetings_controller.update_meeting(
+ meeting.id, num_clients=current_count + 1
+ )
+
+
+async def _handle_participant_left(event: DailyWebhookEvent):
+ """Handle participant left event."""
+ room_name = event.data.get("room", {}).get("name")
+ if not room_name:
+ return
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ # Update participant count (same as Whereby)
+ current_count = getattr(meeting, "num_clients", 0)
+ await meetings_controller.update_meeting(
+ meeting.id, num_clients=max(0, current_count - 1)
+ )
+
+
+async def _handle_recording_started(event: DailyWebhookEvent):
+ """Handle recording started event."""
+ room_name = event.data.get("room", {}).get("name")
+ if not room_name:
+ return
+
+ 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}")
+
+
+async def _handle_recording_ready(event: DailyWebhookEvent):
+ """Handle recording ready for download event."""
+ room_name = event.data.get("room", {}).get("name")
+ download_link = event.data.get("download_link")
+ recording_id = event.data.get("recording_id")
+
+ if not room_name or not download_link:
+ return
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ # Queue recording processing task (same as Whereby)
+ try:
+ # Import here to avoid circular imports
+ from reflector.worker.tasks import process_recording
+
+ process_recording.delay(
+ meeting_id=meeting.id,
+ recording_url=download_link,
+ recording_id=recording_id or event.id,
+ )
+ except ImportError:
+ # Handle case where worker tasks aren't available
+ print(
+ f"Warning: Could not queue recording processing for meeting {meeting.id}"
+ )
+
+
+async def _handle_recording_error(event: DailyWebhookEvent):
+ """Handle recording error event."""
+ room_name = event.data.get("room", {}).get("name")
+ error = event.data.get("error", "Unknown error")
+
+ if room_name:
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ print(f"Recording error for meeting {meeting.id}: {error}")
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 773ce69f..2f565fae 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -14,7 +14,10 @@ from reflector.db import database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.settings import settings
-from reflector.whereby import create_meeting, upload_logo
+from reflector.video_platforms.factory import (
+ create_platform_client,
+ get_platform_for_room,
+)
logger = logging.getLogger(__name__)
@@ -34,6 +37,7 @@ class Room(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
+ platform: str
class Meeting(BaseModel):
@@ -44,6 +48,7 @@ class Meeting(BaseModel):
start_date: datetime
end_date: datetime
recording_type: Literal["none", "local", "cloud"] = "cloud"
+ platform: str
class CreateRoom(BaseModel):
@@ -98,6 +103,14 @@ async def rooms_create(
):
user_id = user["sub"] if user else None
+ # Determine platform for this room (will be "whereby" unless feature flag is enabled)
+ # Note: Since room doesn't exist yet, we can't use room_id for selection
+ platform = (
+ settings.DEFAULT_VIDEO_PLATFORM
+ if settings.DAILY_MIGRATION_ENABLED
+ else "whereby"
+ )
+
return await rooms_controller.add(
name=room.name,
user_id=user_id,
@@ -109,6 +122,7 @@ async def rooms_create(
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
is_shared=room.is_shared,
+ platform=platform,
)
@@ -156,18 +170,26 @@ async def rooms_create_meeting(
if meeting is None:
end_date = current_time + timedelta(hours=8)
- whereby_meeting = await create_meeting("", end_date=end_date, room=room)
- await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
+ # Use the platform abstraction to create meeting
+ platform = get_platform_for_room(room.id)
+ client = create_platform_client(platform)
+
+ meeting_data = await client.create_meeting(
+ room_name_prefix=room.name, end_date=end_date, room=room
+ )
+
+ # Upload logo if supported by platform
+ await client.upload_logo(meeting_data.room_name, "./images/logo.png")
# Now try to save to database
try:
meeting = await meetings_controller.create(
- id=whereby_meeting["meetingId"],
- room_name=whereby_meeting["roomName"],
- room_url=whereby_meeting["roomUrl"],
- host_room_url=whereby_meeting["hostRoomUrl"],
- start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
- end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
+ id=meeting_data.meeting_id,
+ room_name=meeting_data.room_name,
+ room_url=meeting_data.room_url,
+ host_room_url=meeting_data.host_room_url,
+ start_date=current_time,
+ end_date=end_date,
user_id=user_id,
room=room,
)
@@ -179,8 +201,9 @@ async def rooms_create_meeting(
room.name,
)
logger.warning(
- "Whereby meeting %s was created but not used (resource leak) for room %s",
- whereby_meeting["meetingId"],
+ "%s meeting %s was created but not used (resource leak) for room %s",
+ platform,
+ meeting_data.meeting_id,
room.name,
)
diff --git a/server/tests/test_daily_webhook.py b/server/tests/test_daily_webhook.py
new file mode 100644
index 00000000..94b722da
--- /dev/null
+++ b/server/tests/test_daily_webhook.py
@@ -0,0 +1,392 @@
+"""Tests for Daily.co webhook integration."""
+
+import hashlib
+import hmac
+import json
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from reflector.app import app
+from reflector.views.daily import DailyWebhookEvent
+
+
+class TestDailyWebhookIntegration:
+ """Test Daily.co webhook endpoint integration."""
+
+ @pytest.fixture
+ def webhook_secret(self):
+ """Test webhook secret."""
+ return "test-webhook-secret-123"
+
+ @pytest.fixture
+ def mock_room(self):
+ """Create a mock room for testing."""
+ room = MagicMock()
+ room.id = "test-room-123"
+ room.name = "Test Room"
+ room.recording_type = "cloud"
+ room.platform = "daily"
+ return room
+
+ @pytest.fixture
+ def mock_meeting(self):
+ """Create a mock meeting for testing."""
+ meeting = MagicMock()
+ meeting.id = "test-meeting-456"
+ meeting.room_id = "test-room-123"
+ meeting.platform = "daily"
+ meeting.room_name = "test-room-123-abc"
+ return meeting
+
+ def create_webhook_signature(self, payload: bytes, secret: str) -> str:
+ """Create HMAC signature for webhook payload."""
+ return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
+
+ def create_webhook_event(
+ self, event_type: str, room_name: str = "test-room-123-abc", **kwargs
+ ) -> dict:
+ """Create a Daily.co webhook event payload."""
+ base_event = {
+ "type": event_type,
+ "id": f"evt_{event_type.replace('.', '_')}_{int(datetime.utcnow().timestamp())}",
+ "ts": int(datetime.utcnow().timestamp() * 1000), # milliseconds
+ "data": {"room": {"name": room_name}, **kwargs},
+ }
+ return base_event
+
+ @pytest.mark.asyncio
+ async def test_webhook_participant_joined(
+ self, webhook_secret, mock_room, mock_meeting
+ ):
+ """Test participant joined webhook event."""
+ event_data = self.create_webhook_event(
+ "participant.joined",
+ participant={
+ "id": "participant-123",
+ "user_name": "John Doe",
+ "session_id": "session-456",
+ },
+ )
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = mock_meeting
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.update_meeting"
+ ) as mock_update:
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ # Verify meeting was looked up
+ mock_get_meeting.assert_called_once_with("test-room-123-abc")
+
+ @pytest.mark.asyncio
+ async def test_webhook_participant_left(
+ self, webhook_secret, mock_room, mock_meeting
+ ):
+ """Test participant left webhook event."""
+ event_data = self.create_webhook_event(
+ "participant.left",
+ participant={
+ "id": "participant-123",
+ "user_name": "John Doe",
+ "session_id": "session-456",
+ },
+ )
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = mock_meeting
+
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.asyncio
+ async def test_webhook_recording_started(
+ self, webhook_secret, mock_room, mock_meeting
+ ):
+ """Test recording started webhook event."""
+ event_data = self.create_webhook_event(
+ "recording.started",
+ recording={
+ "id": "recording-789",
+ "status": "recording",
+ "start_time": "2025-01-01T10:00:00Z",
+ },
+ )
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = mock_meeting
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.update_meeting"
+ ) as mock_update:
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.asyncio
+ async def test_webhook_recording_ready_triggers_processing(
+ self, webhook_secret, mock_room, mock_meeting
+ ):
+ """Test recording ready webhook triggers audio processing."""
+ event_data = self.create_webhook_event(
+ "recording.ready-to-download",
+ recording={
+ "id": "recording-789",
+ "status": "finished",
+ "download_url": "https://s3.amazonaws.com/bucket/recording.mp4",
+ "start_time": "2025-01-01T10:00:00Z",
+ "duration": 1800,
+ },
+ )
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = mock_meeting
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.update_meeting"
+ ) as mock_update_url:
+ with patch(
+ "reflector.worker.tasks.process_recording.delay"
+ ) as mock_process:
+ async with AsyncClient(
+ app=app, base_url="http://test/v1"
+ ) as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ # Verify recording URL was updated
+ mock_update_url.assert_called_once_with(
+ mock_meeting.id,
+ "https://s3.amazonaws.com/bucket/recording.mp4",
+ )
+
+ # Verify processing was triggered
+ mock_process.assert_called_once_with(mock_meeting.id)
+
+ @pytest.mark.asyncio
+ async def test_webhook_invalid_signature_rejected(self, webhook_secret):
+ """Test webhook with invalid signature is rejected."""
+ event_data = self.create_webhook_event("participant.joined")
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": "invalid-signature"},
+ )
+
+ assert response.status_code == 401
+ assert "Invalid signature" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ async def test_webhook_missing_signature_rejected(self):
+ """Test webhook without signature header is rejected."""
+ 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)
+
+ assert response.status_code == 401
+ assert "Missing signature" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ async def test_webhook_meeting_not_found(self, webhook_secret):
+ """Test webhook for non-existent meeting."""
+ event_data = self.create_webhook_event(
+ "participant.joined", room_name="non-existent-room"
+ )
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = None
+
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ assert response.status_code == 404
+ assert "Meeting not found" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ async def test_webhook_unknown_event_type(self, webhook_secret, mock_meeting):
+ """Test webhook with unknown event type."""
+ event_data = self.create_webhook_event("unknown.event")
+
+ payload = json.dumps(event_data).encode()
+ signature = self.create_webhook_signature(payload, webhook_secret)
+
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ with patch(
+ "reflector.db.meetings.meetings_controller.get_by_room_name"
+ ) as mock_get_meeting:
+ mock_get_meeting.return_value = mock_meeting
+
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ json=event_data,
+ headers={"X-Daily-Signature": signature},
+ )
+
+ # Should still return 200 but log the unknown event
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.asyncio
+ async def test_webhook_malformed_json(self, webhook_secret):
+ """Test webhook with malformed JSON."""
+ with patch("reflector.views.daily.settings") as mock_settings:
+ mock_settings.DAILY_WEBHOOK_SECRET = webhook_secret
+
+ async with AsyncClient(app=app, base_url="http://test/v1") as ac:
+ response = await ac.post(
+ "/daily_webhook",
+ content="invalid json",
+ headers={
+ "Content-Type": "application/json",
+ "X-Daily-Signature": "test-signature",
+ },
+ )
+
+ assert response.status_code == 422 # Validation error
+
+
+class TestWebhookEventValidation:
+ """Test webhook event data validation."""
+
+ def test_daily_webhook_event_validation_valid(self):
+ """Test valid webhook event passes validation."""
+ event_data = {
+ "type": "participant.joined",
+ "id": "evt_123",
+ "ts": 1640995200000, # milliseconds
+ "data": {
+ "room": {"name": "test-room"},
+ "participant": {
+ "id": "participant-123",
+ "user_name": "John Doe",
+ "session_id": "session-456",
+ },
+ },
+ }
+
+ event = DailyWebhookEvent(**event_data)
+ assert event.type == "participant.joined"
+ assert event.data["room"]["name"] == "test-room"
+ assert event.data["participant"]["id"] == "participant-123"
+
+ def test_daily_webhook_event_validation_minimal(self):
+ """Test minimal valid webhook event."""
+ event_data = {
+ "type": "room.created",
+ "id": "evt_123",
+ "ts": 1640995200000,
+ "data": {"room": {"name": "test-room"}},
+ }
+
+ event = DailyWebhookEvent(**event_data)
+ assert event.type == "room.created"
+ assert event.data["room"]["name"] == "test-room"
+
+ def test_daily_webhook_event_validation_with_recording(self):
+ """Test webhook event with recording data."""
+ event_data = {
+ "type": "recording.ready-to-download",
+ "id": "evt_123",
+ "ts": 1640995200000,
+ "data": {
+ "room": {"name": "test-room"},
+ "recording": {
+ "id": "recording-123",
+ "status": "finished",
+ "download_url": "https://example.com/recording.mp4",
+ "start_time": "2025-01-01T10:00:00Z",
+ "duration": 1800,
+ },
+ },
+ }
+
+ event = DailyWebhookEvent(**event_data)
+ assert event.type == "recording.ready-to-download"
+ assert event.data["recording"]["id"] == "recording-123"
+ assert (
+ event.data["recording"]["download_url"]
+ == "https://example.com/recording.mp4"
+ )
diff --git a/server/tests/test_video_platforms.py b/server/tests/test_video_platforms.py
new file mode 100644
index 00000000..1d70ea8b
--- /dev/null
+++ b/server/tests/test_video_platforms.py
@@ -0,0 +1,323 @@
+"""Tests for video platform clients."""
+
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from reflector.db.rooms import Room
+from reflector.video_platforms.base import MeetingData, VideoPlatformConfig
+from reflector.video_platforms.daily import DailyClient
+from reflector.video_platforms.mock import MockPlatformClient
+from reflector.video_platforms.registry import get_platform_client
+from reflector.video_platforms.whereby import WherebyClient
+
+
+@pytest.fixture
+def mock_room():
+ """Create a mock room for testing."""
+ room = MagicMock(spec=Room)
+ room.id = "test-room-123"
+ room.name = "Test Room"
+ room.recording_type = "cloud"
+ room.platform = "whereby"
+ return room
+
+
+@pytest.fixture
+def config():
+ """Create a test configuration."""
+ return VideoPlatformConfig(
+ api_key="test-api-key",
+ webhook_secret="test-webhook-secret",
+ subdomain="test-subdomain",
+ )
+
+
+class TestPlatformFactory:
+ """Test platform client factory."""
+
+ def test_create_whereby_client(self, config):
+ """Test creating Whereby client."""
+ client = get_platform_client("whereby", config)
+ assert isinstance(client, WherebyClient)
+
+ def test_create_daily_client(self, config):
+ """Test creating Daily.co client."""
+ client = get_platform_client("daily", config)
+ assert isinstance(client, DailyClient)
+
+ def test_create_mock_client(self, config):
+ """Test creating mock client."""
+ client = get_platform_client("mock", config)
+ assert isinstance(client, MockPlatformClient)
+
+ def test_invalid_platform_raises_error(self, config):
+ """Test that invalid platform raises ValueError."""
+ with pytest.raises(ValueError, match="Unknown platform: invalid"):
+ get_platform_client("invalid", config)
+
+
+class TestMockPlatformClient:
+ """Test mock platform client implementation."""
+
+ @pytest.fixture
+ def mock_client(self, config):
+ return MockPlatformClient(config)
+
+ @pytest.mark.asyncio
+ async def test_create_meeting(self, mock_client, mock_room):
+ """Test creating a meeting with mock client."""
+ end_date = datetime.utcnow() + timedelta(hours=1)
+
+ meeting_data = await mock_client.create_meeting(
+ room_name_prefix="test", end_date=end_date, room=mock_room
+ )
+
+ assert isinstance(meeting_data, MeetingData)
+ assert meeting_data.room_url.startswith("https://mock.video/")
+ assert meeting_data.host_room_url.startswith("https://mock.video/")
+ assert meeting_data.room_name.startswith("test")
+
+ @pytest.mark.asyncio
+ async def test_get_room_sessions(self, mock_client):
+ """Test getting room sessions."""
+ # First create a room so it exists
+ end_date = datetime.utcnow() + timedelta(hours=1)
+ mock_room = MagicMock()
+ mock_room.id = "test-room"
+ meeting = await mock_client.create_meeting("test", end_date, mock_room)
+
+ sessions = await mock_client.get_room_sessions(meeting.room_name)
+ assert isinstance(sessions, dict)
+ assert "sessions" in sessions
+ assert len(sessions["sessions"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_delete_room(self, mock_client):
+ """Test deleting a room."""
+ # First create a room so it exists
+ end_date = datetime.utcnow() + timedelta(hours=1)
+ mock_room = MagicMock()
+ mock_room.id = "test-room"
+ meeting = await mock_client.create_meeting("test", end_date, mock_room)
+
+ result = await mock_client.delete_room(meeting.room_name)
+ assert result is True
+
+ def test_verify_webhook_signature_valid(self, mock_client):
+ """Test webhook signature verification with valid signature."""
+ payload = b'{"event": "test"}'
+ signature = "valid" # Mock accepts "valid" as valid signature
+
+ result = mock_client.verify_webhook_signature(payload, signature)
+ assert result is True
+
+ def test_verify_webhook_signature_invalid(self, mock_client):
+ """Test webhook signature verification with invalid signature."""
+ payload = b'{"event": "test"}'
+ signature = "invalid-signature"
+
+ result = mock_client.verify_webhook_signature(payload, signature)
+ assert result is False
+
+
+class TestDailyClient:
+ """Test Daily.co platform client."""
+
+ @pytest.fixture
+ def daily_client(self, config):
+ return DailyClient(config)
+
+ @pytest.mark.asyncio
+ async def test_create_meeting_success(self, daily_client, mock_room):
+ """Test successful meeting creation."""
+ end_date = datetime.utcnow() + timedelta(hours=1)
+
+ mock_response = {
+ "url": "https://test.daily.co/test-room-123-abc",
+ "name": "test-room-123-abc",
+ "api_created": True,
+ "privacy": "public",
+ "config": {"enable_recording": "cloud"},
+ }
+
+ with patch.object(
+ daily_client, "_make_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = mock_response
+
+ meeting_data = await daily_client.create_meeting(
+ room_name_prefix="test", end_date=end_date, room=mock_room
+ )
+
+ assert isinstance(meeting_data, MeetingData)
+ assert meeting_data.room_url == "https://test.daily.co/test-room-123-abc"
+ assert (
+ meeting_data.host_room_url == "https://test.daily.co/test-room-123-abc"
+ )
+ assert meeting_data.room_name == "test-room-123-abc"
+
+ # Verify request was made with correct parameters
+ mock_request.assert_called_once()
+ call_args = mock_request.call_args
+ assert call_args[0][0] == "POST"
+ assert "/rooms" in call_args[0][1]
+
+ @pytest.mark.asyncio
+ async def test_get_room_sessions_success(self, daily_client):
+ """Test successful room sessions retrieval."""
+ mock_response = {
+ "data": [
+ {
+ "id": "session-1",
+ "room_name": "test-room",
+ "start_time": "2025-01-01T10:00:00Z",
+ "participants": [],
+ }
+ ]
+ }
+
+ with patch.object(
+ daily_client, "_make_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = mock_response
+
+ sessions = await daily_client.get_room_sessions("test-room")
+
+ assert isinstance(sessions, list)
+ assert len(sessions) == 1
+ assert sessions[0]["id"] == "session-1"
+
+ @pytest.mark.asyncio
+ async def test_delete_room_success(self, daily_client):
+ """Test successful room deletion."""
+ with patch.object(
+ daily_client, "_make_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = {"deleted": True}
+
+ result = await daily_client.delete_room("test-room")
+
+ assert result is True
+ mock_request.assert_called_once_with("DELETE", "/rooms/test-room")
+
+ def test_verify_webhook_signature_valid(self, daily_client):
+ """Test webhook signature verification with valid HMAC."""
+ import hashlib
+ import hmac
+
+ payload = b'{"event": "participant.joined"}'
+ expected_signature = hmac.new(
+ daily_client.webhook_secret.encode(), payload, hashlib.sha256
+ ).hexdigest()
+
+ result = daily_client.verify_webhook_signature(payload, expected_signature)
+ assert result is True
+
+ def test_verify_webhook_signature_invalid(self, daily_client):
+ """Test webhook signature verification with invalid HMAC."""
+ payload = b'{"event": "participant.joined"}'
+ invalid_signature = "invalid-signature"
+
+ result = daily_client.verify_webhook_signature(payload, invalid_signature)
+ assert result is False
+
+
+class TestWherebyClient:
+ """Test Whereby platform client."""
+
+ @pytest.fixture
+ def whereby_client(self, config):
+ return WherebyClient(config)
+
+ @pytest.mark.asyncio
+ async def test_create_meeting_delegates_to_whereby_client(
+ self, whereby_client, mock_room
+ ):
+ """Test that create_meeting delegates to existing Whereby client."""
+ end_date = datetime.utcnow() + timedelta(hours=1)
+
+ mock_whereby_response = {
+ "roomUrl": "https://whereby.com/test-room",
+ "hostRoomUrl": "https://whereby.com/test-room?host",
+ "meetingId": "meeting-123",
+ }
+
+ with patch("reflector.video_platforms.whereby.whereby_client") as mock_client:
+ mock_client.create_meeting.return_value = mock_whereby_response
+
+ meeting_data = await whereby_client.create_meeting(
+ room_name_prefix="test", end_date=end_date, room=mock_room
+ )
+
+ assert isinstance(meeting_data, MeetingData)
+ assert meeting_data.room_url == "https://whereby.com/test-room"
+ assert meeting_data.host_room_url == "https://whereby.com/test-room?host"
+ assert meeting_data.meeting_id == "meeting-123"
+
+ @pytest.mark.asyncio
+ async def test_get_room_sessions_delegates_to_whereby_client(self, whereby_client):
+ """Test that get_room_sessions delegates to existing Whereby client."""
+ mock_sessions = [{"id": "session-1"}]
+
+ with patch("reflector.video_platforms.whereby.whereby_client") as mock_client:
+ mock_client.get_room_sessions.return_value = mock_sessions
+
+ sessions = await whereby_client.get_room_sessions("test-room")
+
+ assert sessions == mock_sessions
+
+ def test_verify_webhook_signature_delegates_to_whereby_client(self, whereby_client):
+ """Test that webhook verification delegates to existing Whereby client."""
+ payload = b'{"event": "test"}'
+ signature = "test-signature"
+
+ with patch("reflector.video_platforms.whereby.whereby_client") as mock_client:
+ mock_client.verify_webhook_signature.return_value = True
+
+ result = whereby_client.verify_webhook_signature(payload, signature)
+
+ assert result is True
+ mock_client.verify_webhook_signature.assert_called_once_with(
+ payload, signature
+ )
+
+
+class TestPlatformIntegration:
+ """Integration tests for platform switching."""
+
+ @pytest.mark.asyncio
+ async def test_platform_switching_preserves_interface(self, config, mock_room):
+ """Test that different platforms provide consistent interface."""
+ end_date = datetime.utcnow() + timedelta(hours=1)
+
+ # Test with mock platform
+ mock_client = get_platform_client("mock", config)
+ mock_meeting = await mock_client.create_meeting("test", end_date, mock_room)
+
+ # Test with Daily platform (mocked)
+ daily_client = get_platform_client("daily", config)
+ with patch.object(
+ daily_client, "_make_request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = {
+ "url": "https://test.daily.co/test-room",
+ "name": "test-room",
+ "api_created": True,
+ }
+
+ daily_meeting = await daily_client.create_meeting(
+ "test", end_date, mock_room
+ )
+
+ # Both should return MeetingData objects with consistent fields
+ assert isinstance(mock_meeting, MeetingData)
+ assert isinstance(daily_meeting, MeetingData)
+
+ # Both should have required fields
+ for meeting in [mock_meeting, daily_meeting]:
+ assert hasattr(meeting, "room_url")
+ assert hasattr(meeting, "host_room_url")
+ assert hasattr(meeting, "room_name")
+ assert meeting.room_url.startswith("https://")
diff --git a/server/tests/utils/__init__.py b/server/tests/utils/__init__.py
new file mode 100644
index 00000000..827ebf96
--- /dev/null
+++ b/server/tests/utils/__init__.py
@@ -0,0 +1 @@
+# Test utilities
diff --git a/server/tests/utils/video_platform_test_utils.py b/server/tests/utils/video_platform_test_utils.py
new file mode 100644
index 00000000..453ea782
--- /dev/null
+++ b/server/tests/utils/video_platform_test_utils.py
@@ -0,0 +1,256 @@
+"""Utilities for testing video platform functionality."""
+
+from contextlib import asynccontextmanager
+from datetime import datetime, timedelta
+from typing import Any, Dict, Optional
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from reflector.db.rooms import Room
+from reflector.video_platforms.base import MeetingData, VideoPlatformConfig
+from reflector.video_platforms.factory import create_platform_client
+
+
+class MockVideoPlatformTestHelper:
+ """Helper class for testing with mock video platforms."""
+
+ def __init__(self, platform: str = "mock"):
+ self.platform = platform
+ self.config = VideoPlatformConfig(
+ api_key="test-api-key",
+ webhook_secret="test-webhook-secret",
+ subdomain="test-subdomain",
+ )
+ self.client = create_platform_client(platform, self.config)
+
+ def create_mock_room(self, room_id: str = "test-room-123", **kwargs) -> MagicMock:
+ """Create a mock room for testing."""
+ room = MagicMock(spec=Room)
+ room.id = room_id
+ room.name = kwargs.get("name", "Test Room")
+ room.recording_type = kwargs.get("recording_type", "cloud")
+ room.platform = kwargs.get("platform", self.platform)
+ return room
+
+ async def create_test_meeting(
+ self, room: Optional[Room] = None, **kwargs
+ ) -> MeetingData:
+ """Create a test meeting with default values."""
+ if room is None:
+ room = self.create_mock_room()
+
+ end_date = kwargs.get("end_date", datetime.utcnow() + timedelta(hours=1))
+ room_name_prefix = kwargs.get("room_name_prefix", "test")
+
+ return await self.client.create_meeting(room_name_prefix, end_date, room)
+
+ def create_webhook_event(
+ self, event_type: str, room_name: str = "test-room-123-abc", **kwargs
+ ) -> Dict[str, Any]:
+ """Create a webhook event payload for testing."""
+ if self.platform == "daily":
+ return self._create_daily_webhook_event(event_type, room_name, **kwargs)
+ elif self.platform == "whereby":
+ return self._create_whereby_webhook_event(event_type, room_name, **kwargs)
+ else:
+ return {"type": event_type, "room_name": room_name, **kwargs}
+
+ def _create_daily_webhook_event(
+ self, event_type: str, room_name: str, **kwargs
+ ) -> Dict[str, Any]:
+ """Create Daily.co-specific webhook event."""
+ base_event = {
+ "type": event_type,
+ "event_ts": int(datetime.utcnow().timestamp()),
+ "room": {"name": room_name},
+ }
+
+ if event_type == "participant.joined" or event_type == "participant.left":
+ base_event["participant"] = kwargs.get(
+ "participant",
+ {
+ "id": "participant-123",
+ "user_name": "Test User",
+ "session_id": "session-456",
+ },
+ )
+
+ if event_type.startswith("recording."):
+ base_event["recording"] = kwargs.get(
+ "recording",
+ {
+ "id": "recording-789",
+ "status": "finished" if "ready" in event_type else "recording",
+ "start_time": "2025-01-01T10:00:00Z",
+ },
+ )
+
+ if "ready" in event_type:
+ base_event["recording"]["download_url"] = (
+ "https://s3.amazonaws.com/bucket/recording.mp4"
+ )
+ base_event["recording"]["duration"] = 1800
+
+ return base_event
+
+ def _create_whereby_webhook_event(
+ self, event_type: str, room_name: str, **kwargs
+ ) -> Dict[str, Any]:
+ """Create Whereby-specific webhook event."""
+ # Whereby uses different event structure
+ return {
+ "event": event_type,
+ "roomName": room_name,
+ "timestamp": datetime.utcnow().isoformat(),
+ **kwargs,
+ }
+
+ def mock_platform_responses(self, platform: str, responses: Dict[str, Any]):
+ """Context manager to mock platform API responses."""
+ if platform == "daily":
+ return self._mock_daily_responses(responses)
+ elif platform == "whereby":
+ return self._mock_whereby_responses(responses)
+ else:
+ return self._mock_generic_responses(responses)
+
+ @asynccontextmanager
+ async def _mock_daily_responses(self, responses: Dict[str, Any]):
+ """Mock Daily.co API responses."""
+ with patch(
+ "reflector.video_platforms.daily.DailyPlatformClient._make_request"
+ ) as mock_request:
+ mock_request.side_effect = lambda method, url, **kwargs: responses.get(
+ f"{method} {url}", {}
+ )
+ yield mock_request
+
+ @asynccontextmanager
+ async def _mock_whereby_responses(self, responses: Dict[str, Any]):
+ """Mock Whereby API responses."""
+ with patch("reflector.video_platforms.whereby.whereby_client") as mock_client:
+ for method, response in responses.items():
+ setattr(mock_client, method, AsyncMock(return_value=response))
+ yield mock_client
+
+ @asynccontextmanager
+ async def _mock_generic_responses(self, responses: Dict[str, Any]):
+ """Mock generic platform responses."""
+ yield responses
+
+
+class IntegrationTestScenario:
+ """Helper for running integration test scenarios across platforms."""
+
+ def __init__(self, platforms: list = None):
+ self.platforms = platforms or ["mock", "daily", "whereby"]
+ self.helpers = {
+ platform: MockVideoPlatformTestHelper(platform)
+ for platform in self.platforms
+ }
+
+ async def test_meeting_lifecycle(self, room_config: Dict[str, Any] = None):
+ """Test complete meeting lifecycle across all platforms."""
+ results = {}
+
+ for platform in self.platforms:
+ helper = self.helpers[platform]
+ room = helper.create_mock_room(**(room_config or {}))
+
+ # Test meeting creation
+ meeting = await helper.create_test_meeting(room=room)
+ assert isinstance(meeting, MeetingData)
+ assert meeting.room_url.startswith("https://")
+
+ # Test room sessions
+ sessions = await helper.client.get_room_sessions(meeting.room_name)
+ assert isinstance(sessions, list)
+
+ # Test room deletion
+ deleted = await helper.client.delete_room(meeting.room_name)
+ assert deleted is True
+
+ results[platform] = {
+ "meeting": meeting,
+ "sessions": sessions,
+ "deleted": deleted,
+ }
+
+ return results
+
+ def test_webhook_signatures(self, payload: bytes = None):
+ """Test webhook signature verification across platforms."""
+ if payload is None:
+ payload = b'{"event": "test"}'
+
+ results = {}
+
+ for platform in self.platforms:
+ helper = self.helpers[platform]
+
+ # Test valid signature
+ if platform == "mock":
+ valid_signature = "valid-signature"
+ else:
+ import hashlib
+ import hmac
+
+ valid_signature = hmac.new(
+ helper.config.webhook_secret.encode(), payload, hashlib.sha256
+ ).hexdigest()
+
+ valid_result = helper.client.verify_webhook_signature(
+ payload, valid_signature
+ )
+ invalid_result = helper.client.verify_webhook_signature(
+ payload, "invalid-signature"
+ )
+
+ results[platform] = {"valid": valid_result, "invalid": invalid_result}
+
+ return results
+
+
+def create_test_meeting_data(platform: str = "mock", **overrides) -> MeetingData:
+ """Create test meeting data with platform-specific URLs."""
+ base_data = {"room_name": "test-room-123-abc", "meeting_id": "meeting-456"}
+
+ if platform == "daily":
+ base_data.update(
+ {
+ "room_url": "https://test.daily.co/test-room-123-abc",
+ "host_room_url": "https://test.daily.co/test-room-123-abc",
+ }
+ )
+ elif platform == "whereby":
+ base_data.update(
+ {
+ "room_url": "https://whereby.com/test-room-123-abc",
+ "host_room_url": "https://whereby.com/test-room-123-abc?host",
+ }
+ )
+ else: # mock
+ base_data.update(
+ {
+ "room_url": "https://mock.daily.co/test-room-123-abc",
+ "host_room_url": "https://mock.daily.co/test-room-123-abc",
+ }
+ )
+
+ base_data.update(overrides)
+ return MeetingData(**base_data)
+
+
+def assert_meeting_data_valid(meeting_data: MeetingData, platform: str = None):
+ """Assert that meeting data is valid for the given platform."""
+ assert isinstance(meeting_data, MeetingData)
+ assert meeting_data.room_url.startswith("https://")
+ assert meeting_data.host_room_url.startswith("https://")
+ assert isinstance(meeting_data.room_name, str)
+ assert len(meeting_data.room_name) > 0
+
+ if platform == "daily":
+ assert "daily.co" in meeting_data.room_url
+ elif platform == "whereby":
+ assert "whereby.com" in meeting_data.room_url
+ elif platform == "mock":
+ assert "mock.daily.co" in meeting_data.room_url
diff --git a/www/REFACTOR2.md b/www/REFACTOR2.md
deleted file mode 100644
index 76b24030..00000000
--- a/www/REFACTOR2.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Chakra UI v3 Migration - Remaining Tasks
-
-## Completed
-
-- ✅ Migrated from Chakra UI v2 to v3 in package.json
-- ✅ Updated theme.ts with whiteAlpha color palette and semantic tokens
-- ✅ Added button recipe with fontWeight 600 and hover states
-- ✅ Moved Poppins font from theme to HTML tag className
-- ✅ Fixed deprecated props across all files:
- - ✅ `isDisabled` → `disabled` (all occurrences fixed)
- - ✅ `isChecked` → `checked` (all occurrences fixed)
- - ✅ `isLoading` → `loading` (all occurrences fixed)
- - ✅ `isOpen` → `open` (all occurrences fixed)
- - ✅ `noOfLines` → `lineClamp` (all occurrences fixed)
- - ✅ `align` → `alignItems` on Flex/Stack components (all occurrences fixed)
- - ✅ `justify` → `justifyContent` on Flex/Stack components (all occurrences fixed)
-
-## Migration Summary
-
-### Files Modified
-
-1. **app/(app)/rooms/page.tsx**
-
- - Fixed: isDisabled, isChecked, align, justify on multiple components
- - Updated temporary Select component props
-
-2. **app/(app)/transcripts/fileUploadButton.tsx**
-
- - Fixed: isDisabled → disabled
-
-3. **app/(app)/transcripts/shareZulip.tsx**
-
- - Fixed: isDisabled → disabled
-
-4. **app/(app)/transcripts/shareAndPrivacy.tsx**
-
- - Fixed: isLoading → loading, isOpen → open
- - Updated temporary Select component props
-
-5. **app/(app)/browse/page.tsx**
-
- - Fixed: isOpen → open, align → alignItems, justify → justifyContent
-
-6. **app/(app)/transcripts/transcriptTitle.tsx**
-
- - Fixed: noOfLines → lineClamp
-
-7. **app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx**
-
- - Fixed: noOfLines → lineClamp
-
-8. **app/lib/expandableText.tsx**
-
- - Fixed: noOfLines → lineClamp
-
-9. **app/[roomName]/page.tsx**
-
- - Fixed: align → alignItems, justify → justifyContent
-
-10. **app/lib/WherebyWebinarEmbed.tsx**
- - Fixed: align → alignItems, justify → justifyContent
-
-## Other Potential Issues
-
-1. Check for Modal/Dialog component imports and usage (currently using temporary replacements)
-2. Review Select component usage (using temporary replacements)
-3. Test button hover states for whiteAlpha color palette
-4. Verify all color palettes work correctly with the new semantic tokens
-
-## Testing
-
-After completing migrations:
-
-1. Run `yarn dev` and check all pages
-2. Test buttons with different color palettes
-3. Verify disabled states work correctly
-4. Check that text alignment and flex layouts are correct
-5. Test modal/dialog functionality
-
-## Next Steps
-
-The Chakra UI v3 migration is now largely complete for deprecated props. The main remaining items are:
-
-- Replace temporary Modal and Select components with proper Chakra v3 implementations
-- Thorough testing of all UI components
-- Performance optimization if needed
diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx
new file mode 100644
index 00000000..a6644322
--- /dev/null
+++ b/www/app/[roomName]/components/DailyRoom.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
+import { toaster } from "../../components/ui/toaster";
+import { useRouter } from "next/navigation";
+import 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;
+}
+
+interface DailyRoomProps {
+ meeting: Meeting;
+}
+
+function ConsentDialogButton({ meetingId }: { meetingId: string }) {
+ const { state: consentState, touch, hasConsent } = useRecordingConsent();
+ const [consentLoading, setConsentLoading] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const api = useApi();
+
+ const handleConsent = useCallback(
+ async (meetingId: string, given: boolean) => {
+ if (!api) return;
+
+ setConsentLoading(true);
+
+ try {
+ await api.v1MeetingAudioConsent({
+ meetingId,
+ requestBody: { consent_given: given },
+ });
+
+ touch(meetingId);
+ } catch (error) {
+ console.error("Error submitting consent:", error);
+ } finally {
+ setConsentLoading(false);
+ }
+ },
+ [api, touch],
+ );
+
+ const showConsentModal = useCallback(() => {
+ if (modalOpen) return;
+
+ setModalOpen(true);
+
+ const toastId = toaster.create({
+ placement: "top",
+ duration: null,
+ render: ({ dismiss }) => (
+
+
+
+ Can we have your permission to store this meeting's audio
+ recording on our servers?
+
+
+
+
+
+
+
+ ),
+ });
+
+ // Set modal state when toast is dismissed
+ toastId.then((id) => {
+ const checkToastStatus = setInterval(() => {
+ if (!toaster.isActive(id)) {
+ setModalOpen(false);
+ clearInterval(checkToastStatus);
+ }
+ }, 100);
+ });
+
+ // Handle escape key to close the toast
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ toastId.then((id) => toaster.dismiss(id));
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ const cleanup = () => {
+ toastId.then((id) => toaster.dismiss(id));
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+
+ return cleanup;
+ }, [meetingId, handleConsent, modalOpen]);
+
+ if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+const recordingTypeRequiresConsent = (recordingType: string) => {
+ return recordingType === "cloud";
+};
+
+export default function DailyRoom({ meeting }: DailyRoomProps) {
+ const router = useRouter();
+ const { isLoading, isAuthenticated } = useSessionStatus();
+ const [callFrame, setCallFrame] = useState(null);
+ const containerRef = useRef(null);
+
+ const roomUrl = meeting?.host_room_url
+ ? meeting?.host_room_url
+ : meeting?.room_url;
+
+ const handleLeave = useCallback(() => {
+ router.push("/browse");
+ }, [router]);
+
+ // Initialize Daily.co call frame
+ useEffect(() => {
+ if (isLoading || !isAuthenticated || !roomUrl) return;
+
+ const frame = DailyIframe.createFrame(containerRef.current!, {
+ iframeStyle: {
+ width: "100vw",
+ height: "100vh",
+ border: "none",
+ },
+ showLeaveButton: true,
+ showFullscreenButton: true,
+ });
+
+ frame.on("left-meeting", handleLeave);
+
+ frame.join({ url: roomUrl });
+
+ setCallFrame(frame);
+
+ return () => {
+ frame.destroy();
+ };
+ }, [roomUrl, isLoading, isAuthenticated, handleLeave]);
+
+ if (!roomUrl) {
+ return null;
+ }
+
+ return (
+
+
+ {recordingTypeRequiresConsent(meeting.recording_type) && (
+
+ )}
+
+ );
+}
diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx
new file mode 100644
index 00000000..3220493f
--- /dev/null
+++ b/www/app/[roomName]/components/RoomContainer.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Suspense } from "react";
+import { Box, Spinner } from "@chakra-ui/react";
+import WherebyRoom from "./WherebyRoom";
+import DailyRoom from "./DailyRoom";
+import useRoomMeeting from "../useRoomMeeting";
+
+export type RoomDetails = {
+ params: {
+ roomName: string;
+ };
+};
+
+function LoadingSpinner() {
+ return (
+
+
+
+ );
+}
+
+export default function RoomContainer({ params }: RoomDetails) {
+ const roomName = params.roomName;
+ const meeting = useRoomMeeting(roomName);
+
+ if (meeting.loading) {
+ return ;
+ }
+
+ if (meeting.error || !meeting.response) {
+ return ;
+ }
+
+ // Determine platform from meeting response
+ // @ts-ignore - platform field may not be in types yet
+ const platform = meeting.response.platform || "whereby";
+
+ if (platform === "daily") {
+ return ;
+ }
+
+ // Default to Whereby for backward compatibility
+ return ;
+}
diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx
new file mode 100644
index 00000000..5b817ea9
--- /dev/null
+++ b/www/app/[roomName]/components/WherebyRoom.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState, RefObject } from "react";
+import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
+import { toaster } from "../../components/ui/toaster";
+import { useRouter } from "next/navigation";
+import useSessionStatus from "../../lib/useSessionStatus";
+import { useRecordingConsent } from "../../recordingConsentContext";
+import useApi from "../../lib/useApi";
+import { FaBars } from "react-icons/fa6";
+
+interface Meeting {
+ id: string;
+ room_url: string;
+ host_room_url?: string;
+ recording_type: string;
+ platform?: string;
+}
+
+interface WherebyRoomProps {
+ meeting: Meeting;
+}
+
+// Focus management for Whereby embed and consent dialog
+const useConsentWherebyFocusManagement = (
+ acceptButtonRef: RefObject,
+ wherebyRef: RefObject,
+) => {
+ const currentFocusRef = useRef(null);
+ useEffect(() => {
+ if (acceptButtonRef.current) {
+ acceptButtonRef.current.focus();
+ } else {
+ console.error(
+ "accept button ref not available yet for focus management - seems to be illegal state",
+ );
+ }
+
+ const handleWherebyReady = () => {
+ console.log("whereby ready - refocusing consent button");
+ currentFocusRef.current = document.activeElement as HTMLElement;
+ if (acceptButtonRef.current) {
+ acceptButtonRef.current.focus();
+ }
+ };
+
+ if (wherebyRef.current) {
+ wherebyRef.current.addEventListener("ready", handleWherebyReady);
+ } else {
+ console.warn(
+ "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
+ );
+ }
+
+ return () => {
+ wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
+ currentFocusRef.current?.focus();
+ };
+ }, []);
+};
+
+const useConsentDialog = (
+ meetingId: string,
+ wherebyRef: RefObject,
+) => {
+ const { state: consentState, touch, hasConsent } = useRecordingConsent();
+ const [consentLoading, setConsentLoading] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const api = useApi();
+
+ const handleConsent = useCallback(
+ async (meetingId: string, given: boolean) => {
+ if (!api) return;
+
+ setConsentLoading(true);
+
+ try {
+ await api.v1MeetingAudioConsent({
+ meetingId,
+ requestBody: { consent_given: given },
+ });
+
+ touch(meetingId);
+ } catch (error) {
+ console.error("Error submitting consent:", error);
+ } finally {
+ setConsentLoading(false);
+ }
+ },
+ [api, touch],
+ );
+
+ const showConsentModal = useCallback(() => {
+ if (modalOpen) return;
+
+ setModalOpen(true);
+
+ const toastId = toaster.create({
+ placement: "top",
+ duration: null,
+ render: ({ dismiss }) => {
+ const AcceptButton = () => {
+ const buttonRef = useRef(null);
+ useConsentWherebyFocusManagement(buttonRef, wherebyRef);
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+ Can we have your permission to store this meeting's audio
+ recording on our servers?
+
+
+
+
+
+
+
+ );
+ },
+ });
+
+ // Set modal state when toast is dismissed
+ toastId.then((id) => {
+ const checkToastStatus = setInterval(() => {
+ if (!toaster.isActive(id)) {
+ setModalOpen(false);
+ clearInterval(checkToastStatus);
+ }
+ }, 100);
+ });
+
+ // Handle escape key to close the toast
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ toastId.then((id) => toaster.dismiss(id));
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ const cleanup = () => {
+ toastId.then((id) => toaster.dismiss(id));
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+
+ return cleanup;
+ }, [meetingId, handleConsent, wherebyRef, modalOpen]);
+
+ return { showConsentModal, consentState, hasConsent, consentLoading };
+};
+
+function ConsentDialogButton({
+ meetingId,
+ wherebyRef,
+}: {
+ meetingId: string;
+ wherebyRef: React.RefObject;
+}) {
+ const { showConsentModal, consentState, hasConsent, consentLoading } =
+ useConsentDialog(meetingId, wherebyRef);
+
+ if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+const recordingTypeRequiresConsent = (recordingType: string) => {
+ return recordingType === "cloud";
+};
+
+// Whereby SDK loading hook
+const useWhereby = () => {
+ const [wherebyLoaded, setWherebyLoaded] = useState(false);
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ import("@whereby.com/browser-sdk/embed")
+ .then(() => {
+ setWherebyLoaded(true);
+ })
+ .catch(console.error.bind(console));
+ }
+ }, []);
+ return wherebyLoaded;
+};
+
+export default function WherebyRoom({ meeting }: WherebyRoomProps) {
+ const wherebyLoaded = useWhereby();
+ const wherebyRef = useRef(null);
+ const router = useRouter();
+ const { isLoading, isAuthenticated } = useSessionStatus();
+
+ const roomUrl = meeting?.host_room_url
+ ? meeting?.host_room_url
+ : meeting?.room_url;
+
+ const handleLeave = useCallback(() => {
+ router.push("/browse");
+ }, [router]);
+
+ useEffect(() => {
+ if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
+
+ wherebyRef.current?.addEventListener("leave", handleLeave);
+
+ return () => {
+ wherebyRef.current?.removeEventListener("leave", handleLeave);
+ };
+ }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
+
+ if (!roomUrl || !wherebyLoaded) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {recordingTypeRequiresConsent(meeting.recording_type) && (
+
+ )}
+ >
+ );
+}
diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx
index b03a7e4f..87651a50 100644
--- a/www/app/[roomName]/page.tsx
+++ b/www/app/[roomName]/page.tsx
@@ -1,326 +1,3 @@
-"use client";
+import RoomContainer from "./components/RoomContainer";
-import {
- useCallback,
- useEffect,
- useRef,
- useState,
- useContext,
- RefObject,
-} from "react";
-import {
- Box,
- Button,
- Text,
- VStack,
- HStack,
- Spinner,
- Icon,
-} from "@chakra-ui/react";
-import { toaster } from "../components/ui/toaster";
-import useRoomMeeting from "./useRoomMeeting";
-import { useRouter } from "next/navigation";
-import { notFound } from "next/navigation";
-import useSessionStatus from "../lib/useSessionStatus";
-import { useRecordingConsent } from "../recordingConsentContext";
-import useApi from "../lib/useApi";
-import { Meeting } from "../api";
-import { FaBars } from "react-icons/fa6";
-
-export type RoomDetails = {
- params: {
- roomName: string;
- };
-};
-
-// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
-const useConsentWherebyFocusManagement = (
- acceptButtonRef: RefObject,
- wherebyRef: RefObject,
-) => {
- const currentFocusRef = useRef(null);
- useEffect(() => {
- if (acceptButtonRef.current) {
- acceptButtonRef.current.focus();
- } else {
- console.error(
- "accept button ref not available yet for focus management - seems to be illegal state",
- );
- }
-
- const handleWherebyReady = () => {
- console.log("whereby ready - refocusing consent button");
- currentFocusRef.current = document.activeElement as HTMLElement;
- if (acceptButtonRef.current) {
- acceptButtonRef.current.focus();
- }
- };
-
- if (wherebyRef.current) {
- wherebyRef.current.addEventListener("ready", handleWherebyReady);
- } else {
- console.warn(
- "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
- );
- }
-
- return () => {
- wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
- currentFocusRef.current?.focus();
- };
- }, []);
-};
-
-const useConsentDialog = (
- meetingId: string,
- wherebyRef: RefObject /*accessibility*/,
-) => {
- const { state: consentState, touch, hasConsent } = useRecordingConsent();
- const [consentLoading, setConsentLoading] = useState(false);
- // toast would open duplicates, even with using "id=" prop
- const [modalOpen, setModalOpen] = useState(false);
- const api = useApi();
-
- const handleConsent = useCallback(
- async (meetingId: string, given: boolean) => {
- if (!api) return;
-
- setConsentLoading(true);
-
- try {
- await api.v1MeetingAudioConsent({
- meetingId,
- requestBody: { consent_given: given },
- });
-
- touch(meetingId);
- } catch (error) {
- console.error("Error submitting consent:", error);
- } finally {
- setConsentLoading(false);
- }
- },
- [api, touch],
- );
-
- const showConsentModal = useCallback(() => {
- if (modalOpen) return;
-
- setModalOpen(true);
-
- const toastId = toaster.create({
- placement: "top",
- duration: null,
- render: ({ dismiss }) => {
- const AcceptButton = () => {
- const buttonRef = useRef(null);
- useConsentWherebyFocusManagement(buttonRef, wherebyRef);
- return (
-
- );
- };
-
- return (
-
-
-
- Can we have your permission to store this meeting's audio
- recording on our servers?
-
-
-
-
-
-
-
- );
- },
- });
-
- // Set modal state when toast is dismissed
- toastId.then((id) => {
- const checkToastStatus = setInterval(() => {
- if (!toaster.isActive(id)) {
- setModalOpen(false);
- clearInterval(checkToastStatus);
- }
- }, 100);
- });
-
- // Handle escape key to close the toast
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- toastId.then((id) => toaster.dismiss(id));
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
-
- const cleanup = () => {
- toastId.then((id) => toaster.dismiss(id));
- document.removeEventListener("keydown", handleKeyDown);
- };
-
- return cleanup;
- }, [meetingId, handleConsent, wherebyRef, modalOpen]);
-
- return { showConsentModal, consentState, hasConsent, consentLoading };
-};
-
-function ConsentDialogButton({
- meetingId,
- wherebyRef,
-}: {
- meetingId: string;
- wherebyRef: React.RefObject;
-}) {
- const { showConsentModal, consentState, hasConsent, consentLoading } =
- useConsentDialog(meetingId, wherebyRef);
-
- if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
- return null;
- }
-
- return (
-
- );
-}
-
-const recordingTypeRequiresConsent = (
- recordingType: NonNullable,
-) => {
- return recordingType === "cloud";
-};
-
-// next throws even with "use client"
-const useWhereby = () => {
- const [wherebyLoaded, setWherebyLoaded] = useState(false);
- useEffect(() => {
- if (typeof window !== "undefined") {
- import("@whereby.com/browser-sdk/embed")
- .then(() => {
- setWherebyLoaded(true);
- })
- .catch(console.error.bind(console));
- }
- }, []);
- return wherebyLoaded;
-};
-
-export default function Room(details: RoomDetails) {
- const wherebyLoaded = useWhereby();
- const wherebyRef = useRef(null);
- const roomName = details.params.roomName;
- const meeting = useRoomMeeting(roomName);
- const router = useRouter();
- const { isLoading, isAuthenticated } = useSessionStatus();
-
- const roomUrl = meeting?.response?.host_room_url
- ? meeting?.response?.host_room_url
- : meeting?.response?.room_url;
-
- const meetingId = meeting?.response?.id;
-
- const recordingType = meeting?.response?.recording_type;
-
- const handleLeave = useCallback(() => {
- router.push("/browse");
- }, [router]);
-
- useEffect(() => {
- if (
- !isLoading &&
- meeting?.error &&
- "status" in meeting.error &&
- meeting.error.status === 404
- ) {
- notFound();
- }
- }, [isLoading, meeting?.error]);
-
- useEffect(() => {
- if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
-
- wherebyRef.current?.addEventListener("leave", handleLeave);
-
- return () => {
- wherebyRef.current?.removeEventListener("leave", handleLeave);
- };
- }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
- <>
- {roomUrl && meetingId && wherebyLoaded && (
- <>
-
- {recordingType && recordingTypeRequiresConsent(recordingType) && (
-
- )}
- >
- )}
- >
- );
-}
+export default RoomContainer;
diff --git a/www/package.json b/www/package.json
index a2e83af0..32c41eb8 100644
--- a/www/package.json
+++ b/www/package.json
@@ -12,6 +12,8 @@
},
"dependencies": {
"@chakra-ui/react": "^3.22.0",
+ "@daily-co/daily-js": "^0.81.0",
+ "@daily-co/daily-react": "^0.23.1",
"@emotion/react": "^11.14.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
diff --git a/www/yarn.lock b/www/yarn.lock
index cbaaba6c..107cd2a9 100644
--- a/www/yarn.lock
+++ b/www/yarn.lock
@@ -163,6 +163,25 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
+"@daily-co/daily-js@^0.81.0":
+ version "0.81.0"
+ resolved "https://registry.yarnpkg.com/@daily-co/daily-js/-/daily-js-0.81.0.tgz#feac952d5be19df093f563d621e00e55d6dd60fb"
+ integrity sha512-idmRGl9fK+KBzsPfx10ceBFdAt31sEmXXnKnuHIN/zqCzhbpb7FoxzQmuY6Ud8hgYdJUCD9LYkBAaARpjnqUkA==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+ "@sentry/browser" "^8.33.1"
+ bowser "^2.8.1"
+ dequal "^2.0.3"
+ events "^3.1.0"
+
+"@daily-co/daily-react@^0.23.1":
+ version "0.23.1"
+ resolved "https://registry.yarnpkg.com/@daily-co/daily-react/-/daily-react-0.23.1.tgz#77ad90a038738c58ac5848985c14783fc15f1bd9"
+ integrity sha512-tGeDYH114P/k5694xD0bBrSM1sYPi3MjBbQlHBTh35O4+9uW+3F2nfFv/WnKrFsjYs6OuVtq8Hk8/BSLvm29ww==
+ dependencies:
+ fast-deep-equal "^3.1.3"
+ lodash.throttle "^4.1.1"
+
"@edge-runtime/format@2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@edge-runtime/format/-/format-2.2.1.tgz#10dcedb0d7c2063c9ee360fbab23846c8720f986"
@@ -959,6 +978,36 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz#9ab8f811930d7af3e3d549183a50884f9eb83f36"
integrity sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==
+"@sentry-internal/browser-utils@8.55.0":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz#d89bae423edd29c39f01285c8e2d59ce9289d9a6"
+ integrity sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==
+ dependencies:
+ "@sentry/core" "8.55.0"
+
+"@sentry-internal/feedback@8.55.0":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.55.0.tgz#170b8e96a36ce6f71f53daad680f1a0c98381314"
+ integrity sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==
+ dependencies:
+ "@sentry/core" "8.55.0"
+
+"@sentry-internal/replay-canvas@8.55.0":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz#e65430207a2f18e4a07c25c669ec758d11282aaf"
+ integrity sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==
+ dependencies:
+ "@sentry-internal/replay" "8.55.0"
+ "@sentry/core" "8.55.0"
+
+"@sentry-internal/replay@8.55.0":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.55.0.tgz#4c00b22cdf58cac5b3e537f8d4f675f2b021f475"
+ integrity sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==
+ dependencies:
+ "@sentry-internal/browser-utils" "8.55.0"
+ "@sentry/core" "8.55.0"
+
"@sentry-internal/tracing@7.77.0":
version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.77.0.tgz#f3d82486f8934a955b3dd2aa54c8d29586e42a37"
@@ -979,6 +1028,17 @@
"@sentry/types" "7.77.0"
"@sentry/utils" "7.77.0"
+"@sentry/browser@^8.33.1":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.55.0.tgz#9a489e2a54d29c65e6271b4ee594b43679cab7bd"
+ integrity sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==
+ dependencies:
+ "@sentry-internal/browser-utils" "8.55.0"
+ "@sentry-internal/feedback" "8.55.0"
+ "@sentry-internal/replay" "8.55.0"
+ "@sentry-internal/replay-canvas" "8.55.0"
+ "@sentry/core" "8.55.0"
+
"@sentry/cli@^1.74.6":
version "1.75.2"
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.75.2.tgz#2c38647b38300e52c9839612d42b7c23f8d6455b"
@@ -999,6 +1059,11 @@
"@sentry/types" "7.77.0"
"@sentry/utils" "7.77.0"
+"@sentry/core@8.55.0":
+ version "8.55.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.55.0.tgz#4964920229fcf649237ef13b1533dfc4b9f6b22e"
+ integrity sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==
+
"@sentry/integrations@7.77.0":
version "7.77.0"
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.77.0.tgz#f2717e05cb7c69363316ccd34096b2ea07ae4c59"
@@ -2685,6 +2750,11 @@ bindings@^1.4.0:
dependencies:
file-uri-to-path "1.0.0"
+bowser@^2.8.1:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
+ integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
+
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
@@ -3846,7 +3916,7 @@ events-intercept@^2.0.0:
resolved "https://registry.yarnpkg.com/events-intercept/-/events-intercept-2.0.0.tgz#adbf38681c5a4b2011c41ee41f61a34cba448897"
integrity sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==
-events@^3.3.0:
+events@^3.1.0, events@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
@@ -5052,6 +5122,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+lodash.throttle@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
+ integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
+
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"