From 8b644384a205ee86760a7fab23f5af19b3f1dc58 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 1 Aug 2025 16:33:40 -0600 Subject: [PATCH] chore: remove refactor md (#527) --- IMPLEMENTATION_STATUS.md | 127 ++++ PLAN.md | 261 ++++++++ REFACTOR_WHEREBY_FINDING.md | 587 ++++++++++++++++++ .../20250801180012_add_platform_support.py | 41 ++ server/reflector/app.py | 2 + server/reflector/db/meetings.py | 8 + server/reflector/db/rooms.py | 6 + server/reflector/settings.py | 12 + server/reflector/video_platforms/__init__.py | 17 + server/reflector/video_platforms/base.py | 82 +++ server/reflector/video_platforms/daily.py | 152 +++++ server/reflector/video_platforms/factory.py | 54 ++ server/reflector/video_platforms/mock.py | 124 ++++ server/reflector/video_platforms/registry.py | 42 ++ server/reflector/video_platforms/whereby.py | 140 +++++ server/reflector/views/daily.py | 142 +++++ server/reflector/views/rooms.py | 45 +- server/tests/test_daily_webhook.py | 392 ++++++++++++ server/tests/test_video_platforms.py | 323 ++++++++++ server/tests/utils/__init__.py | 1 + .../tests/utils/video_platform_test_utils.py | 256 ++++++++ www/REFACTOR2.md | 86 --- www/app/[roomName]/components/DailyRoom.tsx | 208 +++++++ .../[roomName]/components/RoomContainer.tsx | 52 ++ www/app/[roomName]/components/WherebyRoom.tsx | 276 ++++++++ www/app/[roomName]/page.tsx | 327 +--------- www/package.json | 2 + www/yarn.lock | 77 ++- 28 files changed, 3419 insertions(+), 423 deletions(-) create mode 100644 IMPLEMENTATION_STATUS.md create mode 100644 PLAN.md create mode 100644 REFACTOR_WHEREBY_FINDING.md create mode 100644 server/migrations/versions/20250801180012_add_platform_support.py create mode 100644 server/reflector/video_platforms/__init__.py create mode 100644 server/reflector/video_platforms/base.py create mode 100644 server/reflector/video_platforms/daily.py create mode 100644 server/reflector/video_platforms/factory.py create mode 100644 server/reflector/video_platforms/mock.py create mode 100644 server/reflector/video_platforms/registry.py create mode 100644 server/reflector/video_platforms/whereby.py create mode 100644 server/reflector/views/daily.py create mode 100644 server/tests/test_daily_webhook.py create mode 100644 server/tests/test_video_platforms.py create mode 100644 server/tests/utils/__init__.py create mode 100644 server/tests/utils/video_platform_test_utils.py delete mode 100644 www/REFACTOR2.md create mode 100644 www/app/[roomName]/components/DailyRoom.tsx create mode 100644 www/app/[roomName]/components/RoomContainer.tsx create mode 100644 www/app/[roomName]/components/WherebyRoom.tsx 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"