mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
chore: remove refactor md (#527)
This commit is contained in:
587
REFACTOR_WHEREBY_FINDING.md
Normal file
587
REFACTOR_WHEREBY_FINDING.md
Normal file
@@ -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 `<whereby-embed>` 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 (
|
||||
<Box position="relative" width="100vw" height="100vh">
|
||||
{/* Daily.co automatically handles the video/audio UI */}
|
||||
<Box
|
||||
as="iframe"
|
||||
src={daily?.iframe()?.src}
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="camera; microphone; fullscreen; speaker; display-capture"
|
||||
style={{ border: "none" }}
|
||||
/>
|
||||
|
||||
{/* Recording status indicator */}
|
||||
{isRecording && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
bg="red.500"
|
||||
color="white"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
>
|
||||
Recording
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Participant count with real-time data */}
|
||||
<Box position="absolute" bottom={4} left={4} bg="gray.800" color="white" px={3} py={1} borderRadius="md">
|
||||
Participants: {participantIds.length + 1}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string | null>(null);
|
||||
const [callFrame, setCallFrame] = useState<DailyIframe | null>(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 (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
||||
<Spinner color="blue.500" size="xl" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dailyUrl || !callFrame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DailyProvider callObject={callFrame} url={dailyUrl}>
|
||||
<CallInterface />
|
||||
<ConsentDialog meetingId={meeting?.response?.id} />
|
||||
</DailyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
### 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 `<whereby-embed>` 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.*
|
||||
Reference in New Issue
Block a user