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:
127
IMPLEMENTATION_STATUS.md
Normal file
127
IMPLEMENTATION_STATUS.md
Normal file
@@ -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
|
||||||
261
PLAN.md
Normal file
261
PLAN.md
Normal file
@@ -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 <DailyRoom meeting={meeting.response} />
|
||||||
|
}
|
||||||
|
return <WherebyRoom meeting={meeting.response} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [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.
|
||||||
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.*
|
||||||
@@ -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")
|
||||||
@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
|||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.metrics import metrics_init
|
from reflector.metrics import metrics_init
|
||||||
from reflector.settings import settings
|
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.meetings import router as meetings_router
|
||||||
from reflector.views.rooms import router as rooms_router
|
from reflector.views.rooms import router as rooms_router
|
||||||
from reflector.views.rtc_offer import router as rtc_offer_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(user_router, prefix="/v1")
|
||||||
app.include_router(zulip_router, prefix="/v1")
|
app.include_router(zulip_router, prefix="/v1")
|
||||||
app.include_router(whereby_router, prefix="/v1")
|
app.include_router(whereby_router, prefix="/v1")
|
||||||
|
app.include_router(daily_router, prefix="/v1")
|
||||||
add_pagination(app)
|
add_pagination(app)
|
||||||
|
|
||||||
# prepare celery
|
# prepare celery
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ meetings = sa.Table(
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=sa.true(),
|
server_default=sa.true(),
|
||||||
),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"platform",
|
||||||
|
sa.String,
|
||||||
|
nullable=False,
|
||||||
|
server_default="whereby",
|
||||||
|
),
|
||||||
sa.Index("idx_meeting_room_id", "room_id"),
|
sa.Index("idx_meeting_room_id", "room_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,6 +85,7 @@ class Meeting(BaseModel):
|
|||||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
] = "automatic-2nd-participant"
|
] = "automatic-2nd-participant"
|
||||||
num_clients: int = 0
|
num_clients: int = 0
|
||||||
|
platform: Literal["whereby", "daily"] = "whereby"
|
||||||
|
|
||||||
|
|
||||||
class MeetingController:
|
class MeetingController:
|
||||||
@@ -109,6 +116,7 @@ class MeetingController:
|
|||||||
room_mode=room.room_mode,
|
room_mode=room.room_mode,
|
||||||
recording_type=room.recording_type,
|
recording_type=room.recording_type,
|
||||||
recording_trigger=room.recording_trigger,
|
recording_trigger=room.recording_trigger,
|
||||||
|
platform=room.platform,
|
||||||
)
|
)
|
||||||
query = meetings.insert().values(**meeting.model_dump())
|
query = meetings.insert().values(**meeting.model_dump())
|
||||||
await database.execute(query)
|
await database.execute(query)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ rooms = sqlalchemy.Table(
|
|||||||
sqlalchemy.Column(
|
sqlalchemy.Column(
|
||||||
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
|
"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"),
|
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ class Room(BaseModel):
|
|||||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
] = "automatic-2nd-participant"
|
] = "automatic-2nd-participant"
|
||||||
is_shared: bool = False
|
is_shared: bool = False
|
||||||
|
platform: Literal["whereby", "daily"] = "whereby"
|
||||||
|
|
||||||
|
|
||||||
class RoomController:
|
class RoomController:
|
||||||
@@ -107,6 +111,7 @@ class RoomController:
|
|||||||
recording_type: str,
|
recording_type: str,
|
||||||
recording_trigger: str,
|
recording_trigger: str,
|
||||||
is_shared: bool,
|
is_shared: bool,
|
||||||
|
platform: str = "whereby",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a new room
|
Add a new room
|
||||||
@@ -122,6 +127,7 @@ class RoomController:
|
|||||||
recording_type=recording_type,
|
recording_type=recording_type,
|
||||||
recording_trigger=recording_trigger,
|
recording_trigger=recording_trigger,
|
||||||
is_shared=is_shared,
|
is_shared=is_shared,
|
||||||
|
platform=platform,
|
||||||
)
|
)
|
||||||
query = rooms.insert().values(**room.model_dump())
|
query = rooms.insert().values(**room.model_dump())
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ class Settings(BaseSettings):
|
|||||||
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
|
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
|
||||||
SQS_POLLING_TIMEOUT_SECONDS: int = 60
|
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 integration
|
||||||
ZULIP_REALM: str | None = None
|
ZULIP_REALM: str | None = None
|
||||||
ZULIP_API_KEY: str | None = None
|
ZULIP_API_KEY: str | None = None
|
||||||
|
|||||||
17
server/reflector/video_platforms/__init__.py
Normal file
17
server/reflector/video_platforms/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
82
server/reflector/video_platforms/base.py
Normal file
82
server/reflector/video_platforms/base.py
Normal file
@@ -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}
|
||||||
152
server/reflector/video_platforms/daily.py
Normal file
152
server/reflector/video_platforms/daily.py
Normal file
@@ -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()
|
||||||
54
server/reflector/video_platforms/factory.py
Normal file
54
server/reflector/video_platforms/factory.py
Normal file
@@ -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
|
||||||
124
server/reflector/video_platforms/mock.py
Normal file
124
server/reflector/video_platforms/mock.py
Normal file
@@ -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()
|
||||||
42
server/reflector/video_platforms/registry.py
Normal file
42
server/reflector/video_platforms/registry.py
Normal file
@@ -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()
|
||||||
140
server/reflector/video_platforms/whereby.py
Normal file
140
server/reflector/video_platforms/whereby.py
Normal file
@@ -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
|
||||||
142
server/reflector/views/daily.py
Normal file
142
server/reflector/views/daily.py
Normal file
@@ -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}")
|
||||||
@@ -14,7 +14,10 @@ from reflector.db import database
|
|||||||
from reflector.db.meetings import meetings_controller
|
from reflector.db.meetings import meetings_controller
|
||||||
from reflector.db.rooms import rooms_controller
|
from reflector.db.rooms import rooms_controller
|
||||||
from reflector.settings import settings
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ class Room(BaseModel):
|
|||||||
recording_type: str
|
recording_type: str
|
||||||
recording_trigger: str
|
recording_trigger: str
|
||||||
is_shared: bool
|
is_shared: bool
|
||||||
|
platform: str
|
||||||
|
|
||||||
|
|
||||||
class Meeting(BaseModel):
|
class Meeting(BaseModel):
|
||||||
@@ -44,6 +48,7 @@ class Meeting(BaseModel):
|
|||||||
start_date: datetime
|
start_date: datetime
|
||||||
end_date: datetime
|
end_date: datetime
|
||||||
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||||
|
platform: str
|
||||||
|
|
||||||
|
|
||||||
class CreateRoom(BaseModel):
|
class CreateRoom(BaseModel):
|
||||||
@@ -98,6 +103,14 @@ async def rooms_create(
|
|||||||
):
|
):
|
||||||
user_id = user["sub"] if user else None
|
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(
|
return await rooms_controller.add(
|
||||||
name=room.name,
|
name=room.name,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -109,6 +122,7 @@ async def rooms_create(
|
|||||||
recording_type=room.recording_type,
|
recording_type=room.recording_type,
|
||||||
recording_trigger=room.recording_trigger,
|
recording_trigger=room.recording_trigger,
|
||||||
is_shared=room.is_shared,
|
is_shared=room.is_shared,
|
||||||
|
platform=platform,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,18 +170,26 @@ async def rooms_create_meeting(
|
|||||||
if meeting is None:
|
if meeting is None:
|
||||||
end_date = current_time + timedelta(hours=8)
|
end_date = current_time + timedelta(hours=8)
|
||||||
|
|
||||||
whereby_meeting = await create_meeting("", end_date=end_date, room=room)
|
# Use the platform abstraction to create meeting
|
||||||
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
|
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
|
# Now try to save to database
|
||||||
try:
|
try:
|
||||||
meeting = await meetings_controller.create(
|
meeting = await meetings_controller.create(
|
||||||
id=whereby_meeting["meetingId"],
|
id=meeting_data.meeting_id,
|
||||||
room_name=whereby_meeting["roomName"],
|
room_name=meeting_data.room_name,
|
||||||
room_url=whereby_meeting["roomUrl"],
|
room_url=meeting_data.room_url,
|
||||||
host_room_url=whereby_meeting["hostRoomUrl"],
|
host_room_url=meeting_data.host_room_url,
|
||||||
start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
|
start_date=current_time,
|
||||||
end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
|
end_date=end_date,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
room=room,
|
room=room,
|
||||||
)
|
)
|
||||||
@@ -179,8 +201,9 @@ async def rooms_create_meeting(
|
|||||||
room.name,
|
room.name,
|
||||||
)
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Whereby meeting %s was created but not used (resource leak) for room %s",
|
"%s meeting %s was created but not used (resource leak) for room %s",
|
||||||
whereby_meeting["meetingId"],
|
platform,
|
||||||
|
meeting_data.meeting_id,
|
||||||
room.name,
|
room.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
392
server/tests/test_daily_webhook.py
Normal file
392
server/tests/test_daily_webhook.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
323
server/tests/test_video_platforms.py
Normal file
323
server/tests/test_video_platforms.py
Normal file
@@ -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://")
|
||||||
1
server/tests/utils/__init__.py
Normal file
1
server/tests/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Test utilities
|
||||||
256
server/tests/utils/video_platform_test_utils.py
Normal file
256
server/tests/utils/video_platform_test_utils.py
Normal file
@@ -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
|
||||||
@@ -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
|
|
||||||
208
www/app/[roomName]/components/DailyRoom.tsx
Normal file
208
www/app/[roomName]/components/DailyRoom.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
bg="rgba(255, 255, 255, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="lg"
|
||||||
|
maxW="md"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
<VStack gap={4} alignItems="center">
|
||||||
|
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||||
|
Can we have your permission to store this meeting's audio
|
||||||
|
recording on our servers?
|
||||||
|
</Text>
|
||||||
|
<HStack gap={4} justifyContent="center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, false).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No, delete after transcription
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, true).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, store the audio
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
left="8px"
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={showConsentModal}
|
||||||
|
>
|
||||||
|
Meeting is being recorded
|
||||||
|
<Icon as={FaBars} ml={2} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingTypeRequiresConsent = (recordingType: string) => {
|
||||||
|
return recordingType === "cloud";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||||
|
const [callFrame, setCallFrame] = useState<DailyIframe | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<Box position="relative" width="100vw" height="100vh">
|
||||||
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
|
{recordingTypeRequiresConsent(meeting.recording_type) && (
|
||||||
|
<ConsentDialogButton meetingId={meeting.id} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
www/app/[roomName]/components/RoomContainer.tsx
Normal file
52
www/app/[roomName]/components/RoomContainer.tsx
Normal file
@@ -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 (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
height="100vh"
|
||||||
|
bg="gray.50"
|
||||||
|
p={4}
|
||||||
|
>
|
||||||
|
<Spinner color="blue.500" size="xl" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomContainer({ params }: RoomDetails) {
|
||||||
|
const roomName = params.roomName;
|
||||||
|
const meeting = useRoomMeeting(roomName);
|
||||||
|
|
||||||
|
if (meeting.loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meeting.error || !meeting.response) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <DailyRoom meeting={meeting.response} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Whereby for backward compatibility
|
||||||
|
return <WherebyRoom meeting={meeting.response} />;
|
||||||
|
}
|
||||||
276
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
276
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
@@ -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<HTMLButtonElement>,
|
||||||
|
wherebyRef: RefObject<HTMLElement>,
|
||||||
|
) => {
|
||||||
|
const currentFocusRef = useRef<HTMLElement | null>(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<HTMLElement>,
|
||||||
|
) => {
|
||||||
|
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<HTMLButtonElement>(null);
|
||||||
|
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
colorPalette="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, true).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, store the audio
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={6}
|
||||||
|
bg="rgba(255, 255, 255, 0.7)"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="lg"
|
||||||
|
maxW="md"
|
||||||
|
mx="auto"
|
||||||
|
>
|
||||||
|
<VStack gap={4} alignItems="center">
|
||||||
|
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||||
|
Can we have your permission to store this meeting's audio
|
||||||
|
recording on our servers?
|
||||||
|
</Text>
|
||||||
|
<HStack gap={4} justifyContent="center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleConsent(meetingId, false).then(() => {
|
||||||
|
/*signifies it's ok to now wait here.*/
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No, delete after transcription
|
||||||
|
</Button>
|
||||||
|
<AcceptButton />
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<HTMLElement>;
|
||||||
|
}) {
|
||||||
|
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||||
|
useConsentDialog(meetingId, wherebyRef);
|
||||||
|
|
||||||
|
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
left="8px"
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={showConsentModal}
|
||||||
|
>
|
||||||
|
Meeting is being recorded
|
||||||
|
<Icon as={FaBars} ml={2} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLElement>(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 (
|
||||||
|
<>
|
||||||
|
<whereby-embed
|
||||||
|
ref={wherebyRef}
|
||||||
|
room={roomUrl}
|
||||||
|
style={{ width: "100vw", height: "100vh" }}
|
||||||
|
/>
|
||||||
|
{recordingTypeRequiresConsent(meeting.recording_type) && (
|
||||||
|
<ConsentDialogButton meetingId={meeting.id} wherebyRef={wherebyRef} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,326 +1,3 @@
|
|||||||
"use client";
|
import RoomContainer from "./components/RoomContainer";
|
||||||
|
|
||||||
import {
|
export default RoomContainer;
|
||||||
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<HTMLButtonElement>,
|
|
||||||
wherebyRef: RefObject<HTMLElement>,
|
|
||||||
) => {
|
|
||||||
const currentFocusRef = useRef<HTMLElement | null>(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<HTMLElement> /*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<HTMLButtonElement>(null);
|
|
||||||
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={buttonRef}
|
|
||||||
colorPalette="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
handleConsent(meetingId, true).then(() => {
|
|
||||||
/*signifies it's ok to now wait here.*/
|
|
||||||
});
|
|
||||||
dismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Yes, store the audio
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
p={6}
|
|
||||||
bg="rgba(255, 255, 255, 0.7)"
|
|
||||||
borderRadius="lg"
|
|
||||||
boxShadow="lg"
|
|
||||||
maxW="md"
|
|
||||||
mx="auto"
|
|
||||||
>
|
|
||||||
<VStack gap={4} alignItems="center">
|
|
||||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
|
||||||
Can we have your permission to store this meeting's audio
|
|
||||||
recording on our servers?
|
|
||||||
</Text>
|
|
||||||
<HStack gap={4} justifyContent="center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
handleConsent(meetingId, false).then(() => {
|
|
||||||
/*signifies it's ok to now wait here.*/
|
|
||||||
});
|
|
||||||
dismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No, delete after transcription
|
|
||||||
</Button>
|
|
||||||
<AcceptButton />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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<HTMLElement>;
|
|
||||||
}) {
|
|
||||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
|
||||||
useConsentDialog(meetingId, wherebyRef);
|
|
||||||
|
|
||||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
position="absolute"
|
|
||||||
top="56px"
|
|
||||||
left="8px"
|
|
||||||
zIndex={1000}
|
|
||||||
colorPalette="blue"
|
|
||||||
size="sm"
|
|
||||||
onClick={showConsentModal}
|
|
||||||
>
|
|
||||||
Meeting is being recorded
|
|
||||||
<Icon as={FaBars} ml={2} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordingTypeRequiresConsent = (
|
|
||||||
recordingType: NonNullable<Meeting["recording_type"]>,
|
|
||||||
) => {
|
|
||||||
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<HTMLElement>(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 (
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
height="100vh"
|
|
||||||
bg="gray.50"
|
|
||||||
p={4}
|
|
||||||
>
|
|
||||||
<Spinner color="blue.500" size="xl" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{roomUrl && meetingId && wherebyLoaded && (
|
|
||||||
<>
|
|
||||||
<whereby-embed
|
|
||||||
ref={wherebyRef}
|
|
||||||
room={roomUrl}
|
|
||||||
style={{ width: "100vw", height: "100vh" }}
|
|
||||||
/>
|
|
||||||
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
|
||||||
<ConsentDialogButton
|
|
||||||
meetingId={meetingId}
|
|
||||||
wherebyRef={wherebyRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^3.22.0",
|
"@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",
|
"@emotion/react": "^11.14.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
|
|||||||
@@ -163,6 +163,25 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/trace-mapping" "0.3.9"
|
"@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":
|
"@edge-runtime/format@2.2.1":
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@edge-runtime/format/-/format-2.2.1.tgz#10dcedb0d7c2063c9ee360fbab23846c8720f986"
|
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"
|
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz#9ab8f811930d7af3e3d549183a50884f9eb83f36"
|
||||||
integrity sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==
|
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":
|
"@sentry-internal/tracing@7.77.0":
|
||||||
version "7.77.0"
|
version "7.77.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.77.0.tgz#f3d82486f8934a955b3dd2aa54c8d29586e42a37"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.77.0.tgz#f3d82486f8934a955b3dd2aa54c8d29586e42a37"
|
||||||
@@ -979,6 +1028,17 @@
|
|||||||
"@sentry/types" "7.77.0"
|
"@sentry/types" "7.77.0"
|
||||||
"@sentry/utils" "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":
|
"@sentry/cli@^1.74.6":
|
||||||
version "1.75.2"
|
version "1.75.2"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.75.2.tgz#2c38647b38300e52c9839612d42b7c23f8d6455b"
|
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.75.2.tgz#2c38647b38300e52c9839612d42b7c23f8d6455b"
|
||||||
@@ -999,6 +1059,11 @@
|
|||||||
"@sentry/types" "7.77.0"
|
"@sentry/types" "7.77.0"
|
||||||
"@sentry/utils" "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":
|
"@sentry/integrations@7.77.0":
|
||||||
version "7.77.0"
|
version "7.77.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.77.0.tgz#f2717e05cb7c69363316ccd34096b2ea07ae4c59"
|
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.77.0.tgz#f2717e05cb7c69363316ccd34096b2ea07ae4c59"
|
||||||
@@ -2685,6 +2750,11 @@ bindings@^1.4.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
file-uri-to-path "1.0.0"
|
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:
|
brace-expansion@^1.1.7:
|
||||||
version "1.1.11"
|
version "1.1.11"
|
||||||
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
|
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"
|
resolved "https://registry.yarnpkg.com/events-intercept/-/events-intercept-2.0.0.tgz#adbf38681c5a4b2011c41ee41f61a34cba448897"
|
||||||
integrity sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==
|
integrity sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==
|
||||||
|
|
||||||
events@^3.3.0:
|
events@^3.1.0, events@^3.3.0:
|
||||||
version "3.3.0"
|
version "3.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
||||||
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
|
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"
|
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
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:
|
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user