mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
27 Commits
v0.24.0
...
mathieu/ji
| Author | SHA1 | Date | |
|---|---|---|---|
| e91979abbc | |||
| 95e8011975 | |||
| 293f7d4f1f | |||
| 41224a424c | |||
| dd0089906f | |||
| fa559b1970 | |||
| c26ce65083 | |||
| 52eff2acc0 | |||
| 7875ec3432 | |||
| 398be06fad | |||
| da700069d9 | |||
| 51229a1790 | |||
| 2d2c23f7cc | |||
| 0acb9cac79 | |||
| d861d92cc2 | |||
| 24ff83a2ec | |||
| 249234238c | |||
| 42a603d5c3 | |||
| 6d2092f950 | |||
| f2bb6aaecb | |||
| 2b136ac7b0 | |||
| 3f4fc26483 | |||
| 8e5ef5bca6 | |||
| d49fdcb38d | |||
| d42380abf1 | |||
| cf64e1a3d9 | |||
| ea53ca7000 |
369
docs/jitsi.md
Normal file
369
docs/jitsi.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Jitsi Integration for Reflector
|
||||
|
||||
This document contains research and planning notes for integrating Jitsi Meet as a replacement for Whereby in Reflector.
|
||||
|
||||
## Overview
|
||||
|
||||
Jitsi Meet is an open-source video conferencing solution that can replace Whereby in Reflector, providing:
|
||||
- Cost reduction (no per-minute charges)
|
||||
- Direct recording access via Jibri
|
||||
- Real-time event webhooks
|
||||
- Full customization and control
|
||||
|
||||
## Current Whereby Integration Analysis
|
||||
|
||||
### Architecture
|
||||
1. **Room Creation**: User creates a "room" template in Reflector DB with settings
|
||||
2. **Meeting Creation**: `/rooms/{room_name}/meeting` endpoint calls Whereby API to create meeting
|
||||
3. **Recording**: Whereby handles recording automatically to S3 bucket
|
||||
4. **Webhooks**: Whereby sends events for participant tracking
|
||||
|
||||
### Database Structure
|
||||
```python
|
||||
# Room = Template/Configuration
|
||||
class Room:
|
||||
id, name, user_id
|
||||
recording_type, recording_trigger # cloud, automatic-2nd-participant
|
||||
webhook_url, webhook_secret
|
||||
|
||||
# Meeting = Actual Whereby Meeting Instance
|
||||
class Meeting:
|
||||
id # Whereby meetingId
|
||||
room_name # Generated by Whereby
|
||||
room_url, host_room_url # Whereby URLs
|
||||
num_clients # Updated via webhooks
|
||||
```
|
||||
|
||||
## Jitsi Components
|
||||
|
||||
### Core Architecture
|
||||
- **Jitsi Meet**: Web frontend (Next.js + React)
|
||||
- **Prosody**: XMPP server for messaging/rooms
|
||||
- **Jicofo**: Conference focus (orchestration)
|
||||
- **JVB**: Videobridge (media routing)
|
||||
- **Jibri**: Recording service
|
||||
- **Jigasi**: SIP gateway (optional, for phone dial-in)
|
||||
|
||||
### Exposure Requirements
|
||||
- **Web service**: 443/80 (frontend)
|
||||
- **JVB**: 10000/UDP (media streams) - **MUST EXPOSE**
|
||||
- **Prosody**: 5280 (BOSH/WebSocket) - can proxy via web
|
||||
- **Jicofo, Jibri, Jigasi**: Internal only
|
||||
|
||||
## Recording with Jibri
|
||||
|
||||
### How Jibri Works
|
||||
- Each Jibri instance handles **one recording at a time**
|
||||
- Records mixed audio/video to MP4 format
|
||||
- Uses Chrome headless + ffmpeg for capture
|
||||
- Supports finalize scripts for post-processing
|
||||
|
||||
### Jibri Pool for Scaling
|
||||
- Multiple Jibri instances join "jibribrewery" MUC
|
||||
- Jicofo distributes recording requests to available instances
|
||||
- Automatic load balancing and failover
|
||||
|
||||
```yaml
|
||||
# Multiple Jibri instances
|
||||
jibri1:
|
||||
environment:
|
||||
- JIBRI_INSTANCE_ID=jibri1
|
||||
- JIBRI_BREWERY_MUC=jibribrewery
|
||||
|
||||
jibri2:
|
||||
environment:
|
||||
- JIBRI_INSTANCE_ID=jibri2
|
||||
- JIBRI_BREWERY_MUC=jibribrewery
|
||||
```
|
||||
|
||||
### Recording Automation Options
|
||||
1. **Environment Variables**: `ENABLE_RECORDING=1`, `AUTO_RECORDING=1`
|
||||
2. **URL Parameters**: `?config.autoRecord=true`
|
||||
3. **JWT Token**: Include recording permissions in JWT
|
||||
4. **API Control**: `api.executeCommand('startRecording')`
|
||||
|
||||
### Post-Processing Integration
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# finalize.sh - runs after recording completion
|
||||
RECORDING_FILE=$1
|
||||
MEETING_METADATA=$2
|
||||
ROOM_NAME=$3
|
||||
|
||||
# Copy to Reflector-accessible location
|
||||
cp "$RECORDING_FILE" /shared/reflector-uploads/
|
||||
|
||||
# Trigger Reflector processing
|
||||
curl -X POST "http://reflector-api:8000/v1/transcripts/process" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"file_path\": \"/shared/reflector-uploads/$(basename $RECORDING_FILE)\",
|
||||
\"room_name\": \"$ROOM_NAME\",
|
||||
\"source\": \"jitsi\"
|
||||
}"
|
||||
```
|
||||
|
||||
## React Integration
|
||||
|
||||
### Official React SDK
|
||||
```bash
|
||||
npm i @jitsi/react-sdk
|
||||
```
|
||||
|
||||
```jsx
|
||||
import { JitsiMeeting } from '@jitsi/react-sdk'
|
||||
|
||||
<JitsiMeeting
|
||||
room="meeting-room"
|
||||
serverURL="https://your-jitsi.domain"
|
||||
jwt="your-jwt-token"
|
||||
config={{
|
||||
startWithAudioMuted: true,
|
||||
fileRecordingsEnabled: true,
|
||||
autoRecord: true
|
||||
}}
|
||||
onParticipantJoined={(participant) => {
|
||||
// Track participant events
|
||||
}}
|
||||
onRecordingStatusChanged={(status) => {
|
||||
// Handle recording events
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Authentication & Room Control
|
||||
|
||||
### JWT-Based Access Control
|
||||
```python
|
||||
def generate_jitsi_jwt(payload):
|
||||
return jwt.encode({
|
||||
"aud": "jitsi",
|
||||
"iss": "reflector",
|
||||
"sub": "reflector-user",
|
||||
"room": payload["room"],
|
||||
"exp": int(payload["exp"].timestamp()),
|
||||
"context": {
|
||||
"user": {
|
||||
"name": payload["user_name"],
|
||||
"moderator": payload.get("moderator", False)
|
||||
},
|
||||
"features": {
|
||||
"recording": payload.get("recording", True)
|
||||
}
|
||||
}
|
||||
}, JITSI_JWT_SECRET)
|
||||
```
|
||||
|
||||
### Prevent Anonymous Room Creation
|
||||
```bash
|
||||
# Environment configuration
|
||||
ENABLE_AUTH=1
|
||||
ENABLE_GUESTS=0
|
||||
AUTH_TYPE=jwt
|
||||
JWT_APP_ID=reflector
|
||||
JWT_APP_SECRET=your-secret-key
|
||||
```
|
||||
|
||||
## Webhook Integration
|
||||
|
||||
### Real-time Events via Prosody
|
||||
Custom event-sync module can send webhooks for:
|
||||
- Participant join/leave
|
||||
- Recording start/stop
|
||||
- Room creation/destruction
|
||||
- Mute/unmute events
|
||||
|
||||
```lua
|
||||
-- mod_event_sync.lua
|
||||
module:hook("muc-occupant-joined", function(event)
|
||||
send_event({
|
||||
type = "participant_joined",
|
||||
room = event.room.jid,
|
||||
participant = {
|
||||
nick = event.occupant.nick,
|
||||
jid = event.occupant.jid,
|
||||
},
|
||||
timestamp = os.time(),
|
||||
});
|
||||
end);
|
||||
```
|
||||
|
||||
### Jibri Recording Webhooks
|
||||
```bash
|
||||
# Environment variable
|
||||
JIBRI_WEBHOOK_SUBSCRIBERS=https://your-reflector.com/webhooks/jibri
|
||||
```
|
||||
|
||||
## Proposed Reflector Integration
|
||||
|
||||
### Modified Database Schema
|
||||
```python
|
||||
class Meeting(BaseModel):
|
||||
id: str # Our generated meeting ID
|
||||
room_name: str # Generated: reflector-{room.name}-{timestamp}
|
||||
room_url: str # https://jitsi.domain/room_name?jwt=token
|
||||
host_room_url: str # Same but with moderator JWT
|
||||
# Add Jitsi-specific fields
|
||||
jitsi_jwt: str # JWT token
|
||||
jitsi_room_id: str # Internal room identifier
|
||||
recording_status: str # pending, recording, completed
|
||||
recording_file_path: Optional[str]
|
||||
```
|
||||
|
||||
### API Replacement
|
||||
```python
|
||||
# Replace whereby.py with jitsi.py
|
||||
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
|
||||
# Generate unique room name
|
||||
jitsi_room = f"reflector-{room.name}-{int(time.time())}"
|
||||
|
||||
# Generate JWT tokens
|
||||
user_jwt = generate_jwt(room=jitsi_room, moderator=False, exp=end_date)
|
||||
host_jwt = generate_jwt(room=jitsi_room, moderator=True, exp=end_date)
|
||||
|
||||
return {
|
||||
"meetingId": generate_uuid4(), # Our ID
|
||||
"roomName": jitsi_room,
|
||||
"roomUrl": f"https://jitsi.domain/{jitsi_room}?jwt={user_jwt}",
|
||||
"hostRoomUrl": f"https://jitsi.domain/{jitsi_room}?jwt={host_jwt}",
|
||||
"startDate": datetime.now().isoformat(),
|
||||
"endDate": end_date.isoformat(),
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Endpoints
|
||||
```python
|
||||
# Replace whereby webhook with jitsi webhooks
|
||||
@router.post("/jitsi/events")
|
||||
async def jitsi_events_webhook(event_data: dict):
|
||||
event_type = event_data.get("event")
|
||||
room_name = event_data.get("room", "").split("@")[0]
|
||||
|
||||
meeting = await Meeting.get_by_room(room_name)
|
||||
|
||||
if event_type == "muc-occupant-joined":
|
||||
# Update participant count
|
||||
meeting.num_clients += 1
|
||||
|
||||
elif event_type == "jibri-recording-on":
|
||||
meeting.recording_status = "recording"
|
||||
|
||||
elif event_type == "jibri-recording-off":
|
||||
meeting.recording_status = "processing"
|
||||
await process_meeting_recording.delay(meeting.id)
|
||||
|
||||
@router.post("/jibri/recording-complete")
|
||||
async def recording_complete(data: dict):
|
||||
# Handle finalize script webhook
|
||||
room_name = data.get("room_name")
|
||||
file_path = data.get("file_path")
|
||||
|
||||
meeting = await Meeting.get_by_room(room_name)
|
||||
meeting.recording_file_path = file_path
|
||||
meeting.recording_status = "completed"
|
||||
|
||||
# Start Reflector processing
|
||||
await process_recording_for_transcription(meeting.id, file_path)
|
||||
```
|
||||
|
||||
## Deployment with Docker
|
||||
|
||||
### Official docker-jitsi-meet
|
||||
```bash
|
||||
# Download official release
|
||||
wget $(wget -q -O - https://api.github.com/repos/jitsi/docker-jitsi-meet/releases/latest | grep zip | cut -d\" -f4)
|
||||
|
||||
# Setup
|
||||
mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri}
|
||||
./gen-passwords.sh # Generate secure passwords
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Coolify Integration
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
ports: ["80:80", "443:443"]
|
||||
jvb:
|
||||
ports: ["10000:10000/udp"] # Must expose for media
|
||||
jibri1:
|
||||
environment:
|
||||
- JIBRI_INSTANCE_ID=jibri1
|
||||
- JIBRI_FINALIZE_RECORDING_SCRIPT_PATH=/config/finalize.sh
|
||||
jibri2:
|
||||
environment:
|
||||
- JIBRI_INSTANCE_ID=jibri2
|
||||
```
|
||||
|
||||
## Benefits vs Whereby
|
||||
|
||||
### Cost & Control
|
||||
✅ **No per-minute charges** - significant cost savings
|
||||
✅ **Full recording control** - direct file access
|
||||
✅ **Custom branding** - complete UI control
|
||||
✅ **Self-hosted** - no vendor lock-in
|
||||
|
||||
### Technical Advantages
|
||||
✅ **Real-time events** - immediate webhook notifications
|
||||
✅ **Rich participant metadata** - detailed tracking
|
||||
✅ **JWT security** - token-based access with expiration
|
||||
✅ **Multiple recording formats** - audio-only options
|
||||
✅ **Scalable architecture** - horizontal Jibri scaling
|
||||
|
||||
### Integration Benefits
|
||||
✅ **Same API surface** - minimal changes to existing code
|
||||
✅ **React SDK** - better frontend integration
|
||||
✅ **Direct processing** - no S3 download delays
|
||||
✅ **Event-driven architecture** - better real-time capabilities
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. **Deploy Jitsi Stack** - Set up docker-jitsi-meet with multiple Jibri instances
|
||||
2. **Create jitsi.py** - Replace whereby.py with Jitsi API functions
|
||||
3. **Update Database** - Add Jitsi-specific fields to Meeting model
|
||||
4. **Webhook Integration** - Replace Whereby webhooks with Jitsi events
|
||||
5. **Frontend Updates** - Replace Whereby embed with Jitsi React SDK
|
||||
6. **Testing & Migration** - Gradual rollout with fallback to Whereby
|
||||
|
||||
## Recording Limitations & Considerations
|
||||
|
||||
### Current Limitations
|
||||
- **Mixed audio only** - Jibri doesn't separate participant tracks natively
|
||||
- **One recording per Jibri** - requires multiple instances for concurrent recordings
|
||||
- **Chrome dependency** - Jibri uses headless Chrome for recording
|
||||
|
||||
### Metadata Capabilities
|
||||
✅ **Participant join/leave timestamps** - via webhooks
|
||||
✅ **Speaking time tracking** - via audio level events
|
||||
✅ **Meeting duration** - precise timing
|
||||
✅ **Room-specific data** - custom metadata in JWT
|
||||
|
||||
### Alternative Recording Methods
|
||||
- **Local recording** - browser-based, per-participant
|
||||
- **Custom recording** - lib-jitsi-meet for individual streams
|
||||
- **Third-party solutions** - Recall.ai, Otter.ai integrations
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### JWT Configuration
|
||||
- **Room-specific tokens** - limit access to specific rooms
|
||||
- **Time-based expiration** - automatic cleanup
|
||||
- **Feature permissions** - control recording, moderation rights
|
||||
- **User identification** - embed user metadata in tokens
|
||||
|
||||
### Access Control
|
||||
- **No anonymous rooms** - all rooms require valid JWT
|
||||
- **API-only creation** - prevent direct room access
|
||||
- **Webhook verification** - HMAC signature validation
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Deploy test Jitsi instance** - validate recording pipeline
|
||||
2. **Prototype jitsi.py** - create equivalent API functions
|
||||
3. **Test webhook integration** - ensure event delivery works
|
||||
4. **Performance testing** - validate multiple concurrent recordings
|
||||
5. **Migration strategy** - plan gradual transition from Whereby
|
||||
|
||||
---
|
||||
|
||||
*This document serves as the comprehensive planning and research notes for Jitsi integration in Reflector. It should be updated as implementation progresses and new insights are discovered.*
|
||||
720
docs/video-jitsi.md
Normal file
720
docs/video-jitsi.md
Normal file
@@ -0,0 +1,720 @@
|
||||
# Jitsi Meet Integration Configuration Guide
|
||||
|
||||
This guide explains how to configure Reflector to use your self-hosted Jitsi Meet installation for video meetings, recording, and participant tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
Jitsi Meet is an open-source video conferencing platform that can be self-hosted. Reflector integrates with Jitsi Meet to:
|
||||
|
||||
- Create secure meeting rooms with JWT authentication
|
||||
- Track participant join/leave events via Prosody webhooks
|
||||
- Record meetings using Jibri recording service
|
||||
- Process recordings for transcription and analysis
|
||||
|
||||
## Requirements
|
||||
|
||||
### Self-Hosted Jitsi Meet
|
||||
|
||||
You need a complete Jitsi Meet installation including:
|
||||
|
||||
1. **Jitsi Meet Web Interface** - The main meeting interface
|
||||
2. **Prosody XMPP Server** - Handles room management and authentication
|
||||
3. **Jicofo (JItsi COnference FOcus)** - Manages media sessions
|
||||
4. **Jitsi Videobridge (JVB)** - Handles WebRTC media routing
|
||||
5. **Jibri Recording Service** - Records meetings (optional but recommended)
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Domain with SSL Certificate** - Required for WebRTC functionality
|
||||
- **Prosody mod_event_sync** - For webhook event handling
|
||||
- **JWT Authentication** - For secure room access control
|
||||
- **Storage Solution** - For recording files (local or cloud)
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
Add the following environment variables to your Reflector `.env` file:
|
||||
|
||||
### Required Variables
|
||||
|
||||
```bash
|
||||
# Jitsi Meet Domain (without https://)
|
||||
JITSI_DOMAIN=meet.example.com
|
||||
|
||||
# JWT Secret for room authentication (generate with: openssl rand -hex 32)
|
||||
JITSI_JWT_SECRET=your-64-character-hex-secret-here
|
||||
|
||||
# Webhook secret for event handling (generate with: openssl rand -hex 16)
|
||||
JITSI_WEBHOOK_SECRET=your-32-character-hex-secret-here
|
||||
```
|
||||
|
||||
### Optional Variables
|
||||
|
||||
```bash
|
||||
# Application identifier (should match Jitsi configuration)
|
||||
JITSI_APP_ID=reflector
|
||||
|
||||
# JWT issuer and audience (should match Jitsi configuration)
|
||||
JITSI_JWT_ISSUER=reflector
|
||||
JITSI_JWT_AUDIENCE=jitsi
|
||||
```
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Jitsi Meet Server Installation
|
||||
|
||||
#### Quick Installation (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
# Add Jitsi repository
|
||||
curl -fsSL https://download.jitsi.org/jitsi-key.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/jitsi-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/jitsi-keyring.gpg] https://download.jitsi.org stable/" | sudo tee /etc/apt/sources.list.d/jitsi-stable.list
|
||||
|
||||
# Install Jitsi Meet
|
||||
sudo apt update
|
||||
sudo apt install jitsi-meet
|
||||
|
||||
# Configure SSL certificate
|
||||
sudo /usr/share/jitsi-meet/scripts/install-letsencrypt-cert.sh
|
||||
```
|
||||
|
||||
#### Docker Installation
|
||||
|
||||
```bash
|
||||
# Clone Jitsi Docker repository
|
||||
git clone https://github.com/jitsi/docker-jitsi-meet
|
||||
cd docker-jitsi-meet
|
||||
|
||||
# Copy environment template
|
||||
cp env.example .env
|
||||
|
||||
# Edit configuration
|
||||
nano .env
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. JWT Authentication Setup
|
||||
|
||||
#### Update Prosody Configuration
|
||||
|
||||
Edit `/etc/prosody/conf.d/your-domain.cfg.lua`:
|
||||
|
||||
```lua
|
||||
VirtualHost "meet.example.com"
|
||||
authentication = "token"
|
||||
app_id = "reflector"
|
||||
app_secret = "your-jwt-secret-here"
|
||||
|
||||
-- Allow anonymous access for guests
|
||||
c2s_require_encryption = false
|
||||
admins = { "focusUser@auth.meet.example.com" }
|
||||
|
||||
modules_enabled = {
|
||||
"bosh";
|
||||
"pubsub";
|
||||
"ping";
|
||||
"roster";
|
||||
"saslauth";
|
||||
"tls";
|
||||
"dialback";
|
||||
"disco";
|
||||
"carbons";
|
||||
"pep";
|
||||
"private";
|
||||
"blocklist";
|
||||
"vcard";
|
||||
"version";
|
||||
"uptime";
|
||||
"time";
|
||||
"ping";
|
||||
"register";
|
||||
"admin_adhoc";
|
||||
"token_verification";
|
||||
"event_sync"; -- Required for webhooks
|
||||
}
|
||||
```
|
||||
|
||||
#### Configure Jitsi Meet Interface
|
||||
|
||||
Edit `/etc/jitsi/meet/your-domain-config.js`:
|
||||
|
||||
```javascript
|
||||
var config = {
|
||||
hosts: {
|
||||
domain: 'meet.example.com',
|
||||
muc: 'conference.meet.example.com'
|
||||
},
|
||||
|
||||
// Enable JWT authentication
|
||||
enableUserRolesBasedOnToken: true,
|
||||
|
||||
// Recording configuration
|
||||
fileRecordingsEnabled: true,
|
||||
liveStreamingEnabled: false,
|
||||
|
||||
// Reflector integration settings
|
||||
prejoinPageEnabled: true,
|
||||
requireDisplayName: true
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Webhook Event Configuration
|
||||
|
||||
#### Install Event Sync Module
|
||||
|
||||
```bash
|
||||
# Download the module
|
||||
cd /usr/share/jitsi-meet/prosody-plugins/
|
||||
wget https://raw.githubusercontent.com/jitsi-contrib/prosody-plugins/main/mod_event_sync.lua
|
||||
```
|
||||
|
||||
#### Configure Event Sync
|
||||
|
||||
Add to your Prosody configuration:
|
||||
|
||||
```lua
|
||||
Component "conference.meet.example.com" "muc"
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"muc_meeting_id";
|
||||
"muc_domain_mapper";
|
||||
"polls";
|
||||
"event_sync"; -- Enable event sync
|
||||
}
|
||||
|
||||
-- Event sync webhook configuration
|
||||
event_sync_url = "https://your-reflector-domain.com/v1/jitsi/events"
|
||||
event_sync_secret = "your-webhook-secret-here"
|
||||
|
||||
-- Events to track
|
||||
event_sync_events = {
|
||||
"muc-occupant-joined",
|
||||
"muc-occupant-left",
|
||||
"jibri-recording-on",
|
||||
"jibri-recording-off"
|
||||
}
|
||||
|
||||
#### Webhook Event Payload Examples
|
||||
|
||||
**Participant Joined Event:**
|
||||
```json
|
||||
{
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "reflector-my-room-uuid123",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {
|
||||
"occupant_id": "participant-456",
|
||||
"nick": "John Doe",
|
||||
"role": "participant",
|
||||
"affiliation": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recording Started Event:**
|
||||
```json
|
||||
{
|
||||
"event": "jibri-recording-on",
|
||||
"room": "reflector-my-room-uuid123",
|
||||
"timestamp": "2025-01-15T10:32:00.000Z",
|
||||
"data": {
|
||||
"recording_id": "rec-789",
|
||||
"initiator": "moderator-123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recording Completed Event:**
|
||||
```json
|
||||
{
|
||||
"room_name": "reflector-my-room-uuid123",
|
||||
"recording_file": "/var/recordings/rec-789.mp4",
|
||||
"recording_status": "completed",
|
||||
"timestamp": "2025-01-15T11:15:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Jibri Recording Setup (Optional)
|
||||
|
||||
#### Install Jibri
|
||||
|
||||
```bash
|
||||
# Install Jibri package
|
||||
sudo apt install jibri
|
||||
|
||||
# Create recording directory
|
||||
sudo mkdir -p /var/recordings
|
||||
sudo chown jibri:jibri /var/recordings
|
||||
```
|
||||
|
||||
#### Configure Jibri
|
||||
|
||||
Edit `/etc/jitsi/jibri/jibri.conf`:
|
||||
|
||||
```hocon
|
||||
jibri {
|
||||
recording {
|
||||
recordings-directory = "/var/recordings"
|
||||
finalize-script = "/opt/jitsi/jibri/finalize.sh"
|
||||
}
|
||||
|
||||
api {
|
||||
xmpp {
|
||||
environments = [{
|
||||
name = "prod environment"
|
||||
xmpp-server-hosts = ["meet.example.com"]
|
||||
xmpp-domain = "meet.example.com"
|
||||
|
||||
control-muc {
|
||||
domain = "internal.auth.meet.example.com"
|
||||
room-name = "JibriBrewery"
|
||||
nickname = "jibri-nickname"
|
||||
}
|
||||
|
||||
control-login {
|
||||
domain = "auth.meet.example.com"
|
||||
username = "jibri"
|
||||
password = "jibri-password"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Create Finalize Script
|
||||
|
||||
Create `/opt/jitsi/jibri/finalize.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Jibri finalize script for Reflector integration
|
||||
|
||||
RECORDING_FILE="$1"
|
||||
ROOM_NAME="$2"
|
||||
REFLECTOR_API_URL="${REFLECTOR_API_URL:-http://localhost:1250}"
|
||||
|
||||
# Prepare webhook payload
|
||||
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"room_name": "$ROOM_NAME",
|
||||
"recording_file": "$RECORDING_FILE",
|
||||
"recording_status": "completed",
|
||||
"timestamp": "$TIMESTAMP"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Generate signature
|
||||
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$JITSI_WEBHOOK_SECRET" | cut -d' ' -f2)
|
||||
|
||||
# Send webhook to Reflector
|
||||
curl -X POST "$REFLECTOR_API_URL/v1/jibri/recording-complete" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: $SIGNATURE" \
|
||||
-d "$PAYLOAD"
|
||||
|
||||
echo "Recording finalization webhook sent for room: $ROOM_NAME"
|
||||
```
|
||||
|
||||
Make executable:
|
||||
```bash
|
||||
sudo chmod +x /opt/jitsi/jibri/finalize.sh
|
||||
```
|
||||
|
||||
### 5. Restart Services
|
||||
|
||||
After configuration changes:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart prosody
|
||||
sudo systemctl restart jicofo
|
||||
sudo systemctl restart jitsi-videobridge2
|
||||
sudo systemctl restart jibri
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
## Room Configuration
|
||||
|
||||
### Creating Jitsi Rooms
|
||||
|
||||
Create rooms with Jitsi platform in Reflector:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{
|
||||
"name": "my-jitsi-room",
|
||||
"platform": "jitsi",
|
||||
"recording_type": "cloud",
|
||||
"recording_trigger": "automatic-2nd-participant",
|
||||
"is_locked": false,
|
||||
"room_mode": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
### Meeting Creation
|
||||
|
||||
Meetings automatically use JWT authentication:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms/my-jitsi-room/meeting" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN"
|
||||
```
|
||||
|
||||
Response includes JWT-authenticated URLs:
|
||||
```json
|
||||
{
|
||||
"id": "meeting-uuid",
|
||||
"room_name": "reflector-my-jitsi-room-123456",
|
||||
"room_url": "https://meet.example.com/room?jwt=user-token",
|
||||
"host_room_url": "https://meet.example.com/room?jwt=moderator-token"
|
||||
}
|
||||
```
|
||||
|
||||
## Features and Capabilities
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
Reflector automatically generates JWT tokens with:
|
||||
- **Room Access Control** - Secure room entry
|
||||
- **User Roles** - Moderator vs participant permissions
|
||||
- **Expiration** - Configurable token lifetime (default 8 hours)
|
||||
- **Custom Claims** - Room-specific metadata
|
||||
|
||||
### Recording Options
|
||||
|
||||
**Recording Types:**
|
||||
- `"none"` - No recording
|
||||
- `"local"` - Local Jibri recording
|
||||
- `"cloud"` - Cloud recording (requires external storage)
|
||||
|
||||
**Recording Triggers:**
|
||||
- `"none"` - Manual recording only
|
||||
- `"prompt"` - Prompt users to start
|
||||
- `"automatic"` - Start immediately
|
||||
- `"automatic-2nd-participant"` - Start when 2nd person joins
|
||||
|
||||
### Event Tracking and Storage
|
||||
|
||||
Reflector automatically stores all webhook events in the `meetings` table for comprehensive meeting analytics:
|
||||
|
||||
**Supported Event Types:**
|
||||
- `muc-occupant-joined` - Participant joined the meeting
|
||||
- `muc-occupant-left` - Participant left the meeting
|
||||
- `jibri-recording-on` - Recording started
|
||||
- `jibri-recording-off` - Recording stopped
|
||||
- `recording_completed` - Recording file ready for processing
|
||||
|
||||
**Event Storage Structure:**
|
||||
Each webhook event is stored as a JSON object in the `meetings.events` column:
|
||||
```json
|
||||
{
|
||||
"type": "muc-occupant-joined",
|
||||
"timestamp": "2025-01-15T10:30:00.123456Z",
|
||||
"data": {
|
||||
"timestamp": "2025-01-15T10:30:00Z",
|
||||
"user_id": "participant-123",
|
||||
"display_name": "John Doe"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Querying Stored Events:**
|
||||
```sql
|
||||
-- Get all events for a meeting
|
||||
SELECT events FROM meeting WHERE id = 'meeting-uuid';
|
||||
|
||||
-- Count participant joins
|
||||
SELECT json_array_length(
|
||||
json_extract(events, '$[*] ? (@.type == "muc-occupant-joined")')
|
||||
) as total_joins FROM meeting WHERE id = 'meeting-uuid';
|
||||
```
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
### Health Check
|
||||
|
||||
Test Jitsi webhook integration:
|
||||
|
||||
```bash
|
||||
curl "https://your-reflector-domain.com/v1/jitsi/health"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "jitsi-webhooks",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"webhook_secret_configured": true
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Token Testing
|
||||
|
||||
Verify JWT generation works:
|
||||
```bash
|
||||
# Create a test meeting
|
||||
MEETING=$(curl -X POST "https://your-reflector-domain.com/v1/rooms/test-room/meeting" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" | jq -r '.room_url')
|
||||
|
||||
echo "Test meeting URL: $MEETING"
|
||||
```
|
||||
|
||||
### Webhook Testing
|
||||
|
||||
#### Manual Webhook Event Testing
|
||||
|
||||
Test participant join event:
|
||||
```bash
|
||||
# Generate proper signature
|
||||
PAYLOAD='{"event":"muc-occupant-joined","room":"reflector-test-room-uuid","timestamp":"2025-01-15T10:30:00.000Z","data":{"user_id":"test-user","display_name":"Test User"}}'
|
||||
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$JITSI_WEBHOOK_SECRET" | cut -d' ' -f2)
|
||||
|
||||
curl -X POST "https://your-reflector-domain.com/v1/jitsi/events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: $SIGNATURE" \
|
||||
-d "$PAYLOAD"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "reflector-test-room-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
#### Recording Webhook Testing
|
||||
|
||||
Test recording completion event:
|
||||
```bash
|
||||
PAYLOAD='{"room_name":"reflector-test-room-uuid","recording_file":"/recordings/test.mp4","recording_status":"completed","timestamp":"2025-01-15T10:30:00.000Z"}'
|
||||
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$JITSI_WEBHOOK_SECRET" | cut -d' ' -f2)
|
||||
|
||||
curl -X POST "https://your-reflector-domain.com/v1/jibri/recording-complete" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: $SIGNATURE" \
|
||||
-d "$PAYLOAD"
|
||||
```
|
||||
|
||||
#### Event Storage Verification
|
||||
|
||||
Verify events were stored:
|
||||
```bash
|
||||
# Check meeting events via API (requires authentication)
|
||||
curl -H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
"https://your-reflector-domain.com/v1/meetings/{meeting-id}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### JWT Authentication Failures
|
||||
|
||||
**Symptoms**: Users cannot join rooms, "Authentication failed" errors
|
||||
|
||||
**Solutions**:
|
||||
1. Verify `JITSI_JWT_SECRET` matches Prosody configuration
|
||||
2. Check JWT token hasn't expired (default 8 hours)
|
||||
3. Ensure system clocks are synchronized between servers
|
||||
4. Validate JWT issuer/audience configuration matches
|
||||
|
||||
**Debug JWT tokens**:
|
||||
```bash
|
||||
# Decode JWT payload
|
||||
echo "JWT_TOKEN_HERE" | cut -d'.' -f2 | base64 -d | jq
|
||||
```
|
||||
|
||||
#### Webhook Events Not Received
|
||||
|
||||
**Symptoms**: Participant counts not updating, no recording events
|
||||
|
||||
**Solutions**:
|
||||
1. Verify `mod_event_sync` is loaded in Prosody
|
||||
2. Check webhook URL is accessible from Jitsi server
|
||||
3. Validate webhook signature generation
|
||||
4. Review Prosody and Reflector logs
|
||||
|
||||
**Debug webhook connectivity**:
|
||||
```bash
|
||||
# Test from Jitsi server
|
||||
curl -v "https://your-reflector-domain.com/v1/jitsi/health"
|
||||
|
||||
# Check Prosody logs
|
||||
sudo tail -f /var/log/prosody/prosody.log
|
||||
```
|
||||
|
||||
#### Webhook Signature Verification Issues
|
||||
|
||||
**Symptoms**: HTTP 401 "Invalid webhook signature" errors
|
||||
|
||||
**Solutions**:
|
||||
1. Verify webhook secret matches between Jitsi and Reflector
|
||||
2. Check payload encoding (no extra whitespace)
|
||||
3. Ensure proper HMAC-SHA256 signature generation
|
||||
|
||||
**Debug signature generation**:
|
||||
```bash
|
||||
# Test signature manually
|
||||
PAYLOAD='{"event":"test","room":"test","timestamp":"2025-01-15T10:30:00.000Z","data":{}}'
|
||||
SECRET="your-webhook-secret-here"
|
||||
|
||||
# Generate signature (should match X-Jitsi-Signature header)
|
||||
echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2
|
||||
|
||||
# Test with curl
|
||||
curl -X POST "https://your-reflector-domain.com/v1/jitsi/events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: $(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)" \
|
||||
-d "$PAYLOAD" -v
|
||||
```
|
||||
|
||||
#### Event Storage Problems
|
||||
|
||||
**Symptoms**: Events received but not stored in database
|
||||
|
||||
**Solutions**:
|
||||
1. Check database connectivity and permissions
|
||||
2. Verify meeting exists before event processing
|
||||
3. Review Reflector application logs
|
||||
4. Ensure JSON column support in database
|
||||
|
||||
**Debug event storage**:
|
||||
```bash
|
||||
# Check meeting exists
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://your-reflector-domain.com/v1/meetings/{meeting-id}"
|
||||
|
||||
# Monitor database queries (if using PostgreSQL)
|
||||
sudo -u postgres psql -c "SELECT * FROM pg_stat_activity WHERE query LIKE '%meeting%';"
|
||||
|
||||
# Check Reflector logs for event processing
|
||||
sudo journalctl -u reflector -f | grep -E "(event|webhook|jitsi)"
|
||||
```
|
||||
|
||||
#### Recording Issues
|
||||
|
||||
**Symptoms**: Recordings not starting, finalize script errors
|
||||
|
||||
**Solutions**:
|
||||
1. Verify Jibri service status: `sudo systemctl status jibri`
|
||||
2. Check recording directory permissions: `/var/recordings`
|
||||
3. Validate finalize script execution permissions
|
||||
4. Monitor Jibri logs: `sudo journalctl -u jibri -f`
|
||||
|
||||
**Test finalize script**:
|
||||
```bash
|
||||
sudo -u jibri /opt/jitsi/jibri/finalize.sh "/test/recording.mp4" "test-room"
|
||||
```
|
||||
|
||||
#### Meeting Creation Failures
|
||||
|
||||
**Symptoms**: HTTP 500 errors when creating meetings
|
||||
|
||||
**Solutions**:
|
||||
1. Check Reflector logs for JWT generation errors
|
||||
2. Verify all required environment variables are set
|
||||
3. Ensure Jitsi domain is accessible from Reflector
|
||||
4. Test JWT secret configuration
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Verify Prosody configuration
|
||||
sudo prosodyctl check config
|
||||
|
||||
# Check Jitsi services status
|
||||
sudo systemctl status prosody jicofo jitsi-videobridge2
|
||||
|
||||
# Test JWT generation
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms/test/meeting" \
|
||||
-H "Authorization: Bearer $TOKEN" -v
|
||||
|
||||
# Monitor webhook events
|
||||
sudo tail -f /var/log/reflector/app.log | grep jitsi
|
||||
|
||||
# Check SSL certificates
|
||||
sudo certbot certificates
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
#### Scaling Considerations
|
||||
|
||||
**Single Server Limits:**
|
||||
- ~50 concurrent participants per JVB instance
|
||||
- ~10 concurrent Jibri recordings
|
||||
- CPU and bandwidth become bottlenecks
|
||||
|
||||
**Multi-Server Setup:**
|
||||
- Multiple JVB instances for scaling
|
||||
- Dedicated Jibri recording servers
|
||||
- Load balancing for high availability
|
||||
|
||||
#### Resource Monitoring
|
||||
|
||||
```bash
|
||||
# Monitor JVB performance
|
||||
sudo systemctl status jitsi-videobridge2
|
||||
sudo journalctl -u jitsi-videobridge2 -f
|
||||
|
||||
# Check Prosody connections
|
||||
sudo prosodyctl mod_admin_telnet
|
||||
> c2s:show()
|
||||
> muc:rooms()
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### JWT Security
|
||||
- Use strong, unique secrets (32+ characters)
|
||||
- Rotate JWT secrets regularly
|
||||
- Implement proper token expiration
|
||||
- Never log or expose JWT tokens
|
||||
|
||||
### Network Security
|
||||
- Use HTTPS/WSS for all communications
|
||||
- Implement proper firewall rules
|
||||
- Consider VPN for server-to-server communication
|
||||
- Monitor for unauthorized access attempts
|
||||
|
||||
### Recording Security
|
||||
- Encrypt recordings at rest
|
||||
- Implement access controls for recording files
|
||||
- Regular security audits of file permissions
|
||||
- Comply with data protection regulations
|
||||
|
||||
## Migration from Whereby
|
||||
|
||||
If migrating from Whereby to Jitsi:
|
||||
|
||||
1. **Parallel Setup** - Configure Jitsi alongside existing Whereby
|
||||
2. **Room Migration** - Update room platform field to "jitsi"
|
||||
3. **Test Integration** - Verify meeting creation and webhooks
|
||||
4. **User Training** - Different UI and feature set
|
||||
5. **Monitor Performance** - Watch for issues during transition
|
||||
6. **Cleanup** - Remove Whereby configuration when stable
|
||||
|
||||
## Support and Resources
|
||||
|
||||
### Jitsi Community Resources
|
||||
- **Documentation**: [jitsi.github.io/handbook](https://jitsi.github.io/handbook/)
|
||||
- **Community Forum**: [community.jitsi.org](https://community.jitsi.org/)
|
||||
- **GitHub Issues**: [github.com/jitsi/jitsi-meet](https://github.com/jitsi/jitsi-meet)
|
||||
|
||||
### Professional Support
|
||||
- **8x8 Commercial Support** - Professional Jitsi hosting and support
|
||||
- **Community Consulting** - Third-party Jitsi implementation services
|
||||
|
||||
### Monitoring and Maintenance
|
||||
- Monitor system resources (CPU, memory, bandwidth)
|
||||
- Regular security updates for all components
|
||||
- Backup configuration files and certificates
|
||||
- Test disaster recovery procedures
|
||||
276
docs/video-whereby.md
Normal file
276
docs/video-whereby.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Whereby Integration Configuration Guide
|
||||
|
||||
This guide explains how to configure Reflector to use Whereby as your video meeting platform for room creation, recording, and participant tracking.
|
||||
|
||||
## Overview
|
||||
|
||||
Whereby is a browser-based video meeting platform that provides hosted meeting rooms with recording capabilities. Reflector integrates with Whereby's API to:
|
||||
|
||||
- Create secure meeting rooms with custom branding
|
||||
- Handle participant join/leave events via webhooks
|
||||
- Automatically record meetings to AWS S3 storage
|
||||
- Track meeting sessions and participant counts
|
||||
|
||||
## Requirements
|
||||
|
||||
### Whereby Account Setup
|
||||
|
||||
1. **Whereby Account**: Sign up for a Whereby business account at [whereby.com](https://whereby.com/business)
|
||||
2. **API Access**: Request API access from Whereby support (required for programmatic room creation)
|
||||
3. **Webhook Configuration**: Configure webhooks in your Whereby dashboard to point to your Reflector instance
|
||||
|
||||
### AWS S3 Storage
|
||||
|
||||
Whereby requires AWS S3 for recording storage. You need:
|
||||
- AWS account with S3 access
|
||||
- Dedicated S3 bucket for Whereby recordings
|
||||
- AWS IAM credentials with S3 write permissions
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
Add the following environment variables to your Reflector `.env` file:
|
||||
|
||||
### Required Variables
|
||||
|
||||
```bash
|
||||
# Whereby API Configuration
|
||||
WHEREBY_API_KEY=your-whereby-jwt-api-key
|
||||
WHEREBY_WEBHOOK_SECRET=your-webhook-secret-from-whereby
|
||||
|
||||
# AWS S3 Storage for Recordings
|
||||
AWS_WHEREBY_ACCESS_KEY_ID=your-aws-access-key
|
||||
AWS_WHEREBY_ACCESS_KEY_SECRET=your-aws-secret-key
|
||||
RECORDING_STORAGE_AWS_BUCKET_NAME=your-s3-bucket-name
|
||||
```
|
||||
|
||||
### Optional Variables
|
||||
|
||||
```bash
|
||||
# Whereby API URL (defaults to production)
|
||||
WHEREBY_API_URL=https://api.whereby.dev/v1
|
||||
|
||||
# SQS Configuration (for recording processing)
|
||||
AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.region.amazonaws.com/account/queue
|
||||
SQS_POLLING_TIMEOUT_SECONDS=60
|
||||
```
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### 1. Whereby API Key Setup
|
||||
|
||||
1. **Contact Whereby Support** to request API access for your account
|
||||
2. **Generate JWT Token** in your Whereby dashboard under API settings
|
||||
3. **Copy the JWT token** and set it as `WHEREBY_API_KEY` in your environment
|
||||
|
||||
The API key is a JWT token that looks like:
|
||||
```
|
||||
eyJ[...truncated JWT token...]
|
||||
```
|
||||
|
||||
### 2. Webhook Configuration
|
||||
|
||||
1. **Access Whereby Dashboard** and navigate to webhook settings
|
||||
2. **Set Webhook URL** to your Reflector instance:
|
||||
```
|
||||
https://your-reflector-domain.com/v1/whereby
|
||||
```
|
||||
3. **Configure Events** to send the following event types:
|
||||
- `room.client.joined` - When participants join
|
||||
- `room.client.left` - When participants leave
|
||||
4. **Generate Webhook Secret** and set it as `WHEREBY_WEBHOOK_SECRET`
|
||||
5. **Save Configuration** in your Whereby dashboard
|
||||
|
||||
### 3. AWS S3 Storage Setup
|
||||
|
||||
1. **Create S3 Bucket** dedicated for Whereby recordings
|
||||
2. **Create IAM User** with programmatic access
|
||||
3. **Attach S3 Policy** with the following permissions:
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectAcl",
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": "arn:aws:s3:::your-bucket-name/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
4. **Configure Environment Variables** with the IAM credentials
|
||||
|
||||
### 4. Room Configuration
|
||||
|
||||
When creating rooms in Reflector, set the platform to use Whereby:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{
|
||||
"name": "my-whereby-room",
|
||||
"platform": "whereby",
|
||||
"recording_type": "cloud",
|
||||
"recording_trigger": "automatic-2nd-participant",
|
||||
"is_locked": false,
|
||||
"room_mode": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
## Meeting Features
|
||||
|
||||
### Recording Options
|
||||
|
||||
Whereby supports three recording types:
|
||||
- **`none`**: No recording
|
||||
- **`local`**: Local recording (not recommended for production)
|
||||
- **`cloud`**: Cloud recording to S3 (recommended)
|
||||
|
||||
### Recording Triggers
|
||||
|
||||
Control when recordings start:
|
||||
- **`none`**: No automatic recording
|
||||
- **`prompt`**: Prompt users to start recording
|
||||
- **`automatic`**: Start immediately when meeting begins
|
||||
- **`automatic-2nd-participant`**: Start when second participant joins
|
||||
|
||||
### Room Modes
|
||||
|
||||
- **`normal`**: Standard meeting room
|
||||
- **`group`**: Group meeting with advanced features
|
||||
|
||||
## Webhook Event Handling
|
||||
|
||||
Reflector automatically handles these Whereby webhook events:
|
||||
|
||||
### Participant Tracking
|
||||
```json
|
||||
{
|
||||
"type": "room.client.joined",
|
||||
"data": {
|
||||
"meetingId": "room-uuid",
|
||||
"numClients": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recording Events
|
||||
Whereby sends recording completion events that trigger Reflector's processing pipeline:
|
||||
- Audio transcription
|
||||
- Speaker diarization
|
||||
- Summary generation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### API Authentication Errors
|
||||
**Symptoms**: 401 Unauthorized errors when creating meetings
|
||||
|
||||
**Solutions**:
|
||||
1. Verify your `WHEREBY_API_KEY` is correct and not expired
|
||||
2. Ensure you have API access enabled on your Whereby account
|
||||
3. Contact Whereby support if API access is not available
|
||||
|
||||
#### Webhook Signature Validation Failed
|
||||
**Symptoms**: Webhook events rejected with 401 errors
|
||||
|
||||
**Solutions**:
|
||||
1. Verify `WHEREBY_WEBHOOK_SECRET` matches your Whereby dashboard configuration
|
||||
2. Check webhook URL is correctly configured in Whereby dashboard
|
||||
3. Ensure webhook endpoint is accessible from Whereby servers
|
||||
|
||||
#### Recording Upload Failures
|
||||
**Symptoms**: Recordings not appearing in S3 bucket
|
||||
|
||||
**Solutions**:
|
||||
1. Verify AWS credentials have S3 write permissions
|
||||
2. Check S3 bucket name is correct and accessible
|
||||
3. Ensure AWS region settings match your bucket location
|
||||
4. Review AWS CloudTrail logs for permission issues
|
||||
|
||||
#### Participant Count Not Updating
|
||||
**Symptoms**: Meeting participant counts remain at 0
|
||||
|
||||
**Solutions**:
|
||||
1. Verify webhook events are being received at `/v1/whereby`
|
||||
2. Check webhook signature validation is passing
|
||||
3. Ensure meeting IDs match between Whereby and Reflector database
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Test Whereby API connectivity
|
||||
curl -H "Authorization: Bearer $WHEREBY_API_KEY" \
|
||||
https://api.whereby.dev/v1/meetings
|
||||
|
||||
# Check webhook endpoint health
|
||||
curl https://your-reflector-domain.com/v1/whereby/health
|
||||
|
||||
# Verify S3 bucket access
|
||||
aws s3 ls s3://your-bucket-name --profile whereby-user
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Key Security
|
||||
- Store API keys securely using environment variables
|
||||
- Rotate API keys regularly
|
||||
- Never commit API keys to version control
|
||||
- Use separate keys for development and production
|
||||
|
||||
### Webhook Security
|
||||
- Always validate webhook signatures using HMAC-SHA256
|
||||
- Use HTTPS for all webhook endpoints
|
||||
- Implement rate limiting on webhook endpoints
|
||||
- Monitor webhook events for suspicious activity
|
||||
|
||||
### Recording Privacy
|
||||
- Ensure S3 bucket access is restricted to authorized users
|
||||
- Consider encryption at rest for sensitive recordings
|
||||
- Implement retention policies for recorded content
|
||||
- Comply with data protection regulations (GDPR, etc.)
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Meeting Scaling
|
||||
- Monitor concurrent meeting limits on your Whereby plan
|
||||
- Implement meeting cleanup for expired sessions
|
||||
- Use appropriate room modes for different use cases
|
||||
|
||||
### Recording Processing
|
||||
- Configure SQS for asynchronous recording processing
|
||||
- Monitor S3 storage usage and costs
|
||||
- Implement automatic cleanup of processed recordings
|
||||
|
||||
### Webhook Reliability
|
||||
- Implement webhook retry mechanisms
|
||||
- Monitor webhook delivery success rates
|
||||
- Log webhook events for debugging and auditing
|
||||
|
||||
## Migration from Other Platforms
|
||||
|
||||
If migrating from another video platform:
|
||||
|
||||
1. **Update Room Configuration**: Change existing rooms to use `"platform": "whereby"`
|
||||
2. **Configure Webhooks**: Set up Whereby webhook endpoints
|
||||
3. **Test Integration**: Verify meeting creation and event handling
|
||||
4. **Monitor Performance**: Watch for any issues during transition
|
||||
5. **Update Documentation**: Inform users of any workflow changes
|
||||
|
||||
## Support
|
||||
|
||||
For Whereby-specific issues:
|
||||
- **Whereby Support**: [whereby.com/support](https://whereby.com/support)
|
||||
- **API Documentation**: [whereby.dev](https://whereby.dev)
|
||||
- **Status Page**: [status.whereby.com](https://status.whereby.com)
|
||||
|
||||
For Reflector integration issues:
|
||||
- Check application logs for error details
|
||||
- Verify environment variable configuration
|
||||
- Test webhook connectivity and authentication
|
||||
- Review AWS permissions and S3 access
|
||||
474
docs/video_platforms.md
Normal file
474
docs/video_platforms.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Video Platforms Architecture (PR #529 Analysis)
|
||||
|
||||
This document analyzes the video platforms refactoring implemented in PR #529 for daily.co integration, providing a blueprint for extending support to Jitsi and other video conferencing platforms.
|
||||
|
||||
## Overview
|
||||
|
||||
The video platforms refactoring introduces a clean abstraction layer that allows Reflector to support multiple video conferencing providers (Whereby, Daily.co, etc.) without changing core application logic. This architecture enables:
|
||||
|
||||
- Seamless switching between video platforms
|
||||
- Platform-specific feature support
|
||||
- Isolated platform code organization
|
||||
- Consistent API surface across platforms
|
||||
- Feature flags for gradual migration
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. **Directory Structure**
|
||||
|
||||
```
|
||||
server/reflector/video_platforms/
|
||||
├── __init__.py # Public API exports
|
||||
├── base.py # Abstract base classes
|
||||
├── factory.py # Platform client factory
|
||||
├── registry.py # Platform registration system
|
||||
├── whereby.py # Whereby implementation
|
||||
├── daily.py # Daily.co implementation
|
||||
└── mock.py # Testing implementation
|
||||
```
|
||||
|
||||
### 2. **Core Abstract Classes**
|
||||
|
||||
#### `VideoPlatformClient` (base.py)
|
||||
Abstract base class defining the interface all platforms must implement:
|
||||
|
||||
```python
|
||||
class VideoPlatformClient(ABC):
|
||||
PLATFORM_NAME: str = ""
|
||||
|
||||
@abstractmethod
|
||||
async def create_meeting(self, room_name_prefix: str, end_date: datetime, room: Room) -> MeetingData
|
||||
|
||||
@abstractmethod
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]
|
||||
|
||||
@abstractmethod
|
||||
async def delete_room(self, room_name: str) -> bool
|
||||
|
||||
@abstractmethod
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool
|
||||
|
||||
@abstractmethod
|
||||
def verify_webhook_signature(self, body: bytes, signature: str, timestamp: Optional[str] = None) -> bool
|
||||
```
|
||||
|
||||
#### `MeetingData` (base.py)
|
||||
Standardized meeting data structure returned by all platforms:
|
||||
|
||||
```python
|
||||
class MeetingData(BaseModel):
|
||||
meeting_id: str
|
||||
room_name: str
|
||||
room_url: str
|
||||
host_room_url: str
|
||||
platform: str
|
||||
extra_data: Dict[str, Any] = {} # Platform-specific data
|
||||
```
|
||||
|
||||
#### `VideoPlatformConfig` (base.py)
|
||||
Unified configuration structure for all platforms:
|
||||
|
||||
```python
|
||||
class VideoPlatformConfig(BaseModel):
|
||||
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
|
||||
```
|
||||
|
||||
### 3. **Platform Registration System**
|
||||
|
||||
#### Registry Pattern (registry.py)
|
||||
- Automatic registration of built-in platforms
|
||||
- Runtime platform discovery
|
||||
- Type-safe client instantiation
|
||||
|
||||
```python
|
||||
# Auto-registration of platforms
|
||||
_PLATFORMS: Dict[str, Type[VideoPlatformClient]] = {}
|
||||
|
||||
def register_platform(name: str, client_class: Type[VideoPlatformClient])
|
||||
def get_platform_client(platform: str, config: VideoPlatformConfig) -> VideoPlatformClient
|
||||
```
|
||||
|
||||
#### Factory System (factory.py)
|
||||
- Configuration management per platform
|
||||
- Platform selection logic
|
||||
- Feature flag integration
|
||||
|
||||
```python
|
||||
def get_platform_for_room(room_id: Optional[str] = None) -> str:
|
||||
"""Determine which platform to use based on feature flags."""
|
||||
if not settings.DAILY_MIGRATION_ENABLED:
|
||||
return "whereby"
|
||||
|
||||
if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS:
|
||||
return "daily"
|
||||
|
||||
return settings.DEFAULT_VIDEO_PLATFORM
|
||||
```
|
||||
|
||||
### 4. **Database Schema Changes**
|
||||
|
||||
#### Room Model Updates
|
||||
Added `platform` field to track which video platform each room uses:
|
||||
|
||||
```python
|
||||
# Database Schema
|
||||
platform_column = sqlalchemy.Column(
|
||||
"platform",
|
||||
sqlalchemy.String,
|
||||
nullable=False,
|
||||
server_default="whereby"
|
||||
)
|
||||
|
||||
# Pydantic Model
|
||||
class Room(BaseModel):
|
||||
platform: Literal["whereby", "daily"] = "whereby"
|
||||
```
|
||||
|
||||
#### Meeting Model Updates
|
||||
Added `platform` field to meetings for tracking and debugging:
|
||||
|
||||
```python
|
||||
# Database Schema
|
||||
platform_column = sqlalchemy.Column(
|
||||
"platform",
|
||||
sqlalchemy.String,
|
||||
nullable=False,
|
||||
server_default="whereby"
|
||||
)
|
||||
|
||||
# Pydantic Model
|
||||
class Meeting(BaseModel):
|
||||
platform: Literal["whereby", "daily"] = "whereby"
|
||||
```
|
||||
|
||||
**Key Decision**: No platform-specific fields were added to models. Instead, the `extra_data` field in `MeetingData` handles platform-specific information, following the user's rule of using generic `provider_data` as JSON if needed.
|
||||
|
||||
### 5. **Settings Configuration**
|
||||
|
||||
#### Feature Flags
|
||||
```python
|
||||
# Migration control
|
||||
DAILY_MIGRATION_ENABLED: bool = True
|
||||
DAILY_MIGRATION_ROOM_IDS: list[str] = []
|
||||
DEFAULT_VIDEO_PLATFORM: str = "daily"
|
||||
|
||||
# Daily.co specific settings
|
||||
DAILY_API_KEY: str | None = None
|
||||
DAILY_WEBHOOK_SECRET: str | None = None
|
||||
DAILY_SUBDOMAIN: str | None = None
|
||||
AWS_DAILY_S3_BUCKET: str | None = None
|
||||
AWS_DAILY_S3_REGION: str = "us-west-2"
|
||||
AWS_DAILY_ROLE_ARN: str | None = None
|
||||
```
|
||||
|
||||
#### Configuration Pattern
|
||||
Each platform gets its own configuration namespace while sharing common patterns:
|
||||
|
||||
```python
|
||||
def get_platform_config(platform: str) -> VideoPlatformConfig:
|
||||
if platform == "whereby":
|
||||
return VideoPlatformConfig(
|
||||
api_key=settings.WHEREBY_API_KEY or "",
|
||||
webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "",
|
||||
# ... whereby-specific config
|
||||
)
|
||||
elif platform == "daily":
|
||||
return VideoPlatformConfig(
|
||||
api_key=settings.DAILY_API_KEY or "",
|
||||
webhook_secret=settings.DAILY_WEBHOOK_SECRET or "",
|
||||
# ... daily-specific config
|
||||
)
|
||||
```
|
||||
|
||||
### 6. **API Integration Updates**
|
||||
|
||||
#### Room Creation (views/rooms.py)
|
||||
Updated to use platform factory instead of direct Whereby calls:
|
||||
|
||||
```python
|
||||
@router.post("/rooms/{room_name}/meeting")
|
||||
async def rooms_create_meeting(room_name: str, user: UserInfo):
|
||||
# OLD: Direct Whereby integration
|
||||
# whereby_meeting = await create_meeting("", end_date=end_date, room=room)
|
||||
|
||||
# NEW: Platform abstraction
|
||||
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
|
||||
)
|
||||
|
||||
await client.upload_logo(meeting_data.room_name, "./images/logo.png")
|
||||
```
|
||||
|
||||
### 7. **Webhook Handling**
|
||||
|
||||
#### Separate Webhook Endpoints
|
||||
Each platform gets its own webhook endpoint with platform-specific signature verification:
|
||||
|
||||
```python
|
||||
# views/daily.py
|
||||
@router.post("/daily_webhook")
|
||||
async def daily_webhook(event: DailyWebhookEvent, request: Request):
|
||||
# Verify Daily.co signature
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Daily-Signature", "")
|
||||
|
||||
if not verify_daily_webhook_signature(body, signature):
|
||||
raise HTTPException(status_code=401)
|
||||
|
||||
# Handle platform-specific events
|
||||
if event.type == "participant.joined":
|
||||
await _handle_participant_joined(event)
|
||||
```
|
||||
|
||||
#### Consistent Event Handling
|
||||
Despite different event formats, the core business logic remains the same:
|
||||
|
||||
```python
|
||||
async def _handle_participant_joined(event):
|
||||
room_name = event.data.get("room", {}).get("name") # Daily.co format
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
current_count = getattr(meeting, "num_clients", 0)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id, num_clients=current_count + 1
|
||||
)
|
||||
```
|
||||
|
||||
### 8. **Worker Task Integration**
|
||||
|
||||
#### New Task for Daily.co Recording Processing
|
||||
Added platform-specific recording processing while maintaining the same pipeline:
|
||||
|
||||
```python
|
||||
@shared_task
|
||||
@asynctask
|
||||
async def process_recording_from_url(recording_url: str, meeting_id: str, recording_id: str):
|
||||
"""Process recording from Direct URL (Daily.co webhook)."""
|
||||
logger.info("Processing recording from URL for meeting: %s", meeting_id)
|
||||
# Uses same processing pipeline as Whereby S3 recordings
|
||||
```
|
||||
|
||||
**Key Decision**: Worker tasks remain in main worker module but could be moved to platform-specific folders as suggested by the user.
|
||||
|
||||
### 9. **Testing Infrastructure**
|
||||
|
||||
#### Comprehensive Test Suite
|
||||
- Unit tests for each platform client
|
||||
- Integration tests for platform switching
|
||||
- Mock platform for testing without external dependencies
|
||||
- Webhook signature verification tests
|
||||
|
||||
```python
|
||||
class TestPlatformIntegration:
|
||||
"""Integration tests for platform switching."""
|
||||
|
||||
async def test_platform_switching_preserves_interface(self):
|
||||
"""Test that different platforms provide consistent interface."""
|
||||
# Test both Mock and Daily platforms return MeetingData objects
|
||||
# with consistent fields
|
||||
```
|
||||
|
||||
## Implementation Patterns for Jitsi Integration
|
||||
|
||||
Based on the daily.co implementation, here's how Jitsi should be integrated:
|
||||
|
||||
### 1. **Jitsi Client Implementation**
|
||||
|
||||
```python
|
||||
# video_platforms/jitsi.py
|
||||
class JitsiClient(VideoPlatformClient):
|
||||
PLATFORM_NAME = "jitsi"
|
||||
|
||||
async def create_meeting(self, room_name_prefix: str, end_date: datetime, room: Room) -> MeetingData:
|
||||
# Generate unique room name
|
||||
jitsi_room = f"reflector-{room.name}-{int(time.time())}"
|
||||
|
||||
# Generate JWT tokens
|
||||
user_jwt = self._generate_jwt(room=jitsi_room, moderator=False, exp=end_date)
|
||||
host_jwt = self._generate_jwt(room=jitsi_room, moderator=True, exp=end_date)
|
||||
|
||||
return MeetingData(
|
||||
meeting_id=generate_uuid4(),
|
||||
room_name=jitsi_room,
|
||||
room_url=f"https://jitsi.domain/{jitsi_room}?jwt={user_jwt}",
|
||||
host_room_url=f"https://jitsi.domain/{jitsi_room}?jwt={host_jwt}",
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data={"user_jwt": user_jwt, "host_jwt": host_jwt}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. **Settings Integration**
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
JITSI_DOMAIN: str = "meet.jit.si"
|
||||
JITSI_JWT_SECRET: str | None = None
|
||||
JITSI_WEBHOOK_SECRET: str | None = None
|
||||
JITSI_API_URL: str | None = None # If using Jitsi API
|
||||
```
|
||||
|
||||
### 3. **Factory Registration**
|
||||
|
||||
```python
|
||||
# registry.py
|
||||
def _register_builtin_platforms():
|
||||
from .jitsi import JitsiClient
|
||||
register_platform("jitsi", JitsiClient)
|
||||
|
||||
# factory.py
|
||||
def get_platform_config(platform: str) -> VideoPlatformConfig:
|
||||
elif platform == "jitsi":
|
||||
return VideoPlatformConfig(
|
||||
api_key="", # Jitsi may not need API key
|
||||
webhook_secret=settings.JITSI_WEBHOOK_SECRET or "",
|
||||
api_url=settings.JITSI_API_URL,
|
||||
)
|
||||
```
|
||||
|
||||
### 4. **Webhook Integration**
|
||||
|
||||
```python
|
||||
# views/jitsi.py
|
||||
@router.post("/jitsi/events")
|
||||
async def jitsi_events_webhook(event_data: dict):
|
||||
# Handle Prosody event-sync webhook format
|
||||
event_type = event_data.get("event")
|
||||
room_name = event_data.get("room", "").split("@")[0]
|
||||
|
||||
if event_type == "muc-occupant-joined":
|
||||
# Same participant handling logic as other platforms
|
||||
```
|
||||
|
||||
## Key Benefits of This Architecture
|
||||
|
||||
### 1. **Isolation and Organization**
|
||||
- Platform-specific code contained in separate modules
|
||||
- No platform logic leaking into core application
|
||||
- Easy to add/remove platforms without affecting others
|
||||
|
||||
### 2. **Consistent Interface**
|
||||
- All platforms implement the same abstract methods
|
||||
- Standardized `MeetingData` structure
|
||||
- Uniform error handling and logging
|
||||
|
||||
### 3. **Gradual Migration Support**
|
||||
- Feature flags for controlled rollouts
|
||||
- Room-specific platform selection
|
||||
- Fallback mechanisms for platform failures
|
||||
|
||||
### 4. **Configuration Management**
|
||||
- Centralized settings per platform
|
||||
- Consistent naming patterns
|
||||
- Environment-based configuration
|
||||
|
||||
### 5. **Testing and Quality**
|
||||
- Mock platform for testing
|
||||
- Comprehensive test coverage
|
||||
- Platform-specific test utilities
|
||||
|
||||
## Migration Strategy Applied
|
||||
|
||||
The daily.co implementation demonstrates a careful migration approach:
|
||||
|
||||
### 1. **Backward Compatibility**
|
||||
- Default platform remains "whereby"
|
||||
- Existing rooms continue using Whereby unless explicitly migrated
|
||||
- Same API endpoints and response formats
|
||||
|
||||
### 2. **Feature Flag Control**
|
||||
```python
|
||||
# Gradual rollout control
|
||||
DAILY_MIGRATION_ENABLED: bool = True
|
||||
DAILY_MIGRATION_ROOM_IDS: list[str] = [] # Specific rooms to migrate
|
||||
DEFAULT_VIDEO_PLATFORM: str = "daily" # New rooms default
|
||||
```
|
||||
|
||||
### 3. **Data Integrity**
|
||||
- Platform field tracks which service each room/meeting uses
|
||||
- No data loss during migration
|
||||
- Platform-specific data preserved in `extra_data`
|
||||
|
||||
### 4. **Monitoring and Rollback**
|
||||
- Comprehensive logging of platform selection
|
||||
- Easy rollback by changing feature flags
|
||||
- Platform-specific error tracking
|
||||
|
||||
## Recommendations for Jitsi Integration
|
||||
|
||||
Based on this analysis and the user's requirements:
|
||||
|
||||
### 1. **Follow the Pattern**
|
||||
- Create `video_platforms/jitsi/` directory with:
|
||||
- `client.py` - Main JitsiClient implementation
|
||||
- `tasks.py` - Jitsi-specific worker tasks
|
||||
- `__init__.py` - Module exports
|
||||
|
||||
### 2. **Settings Organization**
|
||||
- Use `JITSI_*` prefix for all Jitsi settings
|
||||
- Follow the same configuration pattern as Daily.co
|
||||
- Support both environment variables and config files
|
||||
|
||||
### 3. **Generic Database Fields**
|
||||
- Avoid platform-specific columns in database
|
||||
- Use `provider_data` JSON field if platform-specific data needed
|
||||
- Keep `platform` field as simple string identifier
|
||||
|
||||
### 4. **Worker Task Migration**
|
||||
According to user requirements, migrate platform-specific tasks:
|
||||
```
|
||||
video_platforms/
|
||||
├── whereby/
|
||||
│ ├── client.py (moved from whereby.py)
|
||||
│ └── tasks.py (moved from worker/whereby_tasks.py)
|
||||
├── daily/
|
||||
│ ├── client.py (moved from daily.py)
|
||||
│ └── tasks.py (moved from worker/daily_tasks.py)
|
||||
└── jitsi/
|
||||
├── client.py (new JitsiClient)
|
||||
└── tasks.py (new Jitsi recording tasks)
|
||||
```
|
||||
|
||||
### 5. **Webhook Architecture**
|
||||
- Create `views/jitsi.py` for Jitsi-specific webhooks
|
||||
- Follow the same signature verification pattern
|
||||
- Reuse existing participant tracking logic
|
||||
|
||||
## Implementation Checklist for Jitsi
|
||||
|
||||
- [ ] Create `video_platforms/jitsi/` directory structure
|
||||
- [ ] Implement `JitsiClient` following the abstract interface
|
||||
- [ ] Add Jitsi settings to configuration
|
||||
- [ ] Register Jitsi platform in factory/registry
|
||||
- [ ] Create Jitsi webhook endpoint
|
||||
- [ ] Implement JWT token generation for room access
|
||||
- [ ] Add Jitsi recording processing tasks
|
||||
- [ ] Create comprehensive test suite
|
||||
- [ ] Update database migrations for platform field
|
||||
- [ ] Document Jitsi-specific configuration
|
||||
|
||||
## Conclusion
|
||||
|
||||
The video platforms refactoring in PR #529 provides an excellent foundation for adding Jitsi support. The architecture is well-designed with clear separation of concerns, consistent interfaces, and excellent extensibility. The daily.co implementation demonstrates how to add a new platform while maintaining backward compatibility and providing gradual migration capabilities.
|
||||
|
||||
The pattern should be directly applicable to Jitsi integration, with the main differences being:
|
||||
- JWT-based authentication instead of API keys
|
||||
- Different webhook event formats
|
||||
- Jibri recording pipeline integration
|
||||
- Self-hosted deployment considerations
|
||||
|
||||
This architecture successfully achieves the user's goals of:
|
||||
1. Settings-based configuration
|
||||
2. Generic database fields (no provider-specific columns)
|
||||
3. Platform isolation in separate directories
|
||||
4. Worker task organization within platform folders
|
||||
212
server/contrib/jitsi/README.md
Normal file
212
server/contrib/jitsi/README.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Event Logger for Docker-Jitsi-Meet
|
||||
|
||||
A Prosody module that logs Jitsi meeting events to JSONL files alongside recordings, enabling complete participant tracking and speaker statistics.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Running docker-jitsi-meet installation
|
||||
- Jibri configured for recording
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Copy the Module
|
||||
|
||||
Copy the Prosody module to your custom plugins directory:
|
||||
|
||||
```bash
|
||||
# Create the directory if it doesn't exist
|
||||
mkdir -p ~/.jitsi-meet-cfg/prosody/prosody-plugins-custom
|
||||
|
||||
# Copy the module
|
||||
cp mod_event_logger.lua ~/.jitsi-meet-cfg/prosody/prosody-plugins-custom/
|
||||
```
|
||||
|
||||
### Step 2: Update Your .env File
|
||||
|
||||
Add or modify these variables in your `.env` file:
|
||||
|
||||
```bash
|
||||
# If XMPP_MUC_MODULES already exists, append event_logger
|
||||
# Example: XMPP_MUC_MODULES=existing_module,event_logger
|
||||
XMPP_MUC_MODULES=event_logger
|
||||
|
||||
# Optional: Configure the module (these are defaults)
|
||||
JIBRI_RECORDINGS_PATH=/config/recordings
|
||||
JIBRI_LOG_SPEAKER_STATS=true
|
||||
JIBRI_SPEAKER_STATS_INTERVAL=10
|
||||
```
|
||||
|
||||
**Important**: If you already have `XMPP_MUC_MODULES` defined, add `event_logger` to the comma-separated list:
|
||||
```bash
|
||||
# Existing modules + our module
|
||||
XMPP_MUC_MODULES=mod_info,mod_alert,event_logger
|
||||
```
|
||||
|
||||
### Step 3: Modify docker-compose.yml
|
||||
|
||||
Add a shared recordings volume so Prosody can write events alongside Jibri recordings:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
prosody:
|
||||
# ... existing configuration ...
|
||||
volumes:
|
||||
- ${CONFIG}/prosody/config:/config:Z
|
||||
- ${CONFIG}/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z
|
||||
- ${CONFIG}/recordings:/config/recordings:Z # Add this line
|
||||
environment:
|
||||
# Add if not using .env file
|
||||
- XMPP_MUC_MODULES=${XMPP_MUC_MODULES:-event_logger}
|
||||
- JIBRI_RECORDINGS_PATH=/config/recordings
|
||||
|
||||
jibri:
|
||||
# ... existing configuration ...
|
||||
volumes:
|
||||
- ${CONFIG}/jibri:/config:Z
|
||||
- ${CONFIG}/recordings:/config/recordings:Z # Add this line
|
||||
environment:
|
||||
# For Reflector webhook integration (optional)
|
||||
- REFLECTOR_WEBHOOK_URL=${REFLECTOR_WEBHOOK_URL:-}
|
||||
- JIBRI_FINALIZE_RECORDING_SCRIPT_PATH=/config/finalize.sh
|
||||
```
|
||||
|
||||
### Step 4: Add Finalize Script (Optional - For Reflector Integration)
|
||||
|
||||
If you want to notify Reflector when recordings complete:
|
||||
|
||||
```bash
|
||||
# Copy the finalize script
|
||||
cp finalize.sh ~/.jitsi-meet-cfg/jibri/finalize.sh
|
||||
chmod +x ~/.jitsi-meet-cfg/jibri/finalize.sh
|
||||
|
||||
# Add to .env
|
||||
REFLECTOR_WEBHOOK_URL=http://your-reflector-api:8000
|
||||
```
|
||||
|
||||
### Step 5: Restart Services
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## What Gets Created
|
||||
|
||||
After a recording, you'll find in `~/.jitsi-meet-cfg/recordings/{session-id}/`:
|
||||
- `recording.mp4` - The video recording (created by Jibri)
|
||||
- `metadata.json` - Basic metadata (created by Jibri)
|
||||
- `events.jsonl` - Complete participant timeline (created by this module)
|
||||
|
||||
## Event Format
|
||||
|
||||
Each line in `events.jsonl` is a JSON object:
|
||||
|
||||
```json
|
||||
{"type":"room_created","timestamp":1234567890,"room_name":"TestRoom","room_jid":"testroom@conference.meet.jitsi","meeting_url":"https://meet.jitsi/TestRoom"}
|
||||
{"type":"recording_started","timestamp":1234567891,"room_name":"TestRoom","session_id":"20240115120000_TestRoom","jibri_jid":"jibri@recorder.meet.jitsi"}
|
||||
{"type":"participant_joined","timestamp":1234567892,"room_name":"TestRoom","participant":{"jid":"user1@meet.jitsi/web","nick":"John Doe","id":"user1@meet.jitsi","is_moderator":false}}
|
||||
{"type":"speaker_active","timestamp":1234567895,"room_name":"TestRoom","speaker_jid":"user1@meet.jitsi","speaker_nick":"John Doe","duration":10}
|
||||
{"type":"participant_left","timestamp":1234567920,"room_name":"TestRoom","participant":{"jid":"user1@meet.jitsi/web","nick":"John Doe","duration_seconds":28}}
|
||||
{"type":"recording_stopped","timestamp":1234567950,"room_name":"TestRoom","session_id":"20240115120000_TestRoom","meeting_url":"https://meet.jitsi/TestRoom"}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
All configuration can be done via environment variables:
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `JIBRI_RECORDINGS_PATH` | `/config/recordings` | Path where recordings are stored |
|
||||
| `JIBRI_LOG_SPEAKER_STATS` | `true` | Enable speaker statistics logging |
|
||||
| `JIBRI_SPEAKER_STATS_INTERVAL` | `10` | Seconds between speaker stats updates |
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
Check that the module is loaded:
|
||||
```bash
|
||||
docker-compose logs prosody | grep "Event Logger"
|
||||
# Should see: "Event Logger loaded - writing to /config/recordings"
|
||||
```
|
||||
|
||||
Check for events after a recording:
|
||||
```bash
|
||||
ls -la ~/.jitsi-meet-cfg/recordings/*/events.jsonl
|
||||
cat ~/.jitsi-meet-cfg/recordings/*/events.jsonl | jq .
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No events.jsonl file created
|
||||
|
||||
1. **Check module is enabled**:
|
||||
```bash
|
||||
docker-compose exec prosody grep -r "event_logger" /config
|
||||
```
|
||||
|
||||
2. **Verify volume permissions**:
|
||||
```bash
|
||||
docker-compose exec prosody ls -la /config/recordings
|
||||
```
|
||||
|
||||
3. **Check Prosody logs for errors**:
|
||||
```bash
|
||||
docker-compose logs prosody | grep -i error
|
||||
```
|
||||
|
||||
### Module not loading
|
||||
|
||||
1. **Verify file exists in container**:
|
||||
```bash
|
||||
docker-compose exec prosody ls -la /prosody-plugins-custom/
|
||||
```
|
||||
|
||||
2. **Check XMPP_MUC_MODULES format** (must be comma-separated, no spaces):
|
||||
- ✅ Correct: `XMPP_MUC_MODULES=mod1,mod2,event_logger`
|
||||
- ❌ Wrong: `XMPP_MUC_MODULES=mod1, mod2, event_logger`
|
||||
|
||||
## Common docker-compose.yml Patterns
|
||||
|
||||
### Minimal Addition (if you trust defaults)
|
||||
```yaml
|
||||
services:
|
||||
prosody:
|
||||
volumes:
|
||||
- ${CONFIG}/recordings:/config/recordings:Z # Just add this
|
||||
```
|
||||
|
||||
### Full Configuration
|
||||
```yaml
|
||||
services:
|
||||
prosody:
|
||||
volumes:
|
||||
- ${CONFIG}/prosody/config:/config:Z
|
||||
- ${CONFIG}/prosody/prosody-plugins-custom:/prosody-plugins-custom:Z
|
||||
- ${CONFIG}/recordings:/config/recordings:Z
|
||||
environment:
|
||||
- XMPP_MUC_MODULES=event_logger
|
||||
- JIBRI_RECORDINGS_PATH=/config/recordings
|
||||
- JIBRI_LOG_SPEAKER_STATS=true
|
||||
- JIBRI_SPEAKER_STATS_INTERVAL=10
|
||||
|
||||
jibri:
|
||||
volumes:
|
||||
- ${CONFIG}/jibri:/config:Z
|
||||
- ${CONFIG}/recordings:/config/recordings:Z
|
||||
environment:
|
||||
- JIBRI_RECORDING_DIR=/config/recordings
|
||||
- JIBRI_FINALIZE_RECORDING_SCRIPT_PATH=/config/finalize.sh
|
||||
```
|
||||
|
||||
## Integration with Reflector
|
||||
|
||||
The finalize.sh script will automatically notify Reflector when a recording completes if `REFLECTOR_WEBHOOK_URL` is set. Reflector will receive:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "20240115120000_TestRoom",
|
||||
"path": "20240115120000_TestRoom",
|
||||
"meeting_url": "https://meet.jitsi/TestRoom"
|
||||
}
|
||||
```
|
||||
|
||||
Reflector then processes the recording along with the complete participant timeline from `events.jsonl`.
|
||||
49
server/contrib/jitsi/finalize.sh
Executable file
49
server/contrib/jitsi/finalize.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Jibri finalize script to notify Reflector when recording is complete
|
||||
# This script is called by Jibri with the recording directory as argument
|
||||
|
||||
RECORDING_PATH="$1"
|
||||
SESSION_ID=$(basename "$RECORDING_PATH")
|
||||
METADATA_FILE="$RECORDING_PATH/metadata.json"
|
||||
|
||||
# Extract meeting URL from Jibri's metadata
|
||||
MEETING_URL=""
|
||||
if [ -f "$METADATA_FILE" ]; then
|
||||
MEETING_URL=$(jq -r '.meeting_url' "$METADATA_FILE" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
echo "[$(date)] Recording finalized: $RECORDING_PATH"
|
||||
echo "[$(date)] Session ID: $SESSION_ID"
|
||||
echo "[$(date)] Meeting URL: $MEETING_URL"
|
||||
|
||||
# Check if events.jsonl was created by our Prosody module
|
||||
if [ -f "$RECORDING_PATH/events.jsonl" ]; then
|
||||
EVENT_COUNT=$(wc -l < "$RECORDING_PATH/events.jsonl")
|
||||
echo "[$(date)] Found events.jsonl with $EVENT_COUNT events"
|
||||
else
|
||||
echo "[$(date)] Warning: No events.jsonl found"
|
||||
fi
|
||||
|
||||
# Notify Reflector if webhook URL is configured
|
||||
if [ -n "$REFLECTOR_WEBHOOK_URL" ]; then
|
||||
echo "[$(date)] Notifying Reflector at: $REFLECTOR_WEBHOOK_URL"
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$REFLECTOR_WEBHOOK_URL/api/v1/jibri/recording-ready" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"session_id\":\"$SESSION_ID\",\"path\":\"$SESSION_ID\",\"meeting_url\":\"$MEETING_URL\"}")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "[$(date)] Reflector notified successfully"
|
||||
echo "[$(date)] Response: $BODY"
|
||||
else
|
||||
echo "[$(date)] Failed to notify Reflector. HTTP code: $HTTP_CODE"
|
||||
echo "[$(date)] Response: $BODY"
|
||||
fi
|
||||
else
|
||||
echo "[$(date)] No REFLECTOR_WEBHOOK_URL configured, skipping notification"
|
||||
fi
|
||||
|
||||
echo "[$(date)] Finalize script completed"
|
||||
372
server/contrib/jitsi/mod_event_logger.lua
Normal file
372
server/contrib/jitsi/mod_event_logger.lua
Normal file
@@ -0,0 +1,372 @@
|
||||
local json = require "util.json"
|
||||
local st = require "util.stanza"
|
||||
local jid_bare = require "util.jid".bare
|
||||
|
||||
local recordings_path = os.getenv("JIBRI_RECORDINGS_PATH") or
|
||||
module:get_option_string("jibri_recordings_path", "/recordings")
|
||||
|
||||
-- room_jid -> { session_id, participants = {jid -> info} }
|
||||
local active_recordings = {}
|
||||
-- room_jid -> { participants = {jid -> info}, created_at }
|
||||
local room_states = {}
|
||||
|
||||
local function get_timestamp()
|
||||
return os.time()
|
||||
end
|
||||
|
||||
local function write_event(session_id, event)
|
||||
if not session_id then
|
||||
module:log("warn", "No session_id for event: %s", event.type)
|
||||
return
|
||||
end
|
||||
|
||||
local session_dir = string.format("%s/%s", recordings_path, session_id)
|
||||
local event_file = string.format("%s/events.jsonl", session_dir)
|
||||
|
||||
module:log("info", "Writing event %s to %s", event.type, event_file)
|
||||
|
||||
-- Create directory
|
||||
local mkdir_cmd = string.format("mkdir -p '%s' 2>&1", session_dir)
|
||||
local mkdir_result = os.execute(mkdir_cmd)
|
||||
module:log("debug", "mkdir result: %s", tostring(mkdir_result))
|
||||
|
||||
local file, err = io.open(event_file, "a")
|
||||
if file then
|
||||
local json_str = json.encode(event)
|
||||
file:write(json_str .. "\n")
|
||||
file:close()
|
||||
module:log("info", "Successfully wrote event %s", event.type)
|
||||
else
|
||||
module:log("error", "Failed to write event to %s: %s", event_file, err)
|
||||
end
|
||||
end
|
||||
|
||||
local function extract_participant_info(occupant)
|
||||
local info = {
|
||||
jid = occupant.jid,
|
||||
bare_jid = occupant.bare_jid,
|
||||
nick = occupant.nick,
|
||||
display_name = nil,
|
||||
role = occupant.role
|
||||
}
|
||||
|
||||
local presence = occupant:get_presence()
|
||||
if presence then
|
||||
local nick_element = presence:get_child("nick", "http://jabber.org/protocol/nick")
|
||||
if nick_element then
|
||||
info.display_name = nick_element:get_text()
|
||||
end
|
||||
|
||||
local identity = presence:get_child("identity")
|
||||
if identity then
|
||||
local user = identity:get_child("user")
|
||||
if user then
|
||||
local name = user:get_child("name")
|
||||
if name then
|
||||
info.display_name = name:get_text()
|
||||
end
|
||||
|
||||
local id_element = user:get_child("id")
|
||||
if id_element then
|
||||
info.id = id_element:get_text()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not info.display_name and occupant.nick then
|
||||
local _, _, resource = occupant.nick:match("([^@]+)@([^/]+)/(.+)")
|
||||
if resource then
|
||||
info.display_name = resource
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return info
|
||||
end
|
||||
|
||||
local function get_room_participant_count(room)
|
||||
local count = 0
|
||||
for _ in room:each_occupant() do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function snapshot_room_participants(room)
|
||||
local participants = {}
|
||||
local total = 0
|
||||
local skipped = 0
|
||||
|
||||
module:log("info", "Snapshotting room participants")
|
||||
|
||||
for _, occupant in room:each_occupant() do
|
||||
total = total + 1
|
||||
-- Skip recorders (Jibri)
|
||||
if occupant.bare_jid and (occupant.bare_jid:match("^recorder@") or
|
||||
occupant.bare_jid:match("^jibri@")) then
|
||||
skipped = skipped + 1
|
||||
else
|
||||
local info = extract_participant_info(occupant)
|
||||
participants[occupant.jid] = info
|
||||
module:log("debug", "Added participant: %s", info.display_name or info.bare_jid)
|
||||
end
|
||||
end
|
||||
|
||||
module:log("info", "Snapshot: %d total, %d participants", total, total - skipped)
|
||||
return participants
|
||||
end
|
||||
|
||||
-- Import utility functions if available
|
||||
local util = module:require "util";
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
|
||||
-- Main IQ handler for Jibri stanzas
|
||||
module:hook("pre-iq/full", function(event)
|
||||
local stanza = event.stanza
|
||||
if stanza.name ~= "iq" then
|
||||
return
|
||||
end
|
||||
|
||||
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri')
|
||||
if not jibri then
|
||||
return
|
||||
end
|
||||
|
||||
module:log("info", "=== Jibri IQ intercepted ===")
|
||||
|
||||
local action = jibri.attr.action
|
||||
local session_id = jibri.attr.session_id
|
||||
local room_jid = jibri.attr.room
|
||||
local recording_mode = jibri.attr.recording_mode
|
||||
local app_data = jibri.attr.app_data
|
||||
|
||||
module:log("info", "Jibri %s - session: %s, room: %s, mode: %s",
|
||||
action or "?", session_id or "?", room_jid or "?", recording_mode or "?")
|
||||
|
||||
if not room_jid or not session_id then
|
||||
module:log("warn", "Missing room_jid or session_id")
|
||||
return
|
||||
end
|
||||
|
||||
-- Get the room using util function
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(jid_bare(stanza.attr.to)))
|
||||
if not room then
|
||||
-- Try with the room_jid directly
|
||||
room = get_room_from_jid(room_jid)
|
||||
end
|
||||
|
||||
if not room then
|
||||
module:log("error", "Room not found for jid: %s", room_jid)
|
||||
return
|
||||
end
|
||||
|
||||
module:log("info", "Room found: %s", room:get_name() or room_jid)
|
||||
|
||||
if action == "start" then
|
||||
module:log("info", "Recording START for session %s", session_id)
|
||||
|
||||
-- Count and snapshot participants
|
||||
local participant_count = 0
|
||||
for _ in room:each_occupant() do
|
||||
participant_count = participant_count + 1
|
||||
end
|
||||
|
||||
local participants = snapshot_room_participants(room)
|
||||
local participant_list = {}
|
||||
for jid, info in pairs(participants) do
|
||||
table.insert(participant_list, info)
|
||||
end
|
||||
|
||||
active_recordings[room_jid] = {
|
||||
session_id = session_id,
|
||||
participants = participants,
|
||||
started_at = get_timestamp()
|
||||
}
|
||||
|
||||
write_event(session_id, {
|
||||
type = "recording_started",
|
||||
timestamp = get_timestamp(),
|
||||
room_jid = room_jid,
|
||||
room_name = room:get_name(),
|
||||
session_id = session_id,
|
||||
recording_mode = recording_mode,
|
||||
app_data = app_data,
|
||||
participant_count = participant_count,
|
||||
participants_at_start = participant_list
|
||||
})
|
||||
|
||||
elseif action == "stop" then
|
||||
module:log("info", "Recording STOP for session %s", session_id)
|
||||
|
||||
local recording = active_recordings[room_jid]
|
||||
if recording and recording.session_id == session_id then
|
||||
write_event(session_id, {
|
||||
type = "recording_stopped",
|
||||
timestamp = get_timestamp(),
|
||||
room_jid = room_jid,
|
||||
room_name = room:get_name(),
|
||||
session_id = session_id,
|
||||
duration = get_timestamp() - recording.started_at,
|
||||
participant_count = get_room_participant_count(room)
|
||||
})
|
||||
|
||||
active_recordings[room_jid] = nil
|
||||
else
|
||||
module:log("warn", "No active recording found for room %s", room_jid)
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
-- Room and participant event hooks
|
||||
local function setup_room_hooks(host_module)
|
||||
module:log("info", "Setting up room hooks on %s", host_module.host or "unknown")
|
||||
|
||||
-- Room created
|
||||
host_module:hook("muc-room-created", function(event)
|
||||
local room = event.room
|
||||
local room_jid = room.jid
|
||||
|
||||
room_states[room_jid] = {
|
||||
participants = {},
|
||||
created_at = get_timestamp()
|
||||
}
|
||||
|
||||
module:log("info", "Room created: %s", room_jid)
|
||||
end)
|
||||
|
||||
-- Room destroyed
|
||||
host_module:hook("muc-room-destroyed", function(event)
|
||||
local room = event.room
|
||||
local room_jid = room.jid
|
||||
|
||||
room_states[room_jid] = nil
|
||||
active_recordings[room_jid] = nil
|
||||
|
||||
module:log("info", "Room destroyed: %s", room_jid)
|
||||
end)
|
||||
|
||||
-- Occupant joined
|
||||
host_module:hook("muc-occupant-joined", function(event)
|
||||
local room = event.room
|
||||
local occupant = event.occupant
|
||||
local room_jid = room.jid
|
||||
|
||||
-- Skip recorders
|
||||
if occupant.bare_jid and (occupant.bare_jid:match("^recorder@") or
|
||||
occupant.bare_jid:match("^jibri@")) then
|
||||
return
|
||||
end
|
||||
|
||||
local participant_info = extract_participant_info(occupant)
|
||||
|
||||
-- Update room state
|
||||
if room_states[room_jid] then
|
||||
room_states[room_jid].participants[occupant.jid] = participant_info
|
||||
end
|
||||
|
||||
-- Log to active recording if exists
|
||||
local recording = active_recordings[room_jid]
|
||||
if recording then
|
||||
recording.participants[occupant.jid] = participant_info
|
||||
|
||||
write_event(recording.session_id, {
|
||||
type = "participant_joined",
|
||||
timestamp = get_timestamp(),
|
||||
room_jid = room_jid,
|
||||
room_name = room:get_name(),
|
||||
participant = participant_info,
|
||||
participant_count = get_room_participant_count(room)
|
||||
})
|
||||
end
|
||||
|
||||
module:log("info", "Participant joined %s: %s (%d total)",
|
||||
room:get_name() or room_jid,
|
||||
participant_info.display_name or participant_info.bare_jid,
|
||||
get_room_participant_count(room))
|
||||
end)
|
||||
|
||||
-- Occupant left
|
||||
host_module:hook("muc-occupant-left", function(event)
|
||||
local room = event.room
|
||||
local occupant = event.occupant
|
||||
local room_jid = room.jid
|
||||
|
||||
-- Skip recorders
|
||||
if occupant.bare_jid and (occupant.bare_jid:match("^recorder@") or
|
||||
occupant.bare_jid:match("^jibri@")) then
|
||||
return
|
||||
end
|
||||
|
||||
local participant_info = extract_participant_info(occupant)
|
||||
|
||||
-- Update room state
|
||||
if room_states[room_jid] then
|
||||
room_states[room_jid].participants[occupant.jid] = nil
|
||||
end
|
||||
|
||||
-- Log to active recording if exists
|
||||
local recording = active_recordings[room_jid]
|
||||
if recording then
|
||||
if recording.participants[occupant.jid] then
|
||||
recording.participants[occupant.jid] = nil
|
||||
end
|
||||
|
||||
write_event(recording.session_id, {
|
||||
type = "participant_left",
|
||||
timestamp = get_timestamp(),
|
||||
room_jid = room_jid,
|
||||
room_name = room:get_name(),
|
||||
participant = participant_info,
|
||||
participant_count = get_room_participant_count(room)
|
||||
})
|
||||
end
|
||||
|
||||
module:log("info", "Participant left %s: %s (%d remaining)",
|
||||
room:get_name() or room_jid,
|
||||
participant_info.display_name or participant_info.bare_jid,
|
||||
get_room_participant_count(room))
|
||||
end)
|
||||
end
|
||||
|
||||
-- Module initialization
|
||||
local current_host = module:get_host()
|
||||
local host_type = module:get_host_type()
|
||||
|
||||
module:log("info", "Event Logger loading on %s (type: %s)", current_host, host_type or "unknown")
|
||||
module:log("info", "Recording path: %s", recordings_path)
|
||||
|
||||
-- Setup room hooks based on host type
|
||||
if host_type == "component" and current_host:match("^[^.]+%.") then
|
||||
setup_room_hooks(module)
|
||||
else
|
||||
-- Try to find and hook to MUC component
|
||||
local process_host_module = util.process_host_module
|
||||
local muc_component_host = module:get_option_string("muc_component") or
|
||||
module:get_option_string("main_muc")
|
||||
|
||||
if not muc_component_host then
|
||||
local possible_hosts = {
|
||||
"muc." .. current_host,
|
||||
"conference." .. current_host,
|
||||
"rooms." .. current_host
|
||||
}
|
||||
|
||||
for _, host in ipairs(possible_hosts) do
|
||||
if prosody.hosts[host] then
|
||||
muc_component_host = host
|
||||
module:log("info", "Auto-detected MUC component: %s", muc_component_host)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if muc_component_host then
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
module:log("info", "Hooking to MUC events on %s", host)
|
||||
setup_room_hooks(host_module)
|
||||
end)
|
||||
else
|
||||
module:log("error", "Could not find MUC component")
|
||||
end
|
||||
end
|
||||
493
server/docs/platform-jitsi.md
Normal file
493
server/docs/platform-jitsi.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Jitsi Integration Configuration Guide
|
||||
|
||||
This guide provides step-by-step instructions for configuring Reflector to work with a self-hosted Jitsi Meet installation for video meetings and recording.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before configuring Jitsi integration, ensure you have:
|
||||
|
||||
- **Self-hosted Jitsi Meet installation** (version 2.0.8922 or later recommended)
|
||||
- **Jibri recording service** configured and running
|
||||
- **Prosody XMPP server** with mod_event_sync module installed
|
||||
- **Docker or system deployment** of Reflector with access to environment variables
|
||||
- **SSL certificates** for secure communication between services
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Add the following environment variables to your Reflector deployment:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```bash
|
||||
# Jitsi Meet domain (without https://)
|
||||
JITSI_DOMAIN=meet.example.com
|
||||
|
||||
# JWT secret for room authentication (generate with: openssl rand -hex 32)
|
||||
JITSI_JWT_SECRET=your-64-character-hex-secret-here
|
||||
|
||||
# Webhook secret for secure event handling (generate with: openssl rand -hex 16)
|
||||
JITSI_WEBHOOK_SECRET=your-32-character-hex-secret-here
|
||||
|
||||
# Application identifier (should match Jitsi configuration)
|
||||
JITSI_APP_ID=reflector
|
||||
|
||||
# JWT issuer and audience (should match Jitsi configuration)
|
||||
JITSI_JWT_ISSUER=reflector
|
||||
JITSI_JWT_AUDIENCE=jitsi
|
||||
```
|
||||
|
||||
### Example .env Configuration
|
||||
|
||||
```bash
|
||||
# Add to your server/.env file
|
||||
JITSI_DOMAIN=meet.mycompany.com
|
||||
JITSI_JWT_SECRET=$(openssl rand -hex 32)
|
||||
JITSI_WEBHOOK_SECRET=$(openssl rand -hex 16)
|
||||
JITSI_APP_ID=reflector
|
||||
JITSI_JWT_ISSUER=reflector
|
||||
JITSI_JWT_AUDIENCE=jitsi
|
||||
```
|
||||
|
||||
## Jitsi Meet Server Configuration
|
||||
|
||||
### 1. JWT Authentication Setup
|
||||
|
||||
Edit `/etc/prosody/conf.d/[YOUR_DOMAIN].cfg.lua`:
|
||||
|
||||
```lua
|
||||
VirtualHost "meet.example.com"
|
||||
authentication = "token"
|
||||
app_id = "reflector"
|
||||
app_secret = "your-jwt-secret-here"
|
||||
|
||||
-- Allow anonymous access for non-authenticated users
|
||||
c2s_require_encryption = false
|
||||
admins = { "focusUser@auth.meet.example.com" }
|
||||
|
||||
modules_enabled = {
|
||||
"bosh";
|
||||
"pubsub";
|
||||
"ping";
|
||||
"roster";
|
||||
"saslauth";
|
||||
"tls";
|
||||
"dialback";
|
||||
"disco";
|
||||
"carbons";
|
||||
"pep";
|
||||
"private";
|
||||
"blocklist";
|
||||
"vcard";
|
||||
"version";
|
||||
"uptime";
|
||||
"time";
|
||||
"ping";
|
||||
"register";
|
||||
"admin_adhoc";
|
||||
"token_verification";
|
||||
"event_sync"; -- Required for webhook events
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Room Access Control
|
||||
|
||||
Edit `/etc/jitsi/meet/meet.example.com-config.js`:
|
||||
|
||||
```javascript
|
||||
var config = {
|
||||
hosts: {
|
||||
domain: 'meet.example.com',
|
||||
muc: 'conference.meet.example.com'
|
||||
},
|
||||
|
||||
// Enable JWT authentication
|
||||
enableUserRolesBasedOnToken: true,
|
||||
|
||||
// Recording configuration
|
||||
fileRecordingsEnabled: true,
|
||||
liveStreamingEnabled: false,
|
||||
|
||||
// Reflector-specific settings
|
||||
prejoinPageEnabled: true,
|
||||
requireDisplayName: true,
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Interface Configuration
|
||||
|
||||
Edit `/usr/share/jitsi-meet/interface_config.js`:
|
||||
|
||||
```javascript
|
||||
var interfaceConfig = {
|
||||
// Customize for Reflector branding
|
||||
APP_NAME: 'Reflector Meeting',
|
||||
DEFAULT_WELCOME_PAGE_LOGO_URL: 'https://your-domain.com/logo.png',
|
||||
|
||||
// Hide unnecessary buttons
|
||||
TOOLBAR_BUTTONS: [
|
||||
'microphone', 'camera', 'closedcaptions', 'desktop',
|
||||
'fullscreen', 'fodeviceselection', 'hangup',
|
||||
'chat', 'recording', 'livestreaming', 'etherpad',
|
||||
'sharedvideo', 'settings', 'raisehand', 'videoquality',
|
||||
'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts',
|
||||
'tileview', 'videobackgroundblur', 'download', 'help',
|
||||
'mute-everyone'
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## Jibri Configuration
|
||||
|
||||
### 1. Recording Service Setup
|
||||
|
||||
Edit `/etc/jitsi/jibri/jibri.conf`:
|
||||
|
||||
```hocon
|
||||
jibri {
|
||||
recording {
|
||||
recordings-directory = "/var/recordings"
|
||||
finalize-script = "/opt/jitsi/jibri/finalize.sh"
|
||||
}
|
||||
|
||||
api {
|
||||
xmpp {
|
||||
environments = [{
|
||||
name = "prod environment"
|
||||
xmpp-server-hosts = ["meet.example.com"]
|
||||
xmpp-domain = "meet.example.com"
|
||||
|
||||
control-muc {
|
||||
domain = "internal.auth.meet.example.com"
|
||||
room-name = "JibriBrewery"
|
||||
nickname = "jibri-nickname"
|
||||
}
|
||||
|
||||
control-login {
|
||||
domain = "auth.meet.example.com"
|
||||
username = "jibri"
|
||||
password = "jibri-password"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Finalize Script Setup
|
||||
|
||||
Create `/opt/jitsi/jibri/finalize.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Jibri finalize script for Reflector integration
|
||||
|
||||
RECORDING_FILE="$1"
|
||||
ROOM_NAME="$2"
|
||||
REFLECTOR_API_URL="${REFLECTOR_API_URL:-http://localhost:1250}"
|
||||
WEBHOOK_SECRET="${JITSI_WEBHOOK_SECRET}"
|
||||
|
||||
# Generate webhook signature
|
||||
generate_signature() {
|
||||
local payload="$1"
|
||||
echo -n "$payload" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d' ' -f2
|
||||
}
|
||||
|
||||
# Prepare webhook payload
|
||||
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)
|
||||
PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"room_name": "$ROOM_NAME",
|
||||
"recording_file": "$RECORDING_FILE",
|
||||
"recording_status": "completed",
|
||||
"timestamp": "$TIMESTAMP"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Generate signature
|
||||
SIGNATURE=$(generate_signature "$PAYLOAD")
|
||||
|
||||
# Send webhook to Reflector
|
||||
curl -X POST "$REFLECTOR_API_URL/v1/jibri/recording-complete" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: $SIGNATURE" \
|
||||
-d "$PAYLOAD" \
|
||||
--max-time 30
|
||||
|
||||
echo "Recording finalization webhook sent for room: $ROOM_NAME"
|
||||
```
|
||||
|
||||
Make the script executable:
|
||||
|
||||
```bash
|
||||
chmod +x /opt/jitsi/jibri/finalize.sh
|
||||
```
|
||||
|
||||
## Prosody Event Configuration
|
||||
|
||||
### 1. Event-Sync Module Installation
|
||||
|
||||
Install the mod_event_sync module:
|
||||
|
||||
```bash
|
||||
# Download the module
|
||||
cd /usr/share/jitsi-meet/prosody-plugins/
|
||||
wget https://raw.githubusercontent.com/jitsi-contrib/prosody-plugins/main/mod_event_sync.lua
|
||||
|
||||
# Or if using git
|
||||
git clone https://github.com/jitsi-contrib/prosody-plugins.git
|
||||
cp prosody-plugins/mod_event_sync.lua /usr/share/jitsi-meet/prosody-plugins/
|
||||
```
|
||||
|
||||
### 2. Webhook Configuration
|
||||
|
||||
Add to `/etc/prosody/conf.d/[YOUR_DOMAIN].cfg.lua`:
|
||||
|
||||
```lua
|
||||
Component "conference.meet.example.com" "muc"
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"muc_meeting_id";
|
||||
"muc_domain_mapper";
|
||||
"polls";
|
||||
"event_sync"; -- Enable event sync
|
||||
}
|
||||
|
||||
-- Event sync webhook configuration
|
||||
event_sync_url = "https://your-reflector-domain.com/v1/jitsi/events"
|
||||
event_sync_secret = "your-webhook-secret-here"
|
||||
|
||||
-- Events to track
|
||||
event_sync_events = {
|
||||
"muc-occupant-joined",
|
||||
"muc-occupant-left",
|
||||
"jibri-recording-on",
|
||||
"jibri-recording-off"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Restart Services
|
||||
|
||||
After configuration changes, restart all services:
|
||||
|
||||
```bash
|
||||
systemctl restart prosody
|
||||
systemctl restart jicofo
|
||||
systemctl restart jitsi-videobridge2
|
||||
systemctl restart jibri
|
||||
systemctl restart nginx
|
||||
```
|
||||
|
||||
## Reflector Room Configuration
|
||||
|
||||
### 1. Create Jitsi Room
|
||||
|
||||
When creating rooms in Reflector, set the platform field:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-jitsi-room",
|
||||
"platform": "jitsi",
|
||||
"recording_type": "cloud",
|
||||
"recording_trigger": "automatic-2nd-participant",
|
||||
"is_locked": false,
|
||||
"room_mode": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Meeting Creation
|
||||
|
||||
Meetings will automatically use Jitsi when the room platform is set to "jitsi":
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms/my-jitsi-room/meeting" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN"
|
||||
```
|
||||
|
||||
## Testing the Integration
|
||||
|
||||
### 1. Health Check
|
||||
|
||||
Verify Jitsi webhook configuration:
|
||||
|
||||
```bash
|
||||
curl "https://your-reflector-domain.com/v1/jitsi/health"
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "jitsi-webhooks",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"webhook_secret_configured": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Room Creation Test
|
||||
|
||||
1. Create a Jitsi room via Reflector API
|
||||
2. Start a meeting - should generate Jitsi Meet URL with JWT token
|
||||
3. Join with multiple participants - should trigger participant events
|
||||
4. Start recording - should trigger Jibri recording workflow
|
||||
|
||||
### 3. Webhook Event Test
|
||||
|
||||
Monitor Reflector logs for incoming webhook events:
|
||||
|
||||
```bash
|
||||
# Check for participant events
|
||||
curl -X POST "https://your-reflector-domain.com/v1/jitsi/events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Jitsi-Signature: test-signature" \
|
||||
-d '{
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "test-room-name",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {}
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### JWT Authentication Failures
|
||||
|
||||
**Symptoms:** Users can't join rooms, "Authentication failed" errors
|
||||
|
||||
**Solutions:**
|
||||
1. Verify JWT secret matches between Jitsi and Reflector
|
||||
2. Check JWT token expiration (default 8 hours)
|
||||
3. Ensure system clocks are synchronized
|
||||
4. Validate JWT issuer/audience configuration
|
||||
|
||||
```bash
|
||||
# Debug JWT tokens
|
||||
echo "JWT_TOKEN_HERE" | cut -d'.' -f2 | base64 -d | jq
|
||||
```
|
||||
|
||||
#### Webhook Events Not Received
|
||||
|
||||
**Symptoms:** Participant counts not updating, recording events missing
|
||||
|
||||
**Solutions:**
|
||||
1. Verify event_sync module is loaded in Prosody
|
||||
2. Check webhook URL accessibility from Jitsi server
|
||||
3. Validate webhook signature generation
|
||||
4. Review Prosody and Reflector logs
|
||||
|
||||
```bash
|
||||
# Test webhook connectivity
|
||||
curl -v "https://your-reflector-domain.com/v1/jitsi/health"
|
||||
|
||||
# Check Prosody logs
|
||||
tail -f /var/log/prosody/prosody.log
|
||||
|
||||
# Check Reflector logs
|
||||
docker logs your-reflector-container
|
||||
```
|
||||
|
||||
#### Recording Issues
|
||||
|
||||
**Symptoms:** Recordings not starting, finalize script errors
|
||||
|
||||
**Solutions:**
|
||||
1. Verify Jibri service status and configuration
|
||||
2. Check recording directory permissions
|
||||
3. Validate finalize script execution permissions
|
||||
4. Monitor Jibri logs for errors
|
||||
|
||||
```bash
|
||||
# Check Jibri status
|
||||
systemctl status jibri
|
||||
|
||||
# Test finalize script
|
||||
sudo -u jibri /opt/jitsi/jibri/finalize.sh "/test/recording.mp4" "test-room"
|
||||
|
||||
# Check Jibri logs
|
||||
journalctl -u jibri -f
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Verify Jitsi configuration
|
||||
prosodyctl check config
|
||||
|
||||
# Test JWT generation
|
||||
curl -X POST "https://your-reflector-domain.com/v1/rooms/test/meeting" \
|
||||
-H "Authorization: Bearer $TOKEN" -v
|
||||
|
||||
# Monitor webhook events
|
||||
tail -f /var/log/reflector/app.log | grep jitsi
|
||||
|
||||
# Check room participant counts
|
||||
curl "https://your-reflector-domain.com/v1/rooms" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '.data[].num_clients'
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
#### For High-Concurrent Usage
|
||||
|
||||
1. **Jitsi Videobridge Tuning:**
|
||||
```bash
|
||||
# /etc/jitsi/videobridge/sip-communicator.properties
|
||||
org.jitsi.videobridge.STATISTICS_INTERVAL=5000
|
||||
org.jitsi.videobridge.load.INITIAL_STREAM_LIMIT=50
|
||||
```
|
||||
|
||||
2. **Database Connection Pooling:**
|
||||
```python
|
||||
# In your Reflector settings
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=30
|
||||
```
|
||||
|
||||
3. **Redis Configuration:**
|
||||
```bash
|
||||
# For webhook event caching
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
WEBHOOK_EVENT_TTL=3600
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Network Security
|
||||
- Use HTTPS/WSS for all communications
|
||||
- Implement proper firewall rules
|
||||
- Consider VPN for server-to-server communication
|
||||
|
||||
### Authentication Security
|
||||
- Rotate JWT secrets regularly
|
||||
- Use strong webhook secrets (32+ characters)
|
||||
- Implement rate limiting on webhook endpoints
|
||||
|
||||
### Recording Security
|
||||
- Encrypt recordings at rest
|
||||
- Implement access controls for recording files
|
||||
- Regular security audits of file permissions
|
||||
|
||||
## Support
|
||||
|
||||
For additional support:
|
||||
|
||||
1. **Reflector Issues:** Check GitHub issues or create new ones
|
||||
2. **Jitsi Community:** [Community Forum](https://community.jitsi.org/)
|
||||
3. **Documentation:** [Jitsi Developer Guide](https://jitsi.github.io/handbook/)
|
||||
|
||||
## Migration from Whereby
|
||||
|
||||
If migrating from Whereby integration:
|
||||
|
||||
1. Update existing rooms to use "jitsi" platform
|
||||
2. Verify webhook configurations are updated
|
||||
3. Test recording workflows thoroughly
|
||||
4. Monitor participant event accuracy
|
||||
5. Update any custom integrations using meeting APIs
|
||||
|
||||
The platform abstraction layer ensures smooth migration with minimal API changes.
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add events column to meetings table
|
||||
|
||||
Revision ID: 2890b5104577
|
||||
Revises: 6e6ea8e607c5
|
||||
Create Date: 2025-09-02 17:51:41.620777
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2890b5104577"
|
||||
down_revision: Union[str, None] = "6e6ea8e607c5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"events", sa.JSON(), server_default=sa.text("'[]'"), nullable=False
|
||||
)
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.drop_column("events")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add VideoPlatform enum for rooms and meetings
|
||||
|
||||
Revision ID: 6e6ea8e607c5
|
||||
Revises: 61882a919591
|
||||
Create Date: 2025-09-02 17:33:21.022214
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6e6ea8e607c5"
|
||||
down_revision: Union[str, None] = "61882a919591"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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)
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform")
|
||||
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -40,6 +40,7 @@ dependencies = [
|
||||
"llama-index-llms-openai-like>=0.4.0",
|
||||
"pytest-env>=1.1.5",
|
||||
"webvtt-py>=0.5.0",
|
||||
"PyJWT>=2.8.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -12,6 +12,9 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
||||
from reflector.logger import logger
|
||||
from reflector.metrics import metrics_init
|
||||
from reflector.settings import settings
|
||||
from reflector.video_platforms.jitsi import router as jitsi_router
|
||||
from reflector.video_platforms.whereby import router as whereby_router
|
||||
from reflector.views.jibri_webhook import router as jibri_webhook_router
|
||||
from reflector.views.meetings import router as meetings_router
|
||||
from reflector.views.rooms import router as rooms_router
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
@@ -26,7 +29,6 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
|
||||
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
|
||||
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
|
||||
from reflector.views.user import router as user_router
|
||||
from reflector.views.whereby import router as whereby_router
|
||||
from reflector.views.zulip import router as zulip_router
|
||||
|
||||
try:
|
||||
@@ -86,6 +88,8 @@ app.include_router(transcripts_process_router, prefix="/v1")
|
||||
app.include_router(user_router, prefix="/v1")
|
||||
app.include_router(zulip_router, prefix="/v1")
|
||||
app.include_router(whereby_router, prefix="/v1")
|
||||
app.include_router(jitsi_router, prefix="/v1")
|
||||
app.include_router(jibri_webhook_router) # No /v1 prefix, uses /api/v1/jibri
|
||||
add_pagination(app)
|
||||
|
||||
# prepare celery
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from reflector.db import get_database, metadata
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.db.rooms import Room, VideoPlatform
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
meetings = sa.Table(
|
||||
@@ -44,6 +44,8 @@ meetings = sa.Table(
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
sa.Column("platform", sa.String, nullable=False, server_default="whereby"),
|
||||
sa.Column("events", sa.JSON, nullable=False, server_default=sa.text("'[]'")),
|
||||
sa.Index("idx_meeting_room_id", "room_id"),
|
||||
sa.Index(
|
||||
"idx_one_active_meeting_per_room",
|
||||
@@ -92,6 +94,8 @@ class Meeting(BaseModel):
|
||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||
] = "automatic-2nd-participant"
|
||||
num_clients: int = 0
|
||||
platform: VideoPlatform = VideoPlatform.WHEREBY
|
||||
events: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MeetingController:
|
||||
@@ -117,6 +121,7 @@ class MeetingController:
|
||||
room_mode=room.room_mode,
|
||||
recording_type=room.recording_type,
|
||||
recording_trigger=room.recording_trigger,
|
||||
platform=room.platform,
|
||||
)
|
||||
query = meetings.insert().values(**meeting.model_dump())
|
||||
await get_database().execute(query)
|
||||
@@ -167,6 +172,68 @@ class MeetingController:
|
||||
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
|
||||
await get_database().execute(query)
|
||||
|
||||
async def add_event(
|
||||
self, meeting_id: str, event_type: str, event_data: Dict[str, Any] = None
|
||||
):
|
||||
"""Add an event to a meeting's events list."""
|
||||
if event_data is None:
|
||||
event_data = {}
|
||||
|
||||
event = {
|
||||
"type": event_type,
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"data": event_data,
|
||||
}
|
||||
|
||||
# Get current events
|
||||
query = meetings.select().where(meetings.c.id == meeting_id)
|
||||
result = await get_database().fetch_one(query)
|
||||
if not result:
|
||||
return
|
||||
|
||||
current_events = result["events"] or []
|
||||
current_events.append(event)
|
||||
|
||||
# Update with new events list
|
||||
update_query = (
|
||||
meetings.update()
|
||||
.where(meetings.c.id == meeting_id)
|
||||
.values(events=current_events)
|
||||
)
|
||||
await get_database().execute(update_query)
|
||||
|
||||
async def participant_joined(
|
||||
self, meeting_id: str, participant_data: Dict[str, Any] = None
|
||||
):
|
||||
"""Record a participant joined event."""
|
||||
await self.add_event(meeting_id, "participant_joined", participant_data)
|
||||
|
||||
async def participant_left(
|
||||
self, meeting_id: str, participant_data: Dict[str, Any] = None
|
||||
):
|
||||
"""Record a participant left event."""
|
||||
await self.add_event(meeting_id, "participant_left", participant_data)
|
||||
|
||||
async def recording_started(
|
||||
self, meeting_id: str, recording_data: Dict[str, Any] = None
|
||||
):
|
||||
"""Record a recording started event."""
|
||||
await self.add_event(meeting_id, "recording_started", recording_data)
|
||||
|
||||
async def recording_stopped(
|
||||
self, meeting_id: str, recording_data: Dict[str, Any] = None
|
||||
):
|
||||
"""Record a recording stopped event."""
|
||||
await self.add_event(meeting_id, "recording_stopped", recording_data)
|
||||
|
||||
async def get_events(self, meeting_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all events for a meeting."""
|
||||
query = meetings.select().where(meetings.c.id == meeting_id)
|
||||
result = await get_database().fetch_one(query)
|
||||
if not result:
|
||||
return []
|
||||
return result["events"] or []
|
||||
|
||||
|
||||
class MeetingConsentController:
|
||||
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from enum import StrEnum
|
||||
from sqlite3 import IntegrityError
|
||||
from typing import Literal
|
||||
|
||||
@@ -11,6 +12,12 @@ from sqlalchemy.sql import false, or_
|
||||
from reflector.db import get_database, metadata
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
|
||||
class VideoPlatform(StrEnum):
|
||||
WHEREBY = "whereby"
|
||||
JITSI = "jitsi"
|
||||
|
||||
|
||||
rooms = sqlalchemy.Table(
|
||||
"room",
|
||||
metadata,
|
||||
@@ -43,6 +50,9 @@ rooms = sqlalchemy.Table(
|
||||
),
|
||||
sqlalchemy.Column("webhook_url", sqlalchemy.String, nullable=True),
|
||||
sqlalchemy.Column("webhook_secret", sqlalchemy.String, nullable=True),
|
||||
sqlalchemy.Column(
|
||||
"platform", sqlalchemy.String, nullable=False, server_default="whereby"
|
||||
),
|
||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||
)
|
||||
|
||||
@@ -64,6 +74,7 @@ class Room(BaseModel):
|
||||
is_shared: bool = False
|
||||
webhook_url: str | None = None
|
||||
webhook_secret: str | None = None
|
||||
platform: VideoPlatform = VideoPlatform.WHEREBY
|
||||
|
||||
|
||||
class RoomController:
|
||||
@@ -114,6 +125,7 @@ class RoomController:
|
||||
is_shared: bool,
|
||||
webhook_url: str = "",
|
||||
webhook_secret: str = "",
|
||||
platform: str = "whereby",
|
||||
):
|
||||
"""
|
||||
Add a new room
|
||||
@@ -134,6 +146,7 @@ class RoomController:
|
||||
is_shared=is_shared,
|
||||
webhook_url=webhook_url,
|
||||
webhook_secret=webhook_secret,
|
||||
platform=platform,
|
||||
)
|
||||
query = rooms.insert().values(**room.model_dump())
|
||||
try:
|
||||
|
||||
227
server/reflector/jibri_events.py
Normal file
227
server/reflector/jibri_events.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
jid: str
|
||||
nick: str
|
||||
id: str
|
||||
is_moderator: bool = False
|
||||
|
||||
|
||||
class ParticipantLeftInfo(BaseModel):
|
||||
jid: str
|
||||
nick: Optional[str] = None
|
||||
duration_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class RoomCreatedEvent(BaseModel):
|
||||
type: Literal["room_created"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
room_jid: str
|
||||
meeting_url: str
|
||||
|
||||
|
||||
class RecordingStartedEvent(BaseModel):
|
||||
type: Literal["recording_started"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
session_id: str
|
||||
jibri_jid: str
|
||||
|
||||
|
||||
class RecordingStoppedEvent(BaseModel):
|
||||
type: Literal["recording_stopped"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
session_id: str
|
||||
meeting_url: str
|
||||
|
||||
|
||||
class ParticipantJoinedEvent(BaseModel):
|
||||
type: Literal["participant_joined"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
participant: ParticipantInfo
|
||||
|
||||
|
||||
class ParticipantLeftEvent(BaseModel):
|
||||
type: Literal["participant_left"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
participant: ParticipantLeftInfo
|
||||
|
||||
|
||||
class SpeakerActiveEvent(BaseModel):
|
||||
type: Literal["speaker_active"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
speaker_jid: str
|
||||
speaker_nick: str
|
||||
duration: int
|
||||
|
||||
|
||||
class DominantSpeakerChangedEvent(BaseModel):
|
||||
type: Literal["dominant_speaker_changed"]
|
||||
timestamp: int
|
||||
room_name: str
|
||||
previous: str
|
||||
current: str
|
||||
|
||||
|
||||
JitsiEvent = Union[
|
||||
RoomCreatedEvent,
|
||||
RecordingStartedEvent,
|
||||
RecordingStoppedEvent,
|
||||
ParticipantJoinedEvent,
|
||||
ParticipantLeftEvent,
|
||||
SpeakerActiveEvent,
|
||||
DominantSpeakerChangedEvent,
|
||||
]
|
||||
|
||||
|
||||
class RoomInfo(TypedDict):
|
||||
name: str
|
||||
jid: str
|
||||
created_at: int
|
||||
meeting_url: str
|
||||
recording_stopped_at: Optional[int]
|
||||
|
||||
|
||||
class ParticipantData(TypedDict):
|
||||
jid: str
|
||||
nick: str
|
||||
id: str
|
||||
is_moderator: bool
|
||||
joined_at: int
|
||||
left_at: Optional[int]
|
||||
duration: Optional[int]
|
||||
events: List[str]
|
||||
|
||||
|
||||
class SpeakerStats(TypedDict):
|
||||
total_time: int
|
||||
nick: str
|
||||
|
||||
|
||||
class ParsedMetadata(TypedDict):
|
||||
room: RoomInfo
|
||||
participants: List[ParticipantData]
|
||||
speaker_stats: Dict[str, SpeakerStats]
|
||||
event_count: int
|
||||
|
||||
|
||||
class JitsiEventParser:
|
||||
def parse_event(self, event_data: Dict[str, Any]) -> Optional[JitsiEvent]:
|
||||
event_type = event_data.get("type")
|
||||
|
||||
try:
|
||||
if event_type == "room_created":
|
||||
return RoomCreatedEvent(**event_data)
|
||||
elif event_type == "recording_started":
|
||||
return RecordingStartedEvent(**event_data)
|
||||
elif event_type == "recording_stopped":
|
||||
return RecordingStoppedEvent(**event_data)
|
||||
elif event_type == "participant_joined":
|
||||
return ParticipantJoinedEvent(**event_data)
|
||||
elif event_type == "participant_left":
|
||||
return ParticipantLeftEvent(**event_data)
|
||||
elif event_type == "speaker_active":
|
||||
return SpeakerActiveEvent(**event_data)
|
||||
elif event_type == "dominant_speaker_changed":
|
||||
return DominantSpeakerChangedEvent(**event_data)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def parse_events_file(self, recording_path: str) -> ParsedMetadata:
|
||||
events_file = Path(recording_path) / "events.jsonl"
|
||||
|
||||
room_info: RoomInfo = {
|
||||
"name": "",
|
||||
"jid": "",
|
||||
"created_at": 0,
|
||||
"meeting_url": "",
|
||||
"recording_stopped_at": None,
|
||||
}
|
||||
|
||||
if not events_file.exists():
|
||||
return ParsedMetadata(
|
||||
room=room_info, participants=[], speaker_stats={}, event_count=0
|
||||
)
|
||||
|
||||
events: List[JitsiEvent] = []
|
||||
participants: Dict[str, ParticipantData] = {}
|
||||
speaker_stats: Dict[str, SpeakerStats] = {}
|
||||
|
||||
with open(events_file, "r") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event_data = json.loads(line)
|
||||
event = self.parse_event(event_data)
|
||||
|
||||
if event is None:
|
||||
continue
|
||||
|
||||
events.append(event)
|
||||
|
||||
if isinstance(event, RoomCreatedEvent):
|
||||
room_info = {
|
||||
"name": event.room_name,
|
||||
"jid": event.room_jid,
|
||||
"created_at": event.timestamp,
|
||||
"meeting_url": event.meeting_url,
|
||||
"recording_stopped_at": None,
|
||||
}
|
||||
|
||||
elif isinstance(event, ParticipantJoinedEvent):
|
||||
participants[event.participant.id] = {
|
||||
"jid": event.participant.jid,
|
||||
"nick": event.participant.nick,
|
||||
"id": event.participant.id,
|
||||
"is_moderator": event.participant.is_moderator,
|
||||
"joined_at": event.timestamp,
|
||||
"left_at": None,
|
||||
"duration": None,
|
||||
"events": ["joined"],
|
||||
}
|
||||
|
||||
elif isinstance(event, ParticipantLeftEvent):
|
||||
participant_id = event.participant.jid.split("/")[0]
|
||||
if participant_id in participants:
|
||||
participants[participant_id]["left_at"] = event.timestamp
|
||||
participants[participant_id]["duration"] = (
|
||||
event.participant.duration_seconds
|
||||
)
|
||||
participants[participant_id]["events"].append("left")
|
||||
|
||||
elif isinstance(event, SpeakerActiveEvent):
|
||||
if event.speaker_jid not in speaker_stats:
|
||||
speaker_stats[event.speaker_jid] = {
|
||||
"total_time": 0,
|
||||
"nick": event.speaker_nick,
|
||||
}
|
||||
speaker_stats[event.speaker_jid]["total_time"] += event.duration
|
||||
|
||||
elif isinstance(event, RecordingStoppedEvent):
|
||||
room_info["recording_stopped_at"] = event.timestamp
|
||||
room_info["meeting_url"] = event.meeting_url
|
||||
|
||||
except (json.JSONDecodeError, Exception):
|
||||
continue
|
||||
|
||||
return ParsedMetadata(
|
||||
room=room_info,
|
||||
participants=list(participants.values()),
|
||||
speaker_stats=speaker_stats,
|
||||
event_count=len(events),
|
||||
)
|
||||
@@ -123,12 +123,23 @@ class Settings(BaseSettings):
|
||||
# Whereby integration
|
||||
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
|
||||
WHEREBY_API_KEY: NonEmptyString | None = None
|
||||
|
||||
# Jibri integration
|
||||
JIBRI_RECORDINGS_PATH: str = "/recordings"
|
||||
WHEREBY_WEBHOOK_SECRET: str | None = None
|
||||
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
|
||||
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
|
||||
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
|
||||
SQS_POLLING_TIMEOUT_SECONDS: int = 60
|
||||
|
||||
# Jitsi Meet
|
||||
JITSI_DOMAIN: str = "meet.jit.si"
|
||||
JITSI_JWT_SECRET: str | None = None
|
||||
JITSI_WEBHOOK_SECRET: str | None = None
|
||||
JITSI_APP_ID: str = "reflector"
|
||||
JITSI_JWT_ISSUER: str = "reflector"
|
||||
JITSI_JWT_AUDIENCE: str = "jitsi"
|
||||
|
||||
# Zulip integration
|
||||
ZULIP_REALM: 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}
|
||||
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 TYPE_CHECKING, Literal, Optional, overload
|
||||
|
||||
from reflector.db.rooms import VideoPlatform
|
||||
from reflector.settings import settings
|
||||
|
||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||
from .registry import get_platform_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .jitsi import JitsiClient
|
||||
from .whereby import WherebyClient
|
||||
|
||||
|
||||
def get_platform_config(platform: str) -> VideoPlatformConfig:
|
||||
"""Get configuration for a specific platform."""
|
||||
if platform == VideoPlatform.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.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||
aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||
aws_access_key_secret=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||
)
|
||||
elif platform == VideoPlatform.JITSI:
|
||||
return VideoPlatformConfig(
|
||||
api_key="", # Jitsi uses JWT, no API key
|
||||
webhook_secret=settings.JITSI_WEBHOOK_SECRET or "",
|
||||
api_url=f"https://{settings.JITSI_DOMAIN}",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
|
||||
@overload
|
||||
def create_platform_client(platform: Literal["jitsi"]) -> "JitsiClient": ...
|
||||
|
||||
|
||||
@overload
|
||||
def create_platform_client(platform: Literal["whereby"]) -> "WherebyClient": ...
|
||||
|
||||
|
||||
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."""
|
||||
# For now, default to whereby since we don't have feature flags yet
|
||||
return VideoPlatform.WHEREBY
|
||||
4
server/reflector/video_platforms/jitsi/__init__.py
Normal file
4
server/reflector/video_platforms/jitsi/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .client import JitsiClient, JitsiMeetingData
|
||||
from .router import router
|
||||
|
||||
__all__ = ["JitsiClient", "JitsiMeetingData", "router"]
|
||||
111
server/reflector/video_platforms/jitsi/client.py
Normal file
111
server/reflector/video_platforms/jitsi/client.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import jwt
|
||||
|
||||
from reflector.db.rooms import Room, VideoPlatform
|
||||
from reflector.settings import settings
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
from ..base import MeetingData, VideoPlatformClient
|
||||
|
||||
|
||||
class JitsiMeetingData(MeetingData):
|
||||
@property
|
||||
def user_jwt(self) -> str:
|
||||
return self.extra_data.get("user_jwt", "")
|
||||
|
||||
@property
|
||||
def host_jwt(self) -> str:
|
||||
return self.extra_data.get("host_jwt", "")
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
return self.extra_data.get("domain", "")
|
||||
|
||||
|
||||
class JitsiClient(VideoPlatformClient):
|
||||
PLATFORM_NAME = VideoPlatform.JITSI
|
||||
|
||||
def _generate_jwt(self, room: str, moderator: bool, exp: datetime) -> str:
|
||||
if not settings.JITSI_JWT_SECRET:
|
||||
raise ValueError("JITSI_JWT_SECRET is required for JWT generation")
|
||||
|
||||
payload = {
|
||||
"aud": settings.JITSI_JWT_AUDIENCE,
|
||||
"iss": settings.JITSI_JWT_ISSUER,
|
||||
"sub": settings.JITSI_DOMAIN,
|
||||
"room": room,
|
||||
"exp": int(exp.timestamp()),
|
||||
"context": {
|
||||
"user": {
|
||||
"name": "Reflector User",
|
||||
"moderator": moderator,
|
||||
},
|
||||
"features": {
|
||||
"recording": True,
|
||||
"livestreaming": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return jwt.encode(payload, settings.JITSI_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: Room
|
||||
) -> JitsiMeetingData:
|
||||
jitsi_room = f"reflector-{room.name}-{generate_uuid4()}"
|
||||
|
||||
user_jwt = self._generate_jwt(room=jitsi_room, moderator=False, exp=end_date)
|
||||
host_jwt = self._generate_jwt(room=jitsi_room, moderator=True, exp=end_date)
|
||||
|
||||
room_url = f"https://{settings.JITSI_DOMAIN}/{jitsi_room}?jwt={user_jwt}"
|
||||
host_room_url = f"https://{settings.JITSI_DOMAIN}/{jitsi_room}?jwt={host_jwt}"
|
||||
|
||||
return JitsiMeetingData(
|
||||
meeting_id=generate_uuid4(),
|
||||
room_name=jitsi_room,
|
||||
room_url=room_url,
|
||||
host_room_url=host_room_url,
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data={
|
||||
"user_jwt": user_jwt,
|
||||
"host_jwt": host_jwt,
|
||||
"domain": settings.JITSI_DOMAIN,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"roomName": room_name,
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": generate_uuid4(),
|
||||
"startTime": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"participants": [],
|
||||
"isActive": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def delete_room(self, room_name: str) -> bool:
|
||||
return True
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
return True
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
if not signature or not self.config.webhook_secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
expected = hmac.new(
|
||||
self.config.webhook_secret.encode(), body, sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
except Exception:
|
||||
return False
|
||||
165
server/reflector/video_platforms/jitsi/router.py
Normal file
165
server/reflector/video_platforms/jitsi/router.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
try:
|
||||
from reflector.video_platforms import create_platform_client
|
||||
except ImportError:
|
||||
# PyJWT not yet installed, will be added in final task
|
||||
def create_platform_client(platform: str):
|
||||
return None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class JitsiWebhookEvent(BaseModel):
|
||||
event: str
|
||||
room: str
|
||||
timestamp: datetime
|
||||
data: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class JibriRecordingEvent(BaseModel):
|
||||
room_name: str
|
||||
recording_file: str
|
||||
recording_status: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
def verify_jitsi_webhook_signature(body: bytes, signature: str) -> bool:
|
||||
"""Verify Jitsi webhook signature using HMAC-SHA256."""
|
||||
if not signature or not settings.JITSI_WEBHOOK_SECRET:
|
||||
return False
|
||||
|
||||
try:
|
||||
client = create_platform_client("jitsi")
|
||||
if client is None:
|
||||
# Fallback verification when platform client not available
|
||||
expected = hmac.new(
|
||||
settings.JITSI_WEBHOOK_SECRET.encode(), body, sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
return client.verify_webhook_signature(body, signature)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/jitsi/events")
|
||||
async def jitsi_events_webhook(event: JitsiWebhookEvent, request: Request):
|
||||
"""
|
||||
Handle Prosody event-sync webhooks from Jitsi Meet.
|
||||
|
||||
Expected event types:
|
||||
- muc-occupant-joined: participant joined the room
|
||||
- muc-occupant-left: participant left the room
|
||||
- jibri-recording-on: recording started
|
||||
- jibri-recording-off: recording stopped
|
||||
"""
|
||||
# Verify webhook signature
|
||||
body = await request.body()
|
||||
signature = request.headers.get("x-jitsi-signature", "")
|
||||
|
||||
if not verify_jitsi_webhook_signature(body, signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
# Find meeting by room name
|
||||
meeting = await meetings_controller.get_by_room_name(event.room)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
|
||||
# Handle participant events
|
||||
if event.event == "muc-occupant-joined":
|
||||
# Store event and update participant count
|
||||
await meetings_controller.participant_joined(
|
||||
meeting.id, {"timestamp": event.timestamp, "data": event.data}
|
||||
)
|
||||
current_count = getattr(meeting, "num_clients", 0)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id, num_clients=current_count + 1
|
||||
)
|
||||
elif event.event == "muc-occupant-left":
|
||||
# Store event and update participant count
|
||||
await meetings_controller.participant_left(
|
||||
meeting.id, {"timestamp": event.timestamp, "data": event.data}
|
||||
)
|
||||
current_count = getattr(meeting, "num_clients", 0)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id, num_clients=max(0, current_count - 1)
|
||||
)
|
||||
elif event.event == "jibri-recording-on":
|
||||
# Store recording started event
|
||||
await meetings_controller.recording_started(
|
||||
meeting.id, {"timestamp": event.timestamp, "data": event.data}
|
||||
)
|
||||
elif event.event == "jibri-recording-off":
|
||||
# Store recording stopped event
|
||||
await meetings_controller.recording_stopped(
|
||||
meeting.id, {"timestamp": event.timestamp, "data": event.data}
|
||||
)
|
||||
|
||||
return {"status": "ok", "event": event.event, "room": event.room}
|
||||
|
||||
|
||||
@router.post("/jibri/recording-complete")
|
||||
async def jibri_recording_complete(event: JibriRecordingEvent, request: Request):
|
||||
"""
|
||||
Handle Jibri recording completion webhook.
|
||||
|
||||
This endpoint is called by the Jibri finalize script when a recording
|
||||
is completed and uploaded to storage.
|
||||
"""
|
||||
# Verify webhook signature
|
||||
body = await request.body()
|
||||
signature = request.headers.get("x-jitsi-signature", "")
|
||||
|
||||
if not verify_jitsi_webhook_signature(body, signature):
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
# Find meeting by room name
|
||||
meeting = await meetings_controller.get_by_room_name(event.room_name)
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
|
||||
# Store recording completion event
|
||||
await meetings_controller.add_event(
|
||||
meeting.id,
|
||||
"recording_completed",
|
||||
{
|
||||
"recording_file": event.recording_file,
|
||||
"recording_status": event.recording_status,
|
||||
"timestamp": event.timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
# TODO: Trigger recording processing pipeline
|
||||
# This is where we would:
|
||||
# 1. Download the recording file from Jibri storage
|
||||
# 2. Create a transcript record in the database
|
||||
# 3. Queue the audio processing tasks (chunking, transcription, etc.)
|
||||
# 4. Update meeting status to indicate recording is being processed
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"room_name": event.room_name,
|
||||
"recording_file": event.recording_file,
|
||||
"message": "Recording processing queued",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jitsi/health")
|
||||
async def jitsi_health_check():
|
||||
"""Simple health check endpoint for Jitsi webhook configuration."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "jitsi-webhooks",
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"webhook_secret_configured": bool(settings.JITSI_WEBHOOK_SECRET),
|
||||
}
|
||||
3
server/reflector/video_platforms/jitsi/tasks.py
Normal file
3
server/reflector/video_platforms/jitsi/tasks.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Jitsi-specific worker tasks."""
|
||||
|
||||
# Placeholder for Jitsi recording tasks
|
||||
56
server/reflector/video_platforms/registry.py
Normal file
56
server/reflector/video_platforms/registry.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from typing import TYPE_CHECKING, Dict, Literal, Type, overload
|
||||
|
||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .jitsi import JitsiClient
|
||||
from .whereby import WherebyClient
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@overload
|
||||
def get_platform_client(
|
||||
platform: Literal["jitsi"], config: VideoPlatformConfig
|
||||
) -> "JitsiClient": ...
|
||||
|
||||
|
||||
@overload
|
||||
def get_platform_client(
|
||||
platform: Literal["whereby"], config: VideoPlatformConfig
|
||||
) -> "WherebyClient": ...
|
||||
|
||||
|
||||
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 .jitsi import JitsiClient
|
||||
from .whereby import WherebyClient
|
||||
|
||||
register_platform("jitsi", JitsiClient)
|
||||
register_platform("whereby", WherebyClient)
|
||||
|
||||
|
||||
_register_builtin_platforms()
|
||||
6
server/reflector/video_platforms/whereby/__init__.py
Normal file
6
server/reflector/video_platforms/whereby/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Whereby video platform integration."""
|
||||
|
||||
from .client import WherebyClient
|
||||
from .router import router
|
||||
|
||||
__all__ = ["WherebyClient", "router"]
|
||||
113
server/reflector/video_platforms/whereby/client.py
Normal file
113
server/reflector/video_platforms/whereby/client.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from reflector.db.rooms import Room, VideoPlatform
|
||||
from reflector.settings import settings
|
||||
|
||||
from ..base import MeetingData, VideoPlatformClient
|
||||
|
||||
|
||||
class WherebyClient(VideoPlatformClient):
|
||||
PLATFORM_NAME = VideoPlatform.WHEREBY
|
||||
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": f"Bearer {self.config.api_key}",
|
||||
}
|
||||
self.timeout = 10
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: Room
|
||||
) -> MeetingData:
|
||||
data = {
|
||||
"isLocked": room.is_locked,
|
||||
"roomNamePrefix": room_name_prefix,
|
||||
"roomNamePattern": "uuid",
|
||||
"roomMode": room.room_mode,
|
||||
"endDate": end_date.isoformat(),
|
||||
"recording": {
|
||||
"type": room.recording_type,
|
||||
"destination": {
|
||||
"provider": "s3",
|
||||
"bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||
"accessKeyId": self.config.aws_access_key_id,
|
||||
"accessKeySecret": self.config.aws_access_key_secret,
|
||||
"fileFormat": "mp4",
|
||||
},
|
||||
"startTrigger": room.recording_trigger,
|
||||
},
|
||||
"fields": ["hostRoomUrl"],
|
||||
}
|
||||
|
||||
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()
|
||||
meeting_data = response.json()
|
||||
|
||||
return MeetingData(
|
||||
meeting_id=meeting_data["meetingId"],
|
||||
room_name=meeting_data["roomName"],
|
||||
room_url=meeting_data["roomUrl"],
|
||||
host_room_url=meeting_data["hostRoomUrl"],
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data={
|
||||
"startDate": meeting_data["startDate"],
|
||||
"endDate": meeting_data["endDate"],
|
||||
"recording": meeting_data.get("recording", {}),
|
||||
},
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
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:
|
||||
return True
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
try:
|
||||
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
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
if not signature or not self.config.webhook_secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
expected = hmac.new(
|
||||
self.config.webhook_secret.encode(), body, sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
except Exception:
|
||||
return False
|
||||
4
server/reflector/video_platforms/whereby/tasks.py
Normal file
4
server/reflector/video_platforms/whereby/tasks.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Whereby-specific worker tasks."""
|
||||
|
||||
# Placeholder for Whereby-specific background tasks
|
||||
# This can be extended with Whereby-specific processing tasks in the future
|
||||
126
server/reflector/views/jibri_webhook.py
Normal file
126
server/reflector/views/jibri_webhook.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Dict, Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import reflector.auth as auth
|
||||
from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||
from reflector.jibri_events import JitsiEventParser
|
||||
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
|
||||
from reflector.settings import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/jibri", tags=["jibri"])
|
||||
|
||||
|
||||
class RecordingReadyRequest(BaseModel):
|
||||
session_id: str
|
||||
path: str # Relative path from recordings directory
|
||||
meeting_url: str
|
||||
|
||||
|
||||
@router.post("/recording-ready")
|
||||
async def handle_recording_ready(
|
||||
request: RecordingReadyRequest,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
) -> Dict[str, Any]:
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
recordings_base = Path(settings.JIBRI_RECORDINGS_PATH or "/recordings")
|
||||
recording_path = recordings_base / request.path
|
||||
|
||||
if not recording_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Recording path not found: {request.path}"
|
||||
)
|
||||
|
||||
recording_file = recording_path / "recording.mp4"
|
||||
events_file = recording_path / "events.jsonl"
|
||||
|
||||
if not recording_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recording file not found")
|
||||
|
||||
# Parse events if available
|
||||
metadata = {}
|
||||
participant_count = 0
|
||||
|
||||
if events_file.exists():
|
||||
parser = JitsiEventParser()
|
||||
metadata = parser.parse_events_file(str(recording_path))
|
||||
participant_count = len(metadata.get("participants", []))
|
||||
logger.info(
|
||||
"Parsed Jibri events",
|
||||
session_id=request.session_id,
|
||||
event_count=metadata.get("event_count", 0),
|
||||
participant_count=participant_count,
|
||||
)
|
||||
else:
|
||||
logger.warning("No events file found", session_id=request.session_id)
|
||||
metadata = {
|
||||
"room": {"meeting_url": request.meeting_url, "name": request.session_id},
|
||||
"participants": [],
|
||||
"speaker_stats": {},
|
||||
"event_count": 0,
|
||||
}
|
||||
|
||||
# Create transcript using controller
|
||||
title = f"Meeting: {metadata.get('room', {}).get('name', request.session_id)}"
|
||||
transcript = await transcripts_controller.add(
|
||||
name=title,
|
||||
source_kind=SourceKind.FILE,
|
||||
source_language="en",
|
||||
target_language="en",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Store Jitsi data in appropriate fields
|
||||
update_data = {}
|
||||
|
||||
# Store participants if available
|
||||
if metadata.get("participants"):
|
||||
update_data["participants"] = metadata["participants"]
|
||||
|
||||
# Store events data (room info, speaker stats, etc.)
|
||||
update_data["events"] = {
|
||||
"jitsi_metadata": metadata,
|
||||
"session_id": request.session_id,
|
||||
"recording_path": str(recording_path),
|
||||
"meeting_url": request.meeting_url,
|
||||
}
|
||||
|
||||
if update_data:
|
||||
await transcripts_controller.update(transcript, update_data)
|
||||
|
||||
# Copy recording file to transcript data path
|
||||
# The pipeline expects the file to be in the transcript's data path
|
||||
upload_file = transcript.data_path / "upload.mp4"
|
||||
upload_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create symlink or copy the file
|
||||
import shutil
|
||||
|
||||
shutil.copy2(recording_file, upload_file)
|
||||
|
||||
# Update status to uploaded
|
||||
await transcripts_controller.update(transcript, {"status": "uploaded"})
|
||||
|
||||
# Trigger processing pipeline
|
||||
task_pipeline_file_process.delay(transcript_id=transcript.id)
|
||||
|
||||
logger.info(
|
||||
"Jibri recording ready for processing",
|
||||
transcript_id=transcript.id,
|
||||
session_id=request.session_id,
|
||||
participant_count=participant_count,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "accepted",
|
||||
"transcript_id": transcript.id,
|
||||
"session_id": request.session_id,
|
||||
"events_found": events_file.exists(),
|
||||
"participant_count": participant_count,
|
||||
}
|
||||
@@ -12,9 +12,11 @@ from pydantic import BaseModel
|
||||
import reflector.auth as auth
|
||||
from reflector.db import get_database
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.rooms import VideoPlatform, rooms_controller
|
||||
from reflector.settings import settings
|
||||
from reflector.whereby import create_meeting, upload_logo
|
||||
from reflector.video_platforms.factory import (
|
||||
create_platform_client,
|
||||
)
|
||||
from reflector.worker.webhook import test_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,7 +25,6 @@ router = APIRouter()
|
||||
|
||||
|
||||
def parse_datetime_with_timezone(iso_string: str) -> datetime:
|
||||
"""Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
|
||||
dt = datetime.fromisoformat(iso_string)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
@@ -43,6 +44,7 @@ class Room(BaseModel):
|
||||
recording_type: str
|
||||
recording_trigger: str
|
||||
is_shared: bool
|
||||
platform: VideoPlatform = VideoPlatform.WHEREBY
|
||||
|
||||
|
||||
class RoomDetails(Room):
|
||||
@@ -72,6 +74,7 @@ class CreateRoom(BaseModel):
|
||||
is_shared: bool
|
||||
webhook_url: str
|
||||
webhook_secret: str
|
||||
platform: VideoPlatform
|
||||
|
||||
|
||||
class UpdateRoom(BaseModel):
|
||||
@@ -86,6 +89,7 @@ class UpdateRoom(BaseModel):
|
||||
is_shared: bool
|
||||
webhook_url: str
|
||||
webhook_secret: str
|
||||
platform: VideoPlatform
|
||||
|
||||
|
||||
class DeletionStatus(BaseModel):
|
||||
@@ -149,6 +153,7 @@ async def rooms_create(
|
||||
is_shared=room.is_shared,
|
||||
webhook_url=room.webhook_url,
|
||||
webhook_secret=room.webhook_secret,
|
||||
platform=room.platform,
|
||||
)
|
||||
|
||||
|
||||
@@ -196,36 +201,45 @@ async def rooms_create_meeting(
|
||||
if meeting is None:
|
||||
end_date = current_time + timedelta(hours=8)
|
||||
|
||||
whereby_meeting = await create_meeting("", end_date=end_date, room=room)
|
||||
platform = room.platform
|
||||
client = create_platform_client(platform)
|
||||
|
||||
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
|
||||
platform_meeting = await client.create_meeting("", end_date=end_date, room=room)
|
||||
await client.upload_logo(platform_meeting.room_name, "./images/logo.png")
|
||||
|
||||
# Now try to save to database
|
||||
meeting_data = {
|
||||
"meeting_id": platform_meeting.meeting_id,
|
||||
"room_name": platform_meeting.room_name,
|
||||
"room_url": platform_meeting.room_url,
|
||||
"host_room_url": platform_meeting.host_room_url,
|
||||
"start_date": current_time,
|
||||
"end_date": end_date,
|
||||
}
|
||||
try:
|
||||
meeting = await meetings_controller.create(
|
||||
id=whereby_meeting["meetingId"],
|
||||
room_name=whereby_meeting["roomName"],
|
||||
room_url=whereby_meeting["roomUrl"],
|
||||
host_room_url=whereby_meeting["hostRoomUrl"],
|
||||
start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]),
|
||||
end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
|
||||
id=meeting_data["meeting_id"],
|
||||
room_name=meeting_data["room_name"],
|
||||
room_url=meeting_data["room_url"],
|
||||
host_room_url=meeting_data["host_room_url"],
|
||||
start_date=meeting_data["start_date"],
|
||||
end_date=meeting_data["end_date"],
|
||||
user_id=user_id,
|
||||
room=room,
|
||||
)
|
||||
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
|
||||
# Another request already created a meeting for this room
|
||||
# Log this race condition occurrence
|
||||
logger.warning(
|
||||
"Race condition detected for room %s and meeting %s - fetching existing meeting",
|
||||
logger.info(
|
||||
"Race condition detected for room %s - fetching existing meeting",
|
||||
room.name,
|
||||
)
|
||||
logger.warning(
|
||||
"Platform meeting %s was created but not used (resource leak) for room %s",
|
||||
meeting_data["meeting_id"],
|
||||
room.name,
|
||||
whereby_meeting["meetingId"],
|
||||
)
|
||||
|
||||
# Fetch the meeting that was created by the other request
|
||||
meeting = await meetings_controller.get_active(
|
||||
room=room, current_time=current_time
|
||||
)
|
||||
if meeting is None:
|
||||
# Edge case: meeting was created but expired/deleted between checks
|
||||
logger.error(
|
||||
"Meeting disappeared after race condition for room %s",
|
||||
room.name,
|
||||
@@ -246,7 +260,6 @@ async def rooms_test_webhook(
|
||||
room_id: str,
|
||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||
):
|
||||
"""Test webhook configuration by sending a sample payload."""
|
||||
user_id = user["sub"] if user else None
|
||||
|
||||
room = await rooms_controller.get_by_id(room_id)
|
||||
|
||||
@@ -20,6 +20,7 @@ else:
|
||||
"reflector.worker.healthcheck",
|
||||
"reflector.worker.process",
|
||||
"reflector.worker.cleanup",
|
||||
"reflector.worker.jitsi_events",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -33,6 +34,10 @@ else:
|
||||
"task": "reflector.worker.process.process_meetings",
|
||||
"schedule": float(settings.SQS_POLLING_TIMEOUT_SECONDS),
|
||||
},
|
||||
"process_jitsi_events": {
|
||||
"task": "reflector.worker.jitsi_events.process_jitsi_events",
|
||||
"schedule": 5.0, # Process every 5 seconds
|
||||
},
|
||||
"reprocess_failed_recordings": {
|
||||
"task": "reflector.worker.process.reprocess_failed_recordings",
|
||||
"schedule": crontab(hour=5, minute=0), # Midnight EST
|
||||
|
||||
281
server/reflector/worker/jitsi_events.py
Normal file
281
server/reflector/worker/jitsi_events.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Celery tasks for consuming Jitsi events from Redis queues.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
import redis
|
||||
import structlog
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from reflector.database import get_db_sync
|
||||
from reflector.models import Meeting, Transcript
|
||||
from reflector.settings import settings
|
||||
from reflector.worker.app import app
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class JitsiEventProcessor:
|
||||
"""Process Jitsi events from Redis queues."""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = redis.Redis(
|
||||
host=settings.REDIS_HOST or "redis",
|
||||
port=settings.REDIS_PORT or 6379,
|
||||
decode_responses=True,
|
||||
)
|
||||
self.participants = {} # room_name -> {jid: participant_info}
|
||||
self.speaker_stats = {} # room_name -> {jid: stats}
|
||||
|
||||
def process_participant_joined(self, data: Dict[str, Any], db: Session):
|
||||
"""Track participant joining a room."""
|
||||
room_name = data["room_name"]
|
||||
participant = {
|
||||
"jid": data["participant_jid"],
|
||||
"nick": data["participant_nick"],
|
||||
"id": data["participant_id"],
|
||||
"is_moderator": data.get("is_moderator", False),
|
||||
"joined_at": datetime.now(),
|
||||
}
|
||||
|
||||
if room_name not in self.participants:
|
||||
self.participants[room_name] = {}
|
||||
|
||||
self.participants[room_name][participant["jid"]] = participant
|
||||
|
||||
logger.info(
|
||||
"Participant joined",
|
||||
room=room_name,
|
||||
participant=participant["nick"],
|
||||
total_participants=len(self.participants[room_name]),
|
||||
)
|
||||
|
||||
# Update meeting in database if exists
|
||||
meeting = (
|
||||
db.query(Meeting)
|
||||
.filter(
|
||||
Meeting.room_name == room_name,
|
||||
Meeting.status.in_(["active", "pending"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if meeting:
|
||||
# Store participant info in meeting metadata
|
||||
metadata = meeting.metadata or {}
|
||||
if "participants" not in metadata:
|
||||
metadata["participants"] = []
|
||||
|
||||
metadata["participants"].append(
|
||||
{
|
||||
"id": participant["id"],
|
||||
"name": participant["nick"],
|
||||
"joined_at": participant["joined_at"].isoformat(),
|
||||
"is_moderator": participant["is_moderator"],
|
||||
}
|
||||
)
|
||||
|
||||
meeting.metadata = metadata
|
||||
db.commit()
|
||||
|
||||
def process_participant_left(self, data: Dict[str, Any], db: Session):
|
||||
"""Track participant leaving a room."""
|
||||
room_name = data["room_name"]
|
||||
participant_jid = data["participant_jid"]
|
||||
|
||||
if room_name in self.participants:
|
||||
if participant_jid in self.participants[room_name]:
|
||||
participant = self.participants[room_name][participant_jid]
|
||||
participant["left_at"] = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"Participant left",
|
||||
room=room_name,
|
||||
participant=participant["nick"],
|
||||
duration=(
|
||||
participant["left_at"] - participant["joined_at"]
|
||||
).total_seconds(),
|
||||
)
|
||||
|
||||
# Update meeting in database
|
||||
meeting = (
|
||||
db.query(Meeting)
|
||||
.filter(
|
||||
Meeting.room_name == room_name,
|
||||
Meeting.status.in_(["active", "pending"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if meeting and meeting.metadata and "participants" in meeting.metadata:
|
||||
for p in meeting.metadata["participants"]:
|
||||
if p["id"] == participant["id"]:
|
||||
p["left_at"] = participant["left_at"].isoformat()
|
||||
break
|
||||
db.commit()
|
||||
|
||||
def process_speaker_stats(self, data: Dict[str, Any], db: Session):
|
||||
"""Update speaker statistics."""
|
||||
room_name = data["room_jid"].split("@")[0]
|
||||
self.speaker_stats[room_name] = data["stats"]
|
||||
|
||||
logger.debug(
|
||||
"Speaker stats updated", room=room_name, speakers=len(data["stats"])
|
||||
)
|
||||
|
||||
def process_recording_completed(self, data: Dict[str, Any], db: Session):
|
||||
"""Process completed recording with all metadata."""
|
||||
room_name = data["room_name"]
|
||||
meeting_url = data["meeting_url"]
|
||||
recording_path = data["recording_path"]
|
||||
recording_file = data["recording_file"]
|
||||
|
||||
logger.info(
|
||||
"Recording completed", room=room_name, url=meeting_url, path=recording_path
|
||||
)
|
||||
|
||||
# Get participant data for this room
|
||||
participants = self.participants.get(room_name, {})
|
||||
speaker_stats = self.speaker_stats.get(room_name, {})
|
||||
|
||||
# Create transcript record with full metadata
|
||||
transcript = Transcript(
|
||||
title=f"Recording: {room_name}",
|
||||
source_url=meeting_url,
|
||||
metadata={
|
||||
"jitsi": {
|
||||
"room_name": room_name,
|
||||
"meeting_url": meeting_url,
|
||||
"recording_path": recording_path,
|
||||
"participants": [
|
||||
{
|
||||
"id": p["id"],
|
||||
"name": p["nick"],
|
||||
"joined_at": p["joined_at"].isoformat(),
|
||||
"left_at": p.get("left_at", datetime.now()).isoformat(),
|
||||
"is_moderator": p["is_moderator"],
|
||||
"speaking_time": speaker_stats.get(p["jid"], {}).get(
|
||||
"total_time", 0
|
||||
),
|
||||
}
|
||||
for p in participants.values()
|
||||
],
|
||||
"speaker_stats": speaker_stats,
|
||||
}
|
||||
},
|
||||
status="pending",
|
||||
)
|
||||
db.add(transcript)
|
||||
db.commit()
|
||||
|
||||
# Trigger processing pipeline
|
||||
from reflector.pipelines.main_transcript_pipeline import TranscriptMainPipeline
|
||||
|
||||
pipeline = TranscriptMainPipeline()
|
||||
pipeline.create(transcript.id, recording_file)
|
||||
|
||||
# Clean up room data
|
||||
self.participants.pop(room_name, None)
|
||||
self.speaker_stats.pop(room_name, None)
|
||||
|
||||
logger.info(
|
||||
"Transcript created",
|
||||
transcript_id=transcript.id,
|
||||
participants=len(participants),
|
||||
has_speaker_stats=bool(speaker_stats),
|
||||
)
|
||||
|
||||
|
||||
processor = JitsiEventProcessor()
|
||||
|
||||
|
||||
@app.task(name="reflector.worker.jitsi_events.process_jitsi_events")
|
||||
def process_jitsi_events():
|
||||
"""
|
||||
Process Jitsi events from Redis queue.
|
||||
This should be called periodically by Celery Beat.
|
||||
"""
|
||||
db = next(get_db_sync())
|
||||
processed = 0
|
||||
|
||||
try:
|
||||
# Process up to 100 events per run
|
||||
for _ in range(100):
|
||||
# Pop event from queue (blocking with 1 second timeout)
|
||||
event_data = processor.redis_client.brpop(
|
||||
["jitsi:events:queue", "jitsi:recordings:queue"], timeout=1
|
||||
)
|
||||
|
||||
if not event_data:
|
||||
break
|
||||
|
||||
queue_name, event_json = event_data
|
||||
event = json.loads(event_json)
|
||||
|
||||
event_type = event["type"]
|
||||
data = event["data"]
|
||||
|
||||
logger.debug(f"Processing event: {event_type}")
|
||||
|
||||
# Route to appropriate processor
|
||||
if event_type == "participant_joined":
|
||||
processor.process_participant_joined(data, db)
|
||||
elif event_type == "participant_left":
|
||||
processor.process_participant_left(data, db)
|
||||
elif event_type == "speaker_stats_update":
|
||||
processor.process_speaker_stats(data, db)
|
||||
elif event_type == "recording_completed":
|
||||
processor.process_recording_completed(data, db)
|
||||
else:
|
||||
logger.warning(f"Unknown event type: {event_type}")
|
||||
|
||||
processed += 1
|
||||
|
||||
if processed > 0:
|
||||
logger.info(f"Processed {processed} Jitsi events")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Jitsi events: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
@app.task(name="reflector.worker.jitsi_events.consume_jitsi_stream")
|
||||
def consume_jitsi_stream():
|
||||
"""
|
||||
Alternative: Use Redis Streams for more reliable event processing.
|
||||
Redis Streams provide better guarantees and consumer groups.
|
||||
"""
|
||||
db = next(get_db_sync())
|
||||
|
||||
try:
|
||||
# Read from stream with consumer group
|
||||
events = processor.redis_client.xreadgroup(
|
||||
"reflector-consumers",
|
||||
"worker-1",
|
||||
{"jitsi:events": ">"},
|
||||
count=10,
|
||||
block=1000,
|
||||
)
|
||||
|
||||
for stream_name, messages in events:
|
||||
for message_id, data in messages:
|
||||
event = json.loads(data[b"event"])
|
||||
# Process event...
|
||||
|
||||
# Acknowledge message
|
||||
processor.redis_client.xack(
|
||||
stream_name, "reflector-consumers", message_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error consuming stream: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -17,7 +17,7 @@ from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
|
||||
from reflector.pipelines.main_live_pipeline import asynctask
|
||||
from reflector.settings import settings
|
||||
from reflector.whereby import get_room_sessions
|
||||
from reflector.video_platforms.factory import create_platform_client
|
||||
|
||||
logger = structlog.wrap_logger(get_task_logger(__name__))
|
||||
|
||||
@@ -155,11 +155,18 @@ async def process_meetings():
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
if end_date > datetime.now(timezone.utc):
|
||||
response = await get_room_sessions(meeting.room_name)
|
||||
room_sessions = response.get("results", [])
|
||||
is_active = not room_sessions or any(
|
||||
rs["endedAt"] is None for rs in room_sessions
|
||||
)
|
||||
# Get room sessions using platform client
|
||||
platform = getattr(meeting, "platform", "whereby")
|
||||
client = create_platform_client(platform)
|
||||
if client:
|
||||
response = await client.get_room_sessions(meeting.room_name)
|
||||
room_sessions = response.get("results", [])
|
||||
is_active = not room_sessions or any(
|
||||
rs["endedAt"] is None for rs in room_sessions
|
||||
)
|
||||
else:
|
||||
# Fallback: assume meeting is still active if we can't check
|
||||
is_active = True
|
||||
if not is_active:
|
||||
await meetings_controller.update_meeting(meeting.id, is_active=False)
|
||||
logger.info("Meeting %s is deactivated", meeting.id)
|
||||
|
||||
49
server/run_jibri_tests.py
Normal file
49
server/run_jibri_tests.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python
|
||||
"""Simple test runner for Jibri tests that doesn't require Docker."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Import test functions after path is set
|
||||
exec(open("tests/test_jibri_events.py").read(), globals())
|
||||
|
||||
|
||||
def run_tests():
|
||||
tests = [
|
||||
("test_parse_room_created_event", test_parse_room_created_event),
|
||||
("test_parse_participant_joined_event", test_parse_participant_joined_event),
|
||||
(
|
||||
"test_parse_unknown_event_returns_none",
|
||||
test_parse_unknown_event_returns_none,
|
||||
),
|
||||
(
|
||||
"test_parse_events_file_with_complete_session",
|
||||
test_parse_events_file_with_complete_session,
|
||||
),
|
||||
("test_parse_events_file_missing_file", test_parse_events_file_missing_file),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
test_func()
|
||||
print(f"✓ {name}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f"✗ {name}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {name}: Unexpected error: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\nResults: {passed} passed, {failed} failed")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
122
server/tests/test_jibri_events.py
Normal file
122
server/tests/test_jibri_events.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from reflector.jibri_events import (
|
||||
JitsiEventParser,
|
||||
ParticipantJoinedEvent,
|
||||
RoomCreatedEvent,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_room_created_event():
|
||||
parser = JitsiEventParser()
|
||||
event_data = {
|
||||
"type": "room_created",
|
||||
"timestamp": 1234567890,
|
||||
"room_name": "TestRoom",
|
||||
"room_jid": "testroom@conference.meet.jitsi",
|
||||
"meeting_url": "https://meet.jitsi/TestRoom",
|
||||
}
|
||||
|
||||
event = parser.parse_event(event_data)
|
||||
|
||||
assert isinstance(event, RoomCreatedEvent)
|
||||
assert event.room_name == "TestRoom"
|
||||
assert event.meeting_url == "https://meet.jitsi/TestRoom"
|
||||
|
||||
|
||||
def test_parse_participant_joined_event():
|
||||
parser = JitsiEventParser()
|
||||
event_data = {
|
||||
"type": "participant_joined",
|
||||
"timestamp": 1234567891,
|
||||
"room_name": "TestRoom",
|
||||
"participant": {
|
||||
"jid": "user1@meet.jitsi/resource",
|
||||
"nick": "John Doe",
|
||||
"id": "user1@meet.jitsi",
|
||||
"is_moderator": False,
|
||||
},
|
||||
}
|
||||
|
||||
event = parser.parse_event(event_data)
|
||||
|
||||
assert isinstance(event, ParticipantJoinedEvent)
|
||||
assert event.participant.nick == "John Doe"
|
||||
assert event.participant.is_moderator is False
|
||||
|
||||
|
||||
def test_parse_unknown_event_returns_none():
|
||||
parser = JitsiEventParser()
|
||||
event_data = {"type": "unknown_event", "timestamp": 1234567890}
|
||||
|
||||
event = parser.parse_event(event_data)
|
||||
assert event is None
|
||||
|
||||
|
||||
def test_parse_events_file_with_complete_session():
|
||||
parser = JitsiEventParser()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
events_file = Path(tmpdir) / "events.jsonl"
|
||||
|
||||
events = [
|
||||
{
|
||||
"type": "room_created",
|
||||
"timestamp": 1234567890,
|
||||
"room_name": "TestRoom",
|
||||
"room_jid": "testroom@conference.meet.jitsi",
|
||||
"meeting_url": "https://meet.jitsi/TestRoom",
|
||||
},
|
||||
{
|
||||
"type": "participant_joined",
|
||||
"timestamp": 1234567892,
|
||||
"room_name": "TestRoom",
|
||||
"participant": {
|
||||
"jid": "user1@meet.jitsi/resource",
|
||||
"nick": "John Doe",
|
||||
"id": "user1@meet.jitsi",
|
||||
"is_moderator": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "speaker_active",
|
||||
"timestamp": 1234567895,
|
||||
"room_name": "TestRoom",
|
||||
"speaker_jid": "user1@meet.jitsi",
|
||||
"speaker_nick": "John Doe",
|
||||
"duration": 10,
|
||||
},
|
||||
{
|
||||
"type": "participant_left",
|
||||
"timestamp": 1234567920,
|
||||
"room_name": "TestRoom",
|
||||
"participant": {
|
||||
"jid": "user1@meet.jitsi/resource",
|
||||
"duration_seconds": 28,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
with open(events_file, "w") as f:
|
||||
for event in events:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
metadata = parser.parse_events_file(tmpdir)
|
||||
|
||||
assert metadata["room"]["name"] == "TestRoom"
|
||||
assert metadata["room"]["meeting_url"] == "https://meet.jitsi/TestRoom"
|
||||
assert len(metadata["participants"]) == 1
|
||||
assert metadata["event_count"] == 4
|
||||
|
||||
|
||||
def test_parse_events_file_missing_file():
|
||||
parser = JitsiEventParser()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
metadata = parser.parse_events_file(tmpdir)
|
||||
|
||||
assert metadata["room"]["name"] == ""
|
||||
assert len(metadata["participants"]) == 0
|
||||
assert metadata["event_count"] == 0
|
||||
254
server/tests/test_jibri_webhook.py
Normal file
254
server/tests/test_jibri_webhook.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from reflector.api.jibri_webhook import router
|
||||
from reflector.models import Transcript
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = Mock(spec=Session)
|
||||
db.add = Mock()
|
||||
db.commit = Mock()
|
||||
db.refresh = Mock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
with patch("reflector.api.jibri_webhook.settings") as mock:
|
||||
mock.JIBRI_RECORDINGS_PATH = "/recordings"
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pipeline():
|
||||
with patch("reflector.api.jibri_webhook.TranscriptMainPipeline") as mock:
|
||||
pipeline_instance = Mock()
|
||||
pipeline_instance.create = Mock()
|
||||
mock.return_value = pipeline_instance
|
||||
yield mock
|
||||
|
||||
|
||||
class TestJibriWebhook:
|
||||
def test_recording_ready_success_with_events(
|
||||
self, client, mock_db, mock_settings, mock_pipeline
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
# Create recording directory and files
|
||||
session_id = "test-session-123"
|
||||
recording_dir = Path(tmpdir) / session_id
|
||||
recording_dir.mkdir()
|
||||
|
||||
recording_file = recording_dir / "recording.mp4"
|
||||
recording_file.write_text("fake video content")
|
||||
|
||||
events_file = recording_dir / "events.jsonl"
|
||||
events = [
|
||||
{
|
||||
"type": "room_created",
|
||||
"timestamp": 1234567890,
|
||||
"room_name": "TestRoom",
|
||||
"room_jid": "testroom@conference.meet.jitsi",
|
||||
"meeting_url": "https://meet.jitsi/TestRoom",
|
||||
},
|
||||
{
|
||||
"type": "participant_joined",
|
||||
"timestamp": 1234567892,
|
||||
"room_name": "TestRoom",
|
||||
"participant": {
|
||||
"jid": "user1@meet.jitsi/resource",
|
||||
"nick": "John Doe",
|
||||
"id": "user1@meet.jitsi",
|
||||
"is_moderator": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
with open(events_file, "w") as f:
|
||||
for event in events:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
# Mock database dependency
|
||||
with patch("reflector.api.jibri_webhook.get_db") as mock_get_db:
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"path": session_id,
|
||||
"meeting_url": "https://meet.jitsi/TestRoom",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "accepted"
|
||||
assert data["session_id"] == session_id
|
||||
assert data["events_found"] is True
|
||||
assert data["participant_count"] == 1
|
||||
|
||||
# Verify transcript was created
|
||||
mock_db.add.assert_called_once()
|
||||
transcript_arg = mock_db.add.call_args[0][0]
|
||||
assert isinstance(transcript_arg, Transcript)
|
||||
assert "TestRoom" in transcript_arg.title
|
||||
assert transcript_arg.metadata["jitsi"]["room"]["name"] == "TestRoom"
|
||||
|
||||
# Verify pipeline was triggered
|
||||
mock_pipeline.return_value.create.assert_called_once()
|
||||
|
||||
def test_recording_ready_success_without_events(
|
||||
self, client, mock_db, mock_settings, mock_pipeline
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
session_id = "test-session-456"
|
||||
recording_dir = Path(tmpdir) / session_id
|
||||
recording_dir.mkdir()
|
||||
|
||||
recording_file = recording_dir / "recording.mp4"
|
||||
recording_file.write_text("fake video content")
|
||||
|
||||
with patch("reflector.api.jibri_webhook.get_db") as mock_get_db:
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"path": session_id,
|
||||
"meeting_url": "https://meet.jitsi/NoEventsRoom",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "accepted"
|
||||
assert data["events_found"] is False
|
||||
assert data["participant_count"] == 0
|
||||
|
||||
# Verify transcript was created with minimal metadata
|
||||
mock_db.add.assert_called_once()
|
||||
transcript_arg = mock_db.add.call_args[0][0]
|
||||
assert transcript_arg.metadata["jitsi"]["participants"] == []
|
||||
|
||||
def test_recording_ready_path_not_found(self, client, mock_settings):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": "nonexistent",
|
||||
"path": "nonexistent",
|
||||
"meeting_url": "https://meet.jitsi/Test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Recording path not found" in response.json()["detail"]
|
||||
|
||||
def test_recording_ready_recording_file_not_found(self, client, mock_settings):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
session_id = "test-no-recording"
|
||||
recording_dir = Path(tmpdir) / session_id
|
||||
recording_dir.mkdir()
|
||||
|
||||
# No recording.mp4 file created
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"path": session_id,
|
||||
"meeting_url": "https://meet.jitsi/Test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Recording file not found" in response.json()["detail"]
|
||||
|
||||
def test_recording_ready_with_relative_path(
|
||||
self, client, mock_db, mock_settings, mock_pipeline
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
# Create nested directory structure
|
||||
session_id = "2024/01/15/test-session"
|
||||
recording_dir = Path(tmpdir) / session_id
|
||||
recording_dir.mkdir(parents=True)
|
||||
|
||||
recording_file = recording_dir / "recording.mp4"
|
||||
recording_file.write_text("fake video content")
|
||||
|
||||
with patch("reflector.api.jibri_webhook.get_db") as mock_get_db:
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": "test-session",
|
||||
"path": session_id, # Relative path with subdirectories
|
||||
"meeting_url": "https://meet.jitsi/Test",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "accepted"
|
||||
|
||||
def test_recording_ready_empty_meeting_url(
|
||||
self, client, mock_db, mock_settings, mock_pipeline
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
mock_settings.JIBRI_RECORDINGS_PATH = tmpdir
|
||||
|
||||
session_id = "test-session"
|
||||
recording_dir = Path(tmpdir) / session_id
|
||||
recording_dir.mkdir()
|
||||
|
||||
recording_file = recording_dir / "recording.mp4"
|
||||
recording_file.write_text("fake video content")
|
||||
|
||||
with patch("reflector.api.jibri_webhook.get_db") as mock_get_db:
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/jibri/recording-ready",
|
||||
json={
|
||||
"session_id": session_id,
|
||||
"path": session_id,
|
||||
"meeting_url": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify fallback URL was used
|
||||
transcript_arg = mock_db.add.call_args[0][0]
|
||||
assert transcript_arg.source_url == f"jitsi://{session_id}"
|
||||
768
server/tests/test_video_platforms.py
Normal file
768
server/tests/test_video_platforms.py
Normal file
@@ -0,0 +1,768 @@
|
||||
"""Tests for video platform abstraction and Jitsi integration."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from reflector.db.rooms import Room, VideoPlatform
|
||||
from reflector.video_platforms.base import (
|
||||
MeetingData,
|
||||
VideoPlatformClient,
|
||||
VideoPlatformConfig,
|
||||
)
|
||||
from reflector.video_platforms.factory import (
|
||||
create_platform_client,
|
||||
get_platform_config,
|
||||
)
|
||||
from reflector.video_platforms.jitsi import JitsiClient
|
||||
from reflector.video_platforms.registry import (
|
||||
get_available_platforms,
|
||||
get_platform_client,
|
||||
register_platform,
|
||||
)
|
||||
from reflector.video_platforms.whereby import WherebyClient
|
||||
|
||||
|
||||
class TestVideoPlatformBase:
|
||||
"""Test the video platform base classes and interfaces."""
|
||||
|
||||
def test_video_platform_config_creation(self):
|
||||
"""Test VideoPlatformConfig can be created with required fields."""
|
||||
config = VideoPlatformConfig(
|
||||
api_key="test-key",
|
||||
webhook_secret="test-secret",
|
||||
api_url="https://test.example.com",
|
||||
)
|
||||
assert config.api_key == "test-key"
|
||||
assert config.webhook_secret == "test-secret"
|
||||
assert config.api_url == "https://test.example.com"
|
||||
|
||||
def test_meeting_data_creation(self):
|
||||
"""Test MeetingData can be created with all fields."""
|
||||
meeting_data = MeetingData(
|
||||
meeting_id="test-123",
|
||||
room_name="test-room",
|
||||
room_url="https://test.com/room",
|
||||
host_room_url="https://test.com/host",
|
||||
platform=VideoPlatform.JITSI,
|
||||
extra_data={"jwt": "token123"},
|
||||
)
|
||||
assert meeting_data.meeting_id == "test-123"
|
||||
assert meeting_data.room_name == "test-room"
|
||||
assert meeting_data.platform == VideoPlatform.JITSI
|
||||
assert meeting_data.extra_data["jwt"] == "token123"
|
||||
|
||||
|
||||
class TestJitsiClient:
|
||||
"""Test JitsiClient implementation."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.config = VideoPlatformConfig(
|
||||
api_key="", # Jitsi doesn't use API key
|
||||
webhook_secret="test-webhook-secret",
|
||||
api_url="https://meet.example.com",
|
||||
)
|
||||
self.client = JitsiClient(self.config)
|
||||
self.test_room = Room(
|
||||
id="test-room-id", name="test-room", user_id="test-user", platform="jitsi"
|
||||
)
|
||||
|
||||
@patch("reflector.settings.settings.JITSI_JWT_SECRET", "test-secret-123")
|
||||
@patch("reflector.settings.settings.JITSI_DOMAIN", "meet.example.com")
|
||||
@patch("reflector.settings.settings.JITSI_JWT_ISSUER", "reflector")
|
||||
@patch("reflector.settings.settings.JITSI_JWT_AUDIENCE", "jitsi")
|
||||
def test_jwt_generation(self):
|
||||
"""Test JWT token generation with proper payload."""
|
||||
exp_time = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
jwt_token = self.client._generate_jwt(
|
||||
room="test-room", moderator=True, exp=exp_time
|
||||
)
|
||||
|
||||
# Verify token is generated
|
||||
assert jwt_token is not None
|
||||
assert len(jwt_token) > 50 # JWT tokens are quite long
|
||||
assert jwt_token.count(".") == 2 # JWT has 3 parts separated by dots
|
||||
|
||||
@patch("reflector.settings.settings.JITSI_JWT_SECRET", None)
|
||||
def test_jwt_generation_without_secret_fails(self):
|
||||
"""Test JWT generation fails without secret."""
|
||||
exp_time = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
with pytest.raises(ValueError, match="JITSI_JWT_SECRET is required"):
|
||||
self.client._generate_jwt(room="test-room", moderator=False, exp=exp_time)
|
||||
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.client.generate_uuid4",
|
||||
return_value="test-uuid-123",
|
||||
)
|
||||
@patch("reflector.settings.settings.JITSI_JWT_SECRET", "test-secret-123")
|
||||
@patch("reflector.settings.settings.JITSI_DOMAIN", "meet.example.com")
|
||||
@patch("reflector.settings.settings.JITSI_JWT_ISSUER", "reflector")
|
||||
@patch("reflector.settings.settings.JITSI_JWT_AUDIENCE", "jitsi")
|
||||
async def test_create_meeting(self, mock_uuid):
|
||||
"""Test meeting creation with JWT tokens."""
|
||||
end_date = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
|
||||
meeting_data = await self.client.create_meeting(
|
||||
room_name_prefix="test", end_date=end_date, room=self.test_room
|
||||
)
|
||||
|
||||
# Verify meeting data structure
|
||||
assert meeting_data.meeting_id == "test-uuid-123"
|
||||
assert meeting_data.platform == VideoPlatform.JITSI
|
||||
assert "reflector-test-room" in meeting_data.room_name
|
||||
assert "meet.example.com" in meeting_data.room_url
|
||||
assert "jwt=" in meeting_data.room_url
|
||||
assert "jwt=" in meeting_data.host_room_url
|
||||
|
||||
# Verify extra data contains JWT tokens
|
||||
assert "user_jwt" in meeting_data.extra_data
|
||||
assert "host_jwt" in meeting_data.extra_data
|
||||
assert "domain" in meeting_data.extra_data
|
||||
|
||||
async def test_get_room_sessions(self):
|
||||
"""Test room sessions retrieval (mock implementation)."""
|
||||
sessions = await self.client.get_room_sessions("test-room")
|
||||
|
||||
assert "roomName" in sessions
|
||||
assert "sessions" in sessions
|
||||
assert sessions["roomName"] == "test-room"
|
||||
assert len(sessions["sessions"]) > 0
|
||||
assert sessions["sessions"][0]["isActive"] is True
|
||||
|
||||
async def test_delete_room(self):
|
||||
"""Test room deletion (no-op for Jitsi)."""
|
||||
result = await self.client.delete_room("test-room")
|
||||
assert result is True
|
||||
|
||||
async def test_upload_logo(self):
|
||||
"""Test logo upload (no-op for Jitsi)."""
|
||||
result = await self.client.upload_logo("test-room", "logo.png")
|
||||
assert result is True
|
||||
|
||||
def test_verify_webhook_signature_valid(self):
|
||||
"""Test webhook signature verification with valid signature."""
|
||||
body = b'{"event": "test"}'
|
||||
# Generate expected signature
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
expected_signature = hmac.new(
|
||||
self.config.webhook_secret.encode(), body, sha256
|
||||
).hexdigest()
|
||||
|
||||
result = self.client.verify_webhook_signature(body, expected_signature)
|
||||
assert result is True
|
||||
|
||||
def test_verify_webhook_signature_invalid(self):
|
||||
"""Test webhook signature verification with invalid signature."""
|
||||
body = b'{"event": "test"}'
|
||||
invalid_signature = "invalid-signature"
|
||||
|
||||
result = self.client.verify_webhook_signature(body, invalid_signature)
|
||||
assert result is False
|
||||
|
||||
def test_verify_webhook_signature_no_secret(self):
|
||||
"""Test webhook signature verification without secret."""
|
||||
config = VideoPlatformConfig(
|
||||
api_key="", webhook_secret="", api_url="https://meet.example.com"
|
||||
)
|
||||
client = JitsiClient(config)
|
||||
|
||||
result = client.verify_webhook_signature(b'{"event": "test"}', "signature")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestWherebyClient:
|
||||
"""Test WherebyClient implementation."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.config = VideoPlatformConfig(
|
||||
api_key="test-whereby-api-key",
|
||||
webhook_secret="test-whereby-webhook-secret",
|
||||
api_url="https://api.whereby.dev",
|
||||
s3_bucket="test-recordings-bucket",
|
||||
aws_access_key_id="test-access-key",
|
||||
aws_access_key_secret="test-access-secret",
|
||||
)
|
||||
self.client = WherebyClient(self.config)
|
||||
self.test_room = Room(
|
||||
id="test-room-id",
|
||||
name="test-room",
|
||||
user_id="test-user",
|
||||
platform=VideoPlatform.WHEREBY,
|
||||
)
|
||||
|
||||
@patch("httpx.AsyncClient")
|
||||
async def test_create_meeting(self, mock_client_class):
|
||||
"""Test Whereby meeting creation."""
|
||||
# Mock the HTTP response
|
||||
mock_client = mock_client_class.return_value.__aenter__.return_value
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"meetingId": "whereby-meeting-123",
|
||||
"roomName": "whereby-room-456",
|
||||
"roomUrl": "https://whereby.com/room",
|
||||
"hostRoomUrl": "https://whereby.com/host-room",
|
||||
"startDate": "2025-01-15T10:00:00.000Z",
|
||||
"endDate": "2025-01-15T18:00:00.000Z",
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
end_date = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||
|
||||
meeting_data = await self.client.create_meeting(
|
||||
room_name_prefix="test", end_date=end_date, room=self.test_room
|
||||
)
|
||||
|
||||
# Verify meeting data structure
|
||||
assert meeting_data.meeting_id == "whereby-meeting-123"
|
||||
assert meeting_data.room_name == "whereby-room-456"
|
||||
assert meeting_data.platform == VideoPlatform.WHEREBY
|
||||
assert "whereby.com" in meeting_data.room_url
|
||||
assert "whereby.com" in meeting_data.host_room_url
|
||||
|
||||
# Verify HTTP call was made with correct parameters
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert "whereby.dev" in call_args[0][0] # URL
|
||||
assert "Bearer test-whereby-api-key" in call_args[1]["headers"]["Authorization"]
|
||||
|
||||
@patch("httpx.AsyncClient")
|
||||
async def test_get_room_sessions(self, mock_client_class):
|
||||
"""Test Whereby room sessions retrieval."""
|
||||
mock_client = mock_client_class.return_value.__aenter__.return_value
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
"sessions": [
|
||||
{
|
||||
"id": "session-123",
|
||||
"startTime": "2025-01-15T10:00:00Z",
|
||||
"participants": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
sessions = await self.client.get_room_sessions("test-room")
|
||||
|
||||
assert "sessions" in sessions
|
||||
assert len(sessions["sessions"]) == 1
|
||||
assert sessions["sessions"][0]["id"] == "session-123"
|
||||
|
||||
# Verify HTTP call
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
async def test_delete_room(self):
|
||||
"""Test room deletion (no-op for Whereby)."""
|
||||
result = await self.client.delete_room("test-room")
|
||||
assert result is True
|
||||
|
||||
@patch("httpx.AsyncClient")
|
||||
async def test_upload_logo_success(self, mock_client_class):
|
||||
"""Test logo upload success."""
|
||||
mock_client = mock_client_class.return_value.__aenter__.return_value
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_client.put.return_value = mock_response
|
||||
|
||||
# Create a temporary file for testing
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".png", delete=False) as f:
|
||||
f.write("fake logo content")
|
||||
temp_file = f.name
|
||||
|
||||
result = await self.client.upload_logo("test-room", temp_file)
|
||||
assert result is True
|
||||
|
||||
# Verify HTTP call
|
||||
mock_client.put.assert_called_once()
|
||||
|
||||
# Cleanup
|
||||
import os
|
||||
|
||||
os.unlink(temp_file)
|
||||
|
||||
@patch("httpx.AsyncClient")
|
||||
async def test_upload_logo_failure(self, mock_client_class):
|
||||
"""Test logo upload handles HTTP errors gracefully."""
|
||||
mock_client = mock_client_class.return_value.__aenter__.return_value
|
||||
mock_client.put.side_effect = Exception("HTTP error")
|
||||
|
||||
result = await self.client.upload_logo("test-room", "logo.png")
|
||||
assert result is False
|
||||
|
||||
def test_verify_webhook_signature_valid(self):
|
||||
"""Test Whereby webhook signature verification with valid signature."""
|
||||
body = b'{"event": "test"}'
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
expected_signature = hmac.new(
|
||||
self.config.webhook_secret.encode(), body, sha256
|
||||
).hexdigest()
|
||||
|
||||
result = self.client.verify_webhook_signature(body, expected_signature)
|
||||
assert result is True
|
||||
|
||||
def test_verify_webhook_signature_invalid(self):
|
||||
"""Test Whereby webhook signature verification with invalid signature."""
|
||||
body = b'{"event": "test"}'
|
||||
invalid_signature = "invalid-signature"
|
||||
|
||||
result = self.client.verify_webhook_signature(body, invalid_signature)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPlatformRegistry:
|
||||
"""Test platform registry functionality."""
|
||||
|
||||
def test_platform_registration(self):
|
||||
"""Test platform registration and retrieval."""
|
||||
|
||||
# Create mock client class
|
||||
class MockClient(VideoPlatformClient):
|
||||
async def create_meeting(self, room_name_prefix, end_date, room):
|
||||
pass
|
||||
|
||||
async def get_room_sessions(self, room_name):
|
||||
pass
|
||||
|
||||
async def delete_room(self, room_name):
|
||||
pass
|
||||
|
||||
async def upload_logo(self, room_name, logo_path):
|
||||
pass
|
||||
|
||||
def verify_webhook_signature(self, body, signature, timestamp=None):
|
||||
pass
|
||||
|
||||
# Register mock platform
|
||||
register_platform("test-platform", MockClient)
|
||||
|
||||
# Verify it's available
|
||||
available = get_available_platforms()
|
||||
assert "test-platform" in available
|
||||
|
||||
# Test client creation
|
||||
config = VideoPlatformConfig(
|
||||
api_key="test", webhook_secret="test", api_url="test"
|
||||
)
|
||||
client = get_platform_client("test-platform", config)
|
||||
assert isinstance(client, MockClient)
|
||||
|
||||
def test_get_unknown_platform_raises_error(self):
|
||||
"""Test that requesting unknown platform raises error."""
|
||||
config = VideoPlatformConfig(
|
||||
api_key="test", webhook_secret="test", api_url="test"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown video platform: nonexistent"):
|
||||
get_platform_client("nonexistent", config)
|
||||
|
||||
def test_builtin_platforms_registered(self):
|
||||
"""Test that built-in platforms are registered."""
|
||||
available = get_available_platforms()
|
||||
assert "jitsi" in available
|
||||
assert "whereby" in available
|
||||
|
||||
|
||||
class TestPlatformFactory:
|
||||
"""Test platform factory functionality."""
|
||||
|
||||
@patch("reflector.settings.settings.JITSI_JWT_SECRET", "test-secret")
|
||||
@patch("reflector.settings.settings.JITSI_WEBHOOK_SECRET", "webhook-secret")
|
||||
@patch("reflector.settings.settings.JITSI_DOMAIN", "meet.example.com")
|
||||
def test_get_jitsi_platform_config(self):
|
||||
"""Test Jitsi platform configuration."""
|
||||
config = get_platform_config("jitsi")
|
||||
|
||||
assert config.api_key == "" # Jitsi uses JWT, no API key
|
||||
assert config.webhook_secret == "webhook-secret"
|
||||
assert config.api_url == "https://meet.example.com"
|
||||
|
||||
@patch("reflector.settings.settings.WHEREBY_API_KEY", "whereby-key")
|
||||
@patch("reflector.settings.settings.WHEREBY_WEBHOOK_SECRET", "whereby-secret")
|
||||
@patch("reflector.settings.settings.WHEREBY_API_URL", "https://api.whereby.dev")
|
||||
def test_get_whereby_platform_config(self):
|
||||
"""Test Whereby platform configuration."""
|
||||
config = get_platform_config("whereby")
|
||||
|
||||
assert config.api_key == "whereby-key"
|
||||
assert config.webhook_secret == "whereby-secret"
|
||||
assert config.api_url == "https://api.whereby.dev"
|
||||
|
||||
def test_get_unknown_platform_config_raises_error(self):
|
||||
"""Test that unknown platform config raises error."""
|
||||
with pytest.raises(ValueError, match="Unknown platform: nonexistent"):
|
||||
get_platform_config("nonexistent")
|
||||
|
||||
def test_create_platform_client(self):
|
||||
"""Test platform client creation via factory."""
|
||||
with patch(
|
||||
"reflector.video_platforms.factory.get_platform_config"
|
||||
) as mock_config:
|
||||
mock_config.return_value = VideoPlatformConfig(
|
||||
api_key="",
|
||||
webhook_secret="test-secret",
|
||||
api_url="https://meet.example.com",
|
||||
)
|
||||
|
||||
client = create_platform_client("jitsi")
|
||||
assert isinstance(client, JitsiClient)
|
||||
|
||||
def test_create_jitsi_client_typing(self):
|
||||
"""Test that create_platform_client returns correctly typed JitsiClient."""
|
||||
with patch(
|
||||
"reflector.video_platforms.factory.get_platform_config"
|
||||
) as mock_config:
|
||||
mock_config.return_value = VideoPlatformConfig(
|
||||
api_key="",
|
||||
webhook_secret="test-secret",
|
||||
api_url="https://meet.example.com",
|
||||
)
|
||||
|
||||
# The typing overload should ensure this returns JitsiClient
|
||||
client = create_platform_client("jitsi")
|
||||
assert isinstance(client, JitsiClient)
|
||||
# Verify it has Jitsi-specific methods
|
||||
assert hasattr(client, "_generate_jwt")
|
||||
|
||||
def test_create_whereby_client_typing(self):
|
||||
"""Test that create_platform_client returns correctly typed WherebyClient."""
|
||||
with patch(
|
||||
"reflector.video_platforms.factory.get_platform_config"
|
||||
) as mock_config:
|
||||
mock_config.return_value = VideoPlatformConfig(
|
||||
api_key="whereby-key",
|
||||
webhook_secret="whereby-secret",
|
||||
api_url="https://api.whereby.dev",
|
||||
)
|
||||
|
||||
# The typing overload should ensure this returns WherebyClient
|
||||
client = create_platform_client("whereby")
|
||||
assert isinstance(client, WherebyClient)
|
||||
# Verify it has Whereby-specific attributes
|
||||
assert hasattr(client, "headers")
|
||||
assert hasattr(client, "timeout")
|
||||
|
||||
|
||||
class TestWebhookEventStorage:
|
||||
"""Test webhook event storage functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
from reflector.app import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
|
||||
@patch("reflector.db.meetings.meetings_controller.participant_joined")
|
||||
@patch("reflector.db.meetings.meetings_controller.get_by_room_name")
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_joined_event_storage(
|
||||
self, mock_verify, mock_get, mock_participant_joined
|
||||
):
|
||||
"""Test that participant joined events are stored correctly."""
|
||||
# Mock meeting
|
||||
mock_meeting = Mock()
|
||||
mock_meeting.id = "test-meeting-id"
|
||||
mock_meeting.num_clients = 1
|
||||
mock_get.return_value = mock_meeting
|
||||
|
||||
payload = {
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "test-room",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {"user_id": "test-user", "display_name": "John Doe"},
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jitsi/events",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "valid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify event was stored with correct data
|
||||
mock_participant_joined.assert_called_once_with(
|
||||
"test-meeting-id",
|
||||
{
|
||||
"timestamp": datetime.fromisoformat(
|
||||
"2025-01-15T10:30:00.000Z".replace("Z", "+00:00")
|
||||
),
|
||||
"data": {"user_id": "test-user", "display_name": "John Doe"},
|
||||
},
|
||||
)
|
||||
|
||||
@patch("reflector.db.meetings.meetings_controller.recording_started")
|
||||
@patch("reflector.db.meetings.meetings_controller.get_by_room_name")
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=True,
|
||||
)
|
||||
def test_recording_started_event_storage(
|
||||
self, mock_verify, mock_get, mock_recording_started
|
||||
):
|
||||
"""Test that recording started events are stored correctly."""
|
||||
mock_meeting = Mock()
|
||||
mock_meeting.id = "test-meeting-id"
|
||||
mock_meeting.num_clients = 1
|
||||
mock_get.return_value = mock_meeting
|
||||
|
||||
payload = {
|
||||
"event": "jibri-recording-on",
|
||||
"room": "test-room",
|
||||
"timestamp": "2025-01-15T10:32:00.000Z",
|
||||
"data": {"recording_id": "rec-123"},
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jitsi/events",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "valid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_recording_started.assert_called_once_with(
|
||||
"test-meeting-id",
|
||||
{
|
||||
"timestamp": datetime.fromisoformat(
|
||||
"2025-01-15T10:32:00.000Z".replace("Z", "+00:00")
|
||||
),
|
||||
"data": {"recording_id": "rec-123"},
|
||||
},
|
||||
)
|
||||
|
||||
@patch("reflector.db.meetings.meetings_controller.add_event")
|
||||
@patch("reflector.db.meetings.meetings_controller.get_by_room_name")
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=True,
|
||||
)
|
||||
def test_recording_complete_event_storage(
|
||||
self, mock_verify, mock_get, mock_add_event
|
||||
):
|
||||
"""Test that recording completion events are stored correctly."""
|
||||
mock_meeting = Mock()
|
||||
mock_meeting.id = "test-meeting-id"
|
||||
mock_meeting.num_clients = 1
|
||||
mock_get.return_value = mock_meeting
|
||||
|
||||
payload = {
|
||||
"room_name": "test-room",
|
||||
"recording_file": "/recordings/test.mp4",
|
||||
"recording_status": "completed",
|
||||
"timestamp": "2025-01-15T11:15:00.000Z",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jibri/recording-complete",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "valid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_add_event.assert_called_once_with(
|
||||
"test-meeting-id",
|
||||
"recording_completed",
|
||||
{
|
||||
"recording_file": "/recordings/test.mp4",
|
||||
"recording_status": "completed",
|
||||
"timestamp": datetime.fromisoformat(
|
||||
"2025-01-15T11:15:00.000Z".replace("Z", "+00:00")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestWebhookEndpoints:
|
||||
"""Test Jitsi webhook endpoints."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test client."""
|
||||
from reflector.app import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
|
||||
def test_health_endpoint(self):
|
||||
"""Test Jitsi health check endpoint."""
|
||||
response = self.client.get("/v1/jitsi/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["service"] == "jitsi-webhooks"
|
||||
assert "timestamp" in data
|
||||
assert "webhook_secret_configured" in data
|
||||
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=True,
|
||||
)
|
||||
@patch("reflector.db.meetings.meetings_controller.get_by_room_name")
|
||||
@patch("reflector.db.meetings.meetings_controller.participant_joined")
|
||||
@patch("reflector.db.meetings.meetings_controller.update_meeting")
|
||||
async def test_jitsi_events_webhook_join(
|
||||
self, mock_update, mock_participant_joined, mock_get, mock_verify
|
||||
):
|
||||
"""Test participant join event webhook."""
|
||||
# Mock meeting
|
||||
mock_meeting = Mock()
|
||||
mock_meeting.id = "test-meeting-id"
|
||||
mock_meeting.num_clients = 1
|
||||
mock_get.return_value = mock_meeting
|
||||
|
||||
payload = {
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "test-room",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jitsi/events",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "valid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["event"] == "muc-occupant-joined"
|
||||
assert data["room"] == "test-room"
|
||||
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=False,
|
||||
)
|
||||
async def test_jitsi_events_webhook_invalid_signature(self, mock_verify):
|
||||
"""Test webhook with invalid signature returns 401."""
|
||||
payload = {
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "test-room",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jitsi/events",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "invalid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid webhook signature" in response.text
|
||||
|
||||
@patch(
|
||||
"reflector.video_platforms.jitsi.router.verify_jitsi_webhook_signature",
|
||||
return_value=True,
|
||||
)
|
||||
@patch(
|
||||
"reflector.db.meetings.meetings_controller.get_by_room_name", return_value=None
|
||||
)
|
||||
async def test_jitsi_events_webhook_meeting_not_found(self, mock_get, mock_verify):
|
||||
"""Test webhook with nonexistent meeting returns 404."""
|
||||
payload = {
|
||||
"event": "muc-occupant-joined",
|
||||
"room": "nonexistent-room",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/v1/jitsi/events",
|
||||
json=payload,
|
||||
headers={"x-jitsi-signature": "valid-signature"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Meeting not found" in response.text
|
||||
|
||||
|
||||
class TestRoomsPlatformIntegration:
|
||||
"""Test rooms endpoint integration with platform abstraction."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test client."""
|
||||
from reflector.app import app
|
||||
|
||||
self.client = TestClient(app)
|
||||
|
||||
@patch("reflector.auth.current_user_optional")
|
||||
@patch("reflector.db.rooms.rooms_controller.add")
|
||||
def test_create_room_with_jitsi_platform(self, mock_add, mock_auth):
|
||||
"""Test room creation with Jitsi platform."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
mock_auth.return_value = {"sub": "test-user"}
|
||||
|
||||
# Create a proper Room object for the mock return
|
||||
from reflector.db.rooms import Room
|
||||
|
||||
mock_room = Room(
|
||||
id="test-room-id",
|
||||
name="test-jitsi-room",
|
||||
user_id="test-user",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
zulip_auto_post=False,
|
||||
zulip_stream="",
|
||||
zulip_topic="",
|
||||
is_locked=False,
|
||||
room_mode="normal",
|
||||
recording_type="cloud",
|
||||
recording_trigger="automatic-2nd-participant",
|
||||
is_shared=False,
|
||||
platform=VideoPlatform.JITSI,
|
||||
)
|
||||
mock_add.return_value = mock_room
|
||||
|
||||
payload = {
|
||||
"name": "test-jitsi-room",
|
||||
"platform": "jitsi",
|
||||
"zulip_auto_post": False,
|
||||
"zulip_stream": "",
|
||||
"zulip_topic": "",
|
||||
"is_locked": False,
|
||||
"room_mode": "normal",
|
||||
"recording_type": "cloud",
|
||||
"recording_trigger": "automatic-2nd-participant",
|
||||
"is_shared": False,
|
||||
"webhook_url": "",
|
||||
"webhook_secret": "",
|
||||
}
|
||||
|
||||
response = self.client.post("/v1/rooms", json=payload)
|
||||
|
||||
# Verify the add method was called with platform parameter
|
||||
mock_add.assert_called_once()
|
||||
call_args = mock_add.call_args
|
||||
assert call_args.kwargs["platform"] == "jitsi"
|
||||
assert call_args.kwargs["name"] == "test-jitsi-room"
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_create_meeting_with_jitsi_platform_fallback(self):
|
||||
"""Test that meeting creation falls back to whereby when platform client unavailable."""
|
||||
# This tests the fallback behavior in rooms.py when platform client returns None
|
||||
# The actual platform integration test is covered in the unit tests above
|
||||
|
||||
# Just verify the endpoint exists and has the right structure
|
||||
# More detailed integration testing would require a full test database setup
|
||||
assert hasattr(self.client.app, "routes")
|
||||
|
||||
# Find the meeting creation route
|
||||
meeting_routes = [
|
||||
r
|
||||
for r in self.client.app.routes
|
||||
if hasattr(r, "path") and "meeting" in r.path
|
||||
]
|
||||
assert len(meeting_routes) > 0
|
||||
35
server/uv.lock
generated
35
server/uv.lock
generated
@@ -2706,6 +2706,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/7f/113b16d55e8d2dd9143628eec39b138fd6c52f72dcd11b4dae4a3845da4d/pyinstrument-5.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:88df7e3ab11604ae7cef1f576c097a08752bf8fc13c5755803bd3cd92f15aba3", size = 124314, upload-time = "2025-07-02T14:13:26.708Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylibsrtp"
|
||||
version = "0.12.0"
|
||||
@@ -3136,6 +3145,7 @@ dependencies = [
|
||||
{ name = "protobuf" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pytest-env" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
@@ -3213,6 +3223,7 @@ requires-dist = [
|
||||
{ name = "protobuf", specifier = ">=4.24.3" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "pytest-env", specifier = ">=1.1.5" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
@@ -3954,8 +3965,8 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:3d05017d19bc99741288e458888283a44b0ee881d53f05f72f8b1cfea8998122" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3980,16 +3991,16 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:2bfc013dd6efdc8f8223a0241d3529af9f315dffefb53ffa3bf14d3f10127da6" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:680129efdeeec3db5da3f88ee5d28c1b1e103b774aef40f9d638e2cce8f8d8d8" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb06175284673a581dd91fb1965662ae4ecaba6e5c357aa0ea7bb8b84b6b7eeb" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7631ef49fbd38d382909525b83696dc12a55d68492ade4ace3883c62b9fc140f" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:41e6fc5ec0914fcdce44ccf338b1d19a441b55cafdd741fd0bf1af3f9e4cfd14" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl" },
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
46
www/.env.template
Normal file
46
www/.env.template
Normal file
@@ -0,0 +1,46 @@
|
||||
# NextAuth configuration
|
||||
NEXTAUTH_SECRET="your-secret-key"
|
||||
NEXTAUTH_URL="http://localhost:3000/"
|
||||
|
||||
# API configuration
|
||||
NEXT_PUBLIC_API_URL="http://127.0.0.1:1250"
|
||||
NEXT_PUBLIC_WEBSOCKET_URL="ws://127.0.0.1:1250"
|
||||
NEXT_PUBLIC_AUTH_CALLBACK_URL="http://localhost:3000/auth-callback"
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000/"
|
||||
|
||||
# Environment
|
||||
NEXT_PUBLIC_ENV="development"
|
||||
ENVIRONMENT="development"
|
||||
|
||||
# Video Platform Configuration
|
||||
# Options: "whereby" | "jitsi" (default: whereby)
|
||||
NEXT_PUBLIC_VIDEO_PLATFORM="whereby"
|
||||
|
||||
# Features
|
||||
NEXT_PUBLIC_PROJECTOR_MODE="false"
|
||||
|
||||
# Authentication providers (optional)
|
||||
# Authentik
|
||||
AUTHENTIK_CLIENT_ID=""
|
||||
AUTHENTIK_CLIENT_SECRET=""
|
||||
AUTHENTIK_ISSUER=""
|
||||
AUTHENTIK_REFRESH_TOKEN_URL=""
|
||||
|
||||
# Fief
|
||||
FIEF_CLIENT_ID=""
|
||||
FIEF_CLIENT_SECRET=""
|
||||
FIEF_URL=""
|
||||
|
||||
# Zulip integration (optional)
|
||||
ZULIP_API_KEY=""
|
||||
ZULIP_BOT_EMAIL=""
|
||||
ZULIP_REALM=""
|
||||
|
||||
# External services (optional)
|
||||
ZEPHYR_LLM_URL=""
|
||||
|
||||
# Redis/KV (optional)
|
||||
KV_REST_API_TOKEN=""
|
||||
KV_REST_API_READ_ONLY_TOKEN=""
|
||||
KV_REST_API_URL=""
|
||||
KV_URL=""
|
||||
@@ -10,12 +10,17 @@ import {
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
} from "@chakra-ui/react";
|
||||
import { LuLink } from "react-icons/lu";
|
||||
import type { components } from "../../../reflector-api";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||
import {
|
||||
getPlatformDisplayName,
|
||||
getPlatformColor,
|
||||
} from "../../../lib/videoPlatforms";
|
||||
|
||||
interface RoomCardsProps {
|
||||
rooms: Room[];
|
||||
@@ -95,6 +100,15 @@ export function RoomCards({
|
||||
/>
|
||||
</Flex>
|
||||
<VStack align="start" fontSize="sm" gap={0}>
|
||||
<HStack gap={2}>
|
||||
<Text fontWeight="500">Platform:</Text>
|
||||
<Badge
|
||||
colorPalette={getPlatformColor(room.platform)}
|
||||
size="sm"
|
||||
>
|
||||
{getPlatformDisplayName(room.platform)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{room.zulip_auto_post && (
|
||||
<HStack gap={2}>
|
||||
<Text fontWeight="500">Zulip:</Text>
|
||||
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
IconButton,
|
||||
Text,
|
||||
Spinner,
|
||||
Badge,
|
||||
} from "@chakra-ui/react";
|
||||
import { LuLink } from "react-icons/lu";
|
||||
import type { components } from "../../../reflector-api";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
import { RoomActionsMenu } from "./RoomActionsMenu";
|
||||
import {
|
||||
getPlatformDisplayName,
|
||||
getPlatformColor,
|
||||
} from "../../../lib/videoPlatforms";
|
||||
|
||||
interface RoomTableProps {
|
||||
rooms: Room[];
|
||||
@@ -94,16 +99,19 @@ export function RoomTable({
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
||||
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||
Room Name
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="250px" fontWeight="600">
|
||||
Zulip
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="150px" fontWeight="600">
|
||||
Room Size
|
||||
<Table.ColumnHeader width="120px" fontWeight="600">
|
||||
Platform
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||
Zulip
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="130px" fontWeight="600">
|
||||
Room Size
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="180px" fontWeight="600">
|
||||
Recording
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
@@ -118,6 +126,14 @@ export function RoomTable({
|
||||
<Table.Cell>
|
||||
<Link href={`/${room.name}`}>{room.name}</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
colorPalette={getPlatformColor(room.platform)}
|
||||
size="sm"
|
||||
>
|
||||
{getPlatformDisplayName(room.platform)}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{getZulipDisplay(
|
||||
room.zulip_auto_post,
|
||||
|
||||
326
www/app/[roomName]/page-old.tsx
Normal file
326
www/app/[roomName]/page-old.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useContext,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { toaster } from "../components/ui/toaster";
|
||||
import useRoomMeeting from "./useRoomMeeting";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import useSessionStatus from "../lib/useSessionStatus";
|
||||
import { useRecordingConsent } from "../recordingConsentContext";
|
||||
import useApi from "../lib/useApi";
|
||||
import { Meeting } from "../api";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
|
||||
export type RoomDetails = {
|
||||
params: {
|
||||
roomName: string;
|
||||
};
|
||||
};
|
||||
|
||||
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
|
||||
const useConsentWherebyFocusManagement = (
|
||||
acceptButtonRef: RefObject<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useContext,
|
||||
RefObject,
|
||||
use,
|
||||
} from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { toaster } from "../components/ui/toaster";
|
||||
import { useCallback, useEffect, useState, use } from "react";
|
||||
import { Box, Spinner } from "@chakra-ui/react";
|
||||
import useRoomMeeting from "./useRoomMeeting";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useRecordingConsent } from "../recordingConsentContext";
|
||||
import { useMeetingAudioConsent } from "../lib/apiHooks";
|
||||
import type { components } from "../reflector-api";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import { useAuth } from "../lib/AuthProvider";
|
||||
import VideoPlatformEmbed from "../lib/videoPlatforms/VideoPlatformEmbed";
|
||||
|
||||
export type RoomDetails = {
|
||||
params: Promise<{
|
||||
@@ -36,229 +14,9 @@ export type RoomDetails = {
|
||||
}>;
|
||||
};
|
||||
|
||||
// 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();
|
||||
// toast would open duplicates, even with using "id=" prop
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (meetingId: string, given: boolean) => {
|
||||
try {
|
||||
await audioConsentMutation.mutateAsync({
|
||||
params: {
|
||||
path: {
|
||||
meeting_id: meetingId,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
consent_given: given,
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
},
|
||||
[audioConsentMutation, 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: audioConsentMutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
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 [platformReady, setPlatformReady] = useState(false);
|
||||
const params = use(details.params);
|
||||
const wherebyLoaded = useWhereby();
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
const roomName = params.roomName;
|
||||
const meeting = useRoomMeeting(roomName);
|
||||
const router = useRouter();
|
||||
@@ -266,18 +24,14 @@ export default function Room(details: RoomDetails) {
|
||||
const isAuthenticated = status === "authenticated";
|
||||
const isLoading = status === "loading" || meeting.loading;
|
||||
|
||||
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]);
|
||||
|
||||
const handlePlatformReady = useCallback(() => {
|
||||
setPlatformReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
@@ -289,16 +43,6 @@ export default function Room(details: RoomDetails) {
|
||||
}
|
||||
}, [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
|
||||
@@ -314,23 +58,15 @@ export default function Room(details: RoomDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!meeting?.response || !isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{roomUrl && meetingId && wherebyLoaded && (
|
||||
<>
|
||||
<whereby-embed
|
||||
ref={wherebyRef}
|
||||
room={roomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{recordingType && recordingTypeRequiresConsent(recordingType) && (
|
||||
<ConsentDialogButton
|
||||
meetingId={meetingId}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<VideoPlatformEmbed
|
||||
meeting={meeting.response}
|
||||
onLeave={handleLeave}
|
||||
onReady={handlePlatformReady}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
302
www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx
Normal file
302
www/app/lib/videoPlatforms/VideoPlatformEmbed.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, RefObject } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from "@chakra-ui/react";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import { Meeting, VideoPlatform } from "../../api";
|
||||
import { getVideoPlatformAdapter, getCurrentVideoPlatform } from "./factory";
|
||||
import { useRecordingConsent } from "../../recordingConsentContext";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import useApi from "../useApi";
|
||||
|
||||
interface VideoPlatformEmbedProps {
|
||||
meeting: Meeting;
|
||||
platform?: VideoPlatform;
|
||||
onLeave?: () => void;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
// Focus management hook for platforms that support it
|
||||
const usePlatformFocusManagement = (
|
||||
acceptButtonRef: RefObject<HTMLButtonElement>,
|
||||
platformRef: RefObject<HTMLElement>,
|
||||
supportsFocusManagement: boolean,
|
||||
) => {
|
||||
const currentFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsFocusManagement) return;
|
||||
|
||||
if (acceptButtonRef.current) {
|
||||
acceptButtonRef.current.focus();
|
||||
} else {
|
||||
console.error(
|
||||
"accept button ref not available yet for focus management - seems to be illegal state",
|
||||
);
|
||||
}
|
||||
|
||||
const handlePlatformReady = () => {
|
||||
console.log("platform ready - refocusing consent button");
|
||||
currentFocusRef.current = document.activeElement as HTMLElement;
|
||||
if (acceptButtonRef.current) {
|
||||
acceptButtonRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (platformRef.current) {
|
||||
platformRef.current.addEventListener("ready", handlePlatformReady);
|
||||
} else {
|
||||
console.warn(
|
||||
"platform ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
platformRef.current?.removeEventListener("ready", handlePlatformReady);
|
||||
currentFocusRef.current?.focus();
|
||||
};
|
||||
}, [acceptButtonRef, platformRef, supportsFocusManagement]);
|
||||
};
|
||||
|
||||
const useConsentDialog = (
|
||||
meetingId: string,
|
||||
platformRef: RefObject<HTMLElement>,
|
||||
supportsFocusManagement: boolean,
|
||||
) => {
|
||||
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);
|
||||
usePlatformFocusManagement(
|
||||
buttonRef,
|
||||
platformRef,
|
||||
supportsFocusManagement,
|
||||
);
|
||||
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,
|
||||
platformRef,
|
||||
modalOpen,
|
||||
supportsFocusManagement,
|
||||
]);
|
||||
|
||||
return { showConsentModal, consentState, hasConsent, consentLoading };
|
||||
};
|
||||
|
||||
function ConsentDialogButton({
|
||||
meetingId,
|
||||
platformRef,
|
||||
supportsFocusManagement,
|
||||
}: {
|
||||
meetingId: string;
|
||||
platformRef: React.RefObject<HTMLElement>;
|
||||
supportsFocusManagement: boolean;
|
||||
}) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId, platformRef, supportsFocusManagement);
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
export default function VideoPlatformEmbed({
|
||||
meeting,
|
||||
platform,
|
||||
onLeave,
|
||||
onReady,
|
||||
}: VideoPlatformEmbedProps) {
|
||||
const platformRef = useRef<HTMLElement>(null);
|
||||
const selectedPlatform = platform || getCurrentVideoPlatform();
|
||||
const adapter = getVideoPlatformAdapter(selectedPlatform);
|
||||
const PlatformComponent = adapter.component;
|
||||
|
||||
const meetingId = meeting.id;
|
||||
const recordingType = meeting.recording_type;
|
||||
|
||||
// Handle leave event
|
||||
const handleLeave = useCallback(() => {
|
||||
if (onLeave) {
|
||||
onLeave();
|
||||
}
|
||||
}, [onLeave]);
|
||||
|
||||
// Handle ready event
|
||||
const handleReady = useCallback(() => {
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
// Set up leave event listener for platforms that support it
|
||||
useEffect(() => {
|
||||
if (!platformRef.current) return;
|
||||
|
||||
const element = platformRef.current;
|
||||
element.addEventListener("leave", handleLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("leave", handleLeave);
|
||||
};
|
||||
}, [handleLeave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlatformComponent
|
||||
ref={platformRef}
|
||||
meeting={meeting}
|
||||
roomRef={platformRef}
|
||||
onReady={handleReady}
|
||||
/>
|
||||
{recordingType &&
|
||||
recordingTypeRequiresConsent(recordingType) &&
|
||||
adapter.requiresConsent && (
|
||||
<ConsentDialogButton
|
||||
meetingId={meetingId}
|
||||
platformRef={platformRef}
|
||||
supportsFocusManagement={adapter.supportsFocusManagement}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
www/app/lib/videoPlatforms/factory.ts
Normal file
29
www/app/lib/videoPlatforms/factory.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { VideoPlatform } from "../../api";
|
||||
import { VideoPlatformAdapter } from "./types";
|
||||
import { localConfig } from "../../../config-template";
|
||||
|
||||
// Platform implementations
|
||||
import { WherebyAdapter } from "./whereby/WherebyAdapter";
|
||||
import { JitsiAdapter } from "./jitsi/JitsiAdapter";
|
||||
|
||||
const platformAdapters: Record<VideoPlatform, VideoPlatformAdapter> = {
|
||||
whereby: WherebyAdapter,
|
||||
jitsi: JitsiAdapter,
|
||||
};
|
||||
|
||||
export function getVideoPlatformAdapter(
|
||||
platform?: VideoPlatform,
|
||||
): VideoPlatformAdapter {
|
||||
const selectedPlatform = platform || localConfig.video_platform;
|
||||
|
||||
const adapter = platformAdapters[selectedPlatform];
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported video platform: ${selectedPlatform}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function getCurrentVideoPlatform(): VideoPlatform {
|
||||
return localConfig.video_platform;
|
||||
}
|
||||
5
www/app/lib/videoPlatforms/index.ts
Normal file
5
www/app/lib/videoPlatforms/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./types";
|
||||
export * from "./factory";
|
||||
export * from "./whereby";
|
||||
export * from "./jitsi";
|
||||
export * from "./utils";
|
||||
8
www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx
Normal file
8
www/app/lib/videoPlatforms/jitsi/JitsiAdapter.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { VideoPlatformAdapter } from "../types";
|
||||
import JitsiProvider from "./JitsiProvider";
|
||||
|
||||
export const JitsiAdapter: VideoPlatformAdapter = {
|
||||
component: JitsiProvider,
|
||||
requiresConsent: true,
|
||||
supportsFocusManagement: false, // Jitsi iframe doesn't support the same focus management as Whereby
|
||||
};
|
||||
68
www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx
Normal file
68
www/app/lib/videoPlatforms/jitsi/JitsiProvider.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { VideoPlatformComponentProps } from "../types";
|
||||
|
||||
const JitsiProvider = forwardRef<HTMLElement, VideoPlatformComponentProps>(
|
||||
({ meeting, roomRef, onReady, onConsentGiven, onConsentDeclined }, ref) => {
|
||||
const [jitsiReady, setJitsiReady] = useState(false);
|
||||
const internalRef = useRef<HTMLIFrameElement>(null);
|
||||
const iframeRef =
|
||||
(roomRef as React.RefObject<HTMLIFrameElement>) || internalRef;
|
||||
|
||||
// Expose the element ref through the forwarded ref
|
||||
useImperativeHandle(ref, () => iframeRef.current!, [iframeRef]);
|
||||
|
||||
// Handle iframe load
|
||||
const handleLoad = useCallback(() => {
|
||||
setJitsiReady(true);
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
// Set up event listeners
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
};
|
||||
}, [handleLoad]);
|
||||
|
||||
if (!meeting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For Jitsi, we use the room_url (user JWT) or host_room_url (moderator JWT)
|
||||
const roomUrl = meeting.host_room_url || meeting.room_url;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={roomUrl}
|
||||
style={{
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
}}
|
||||
allow="camera; microphone; fullscreen; display-capture; autoplay"
|
||||
title="Jitsi Meet"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
JitsiProvider.displayName = "JitsiProvider";
|
||||
|
||||
export default JitsiProvider;
|
||||
2
www/app/lib/videoPlatforms/jitsi/index.ts
Normal file
2
www/app/lib/videoPlatforms/jitsi/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as JitsiProvider } from "./JitsiProvider";
|
||||
export { JitsiAdapter } from "./JitsiAdapter";
|
||||
32
www/app/lib/videoPlatforms/types.ts
Normal file
32
www/app/lib/videoPlatforms/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { RefObject, ReactNode } from "react";
|
||||
import { VideoPlatform, Meeting } from "../../api";
|
||||
|
||||
export interface VideoPlatformProviderProps {
|
||||
meeting: Meeting;
|
||||
onConsentGiven?: () => void;
|
||||
onConsentDeclined?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export interface VideoPlatformContextValue {
|
||||
platform: VideoPlatform;
|
||||
meeting: Meeting | null;
|
||||
isReady: boolean;
|
||||
hasConsent: boolean;
|
||||
giveConsent: () => void;
|
||||
declineConsent: () => void;
|
||||
}
|
||||
|
||||
export interface VideoPlatformComponentProps {
|
||||
meeting: Meeting;
|
||||
roomRef?: RefObject<HTMLElement>;
|
||||
onReady?: () => void;
|
||||
onConsentGiven?: () => void;
|
||||
onConsentDeclined?: () => void;
|
||||
}
|
||||
|
||||
export interface VideoPlatformAdapter {
|
||||
component: React.ComponentType<VideoPlatformComponentProps>;
|
||||
requiresConsent: boolean;
|
||||
supportsFocusManagement: boolean;
|
||||
}
|
||||
23
www/app/lib/videoPlatforms/utils.ts
Normal file
23
www/app/lib/videoPlatforms/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { VideoPlatform } from "../../api";
|
||||
|
||||
export const getPlatformDisplayName = (platform?: VideoPlatform): string => {
|
||||
switch (platform) {
|
||||
case "whereby":
|
||||
return "Whereby";
|
||||
case "jitsi":
|
||||
return "Jitsi Meet";
|
||||
default:
|
||||
return "Whereby"; // Default fallback
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatformColor = (platform?: VideoPlatform): string => {
|
||||
switch (platform) {
|
||||
case "whereby":
|
||||
return "blue";
|
||||
case "jitsi":
|
||||
return "green";
|
||||
default:
|
||||
return "blue"; // Default fallback
|
||||
}
|
||||
};
|
||||
8
www/app/lib/videoPlatforms/whereby/WherebyAdapter.tsx
Normal file
8
www/app/lib/videoPlatforms/whereby/WherebyAdapter.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { VideoPlatformAdapter } from "../types";
|
||||
import WherebyProvider from "./WherebyProvider";
|
||||
|
||||
export const WherebyAdapter: VideoPlatformAdapter = {
|
||||
component: WherebyProvider,
|
||||
requiresConsent: true,
|
||||
supportsFocusManagement: true,
|
||||
};
|
||||
94
www/app/lib/videoPlatforms/whereby/WherebyProvider.tsx
Normal file
94
www/app/lib/videoPlatforms/whereby/WherebyProvider.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { VideoPlatformComponentProps } from "../types";
|
||||
|
||||
// Whereby embed element type declaration
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"whereby-embed": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
room?: string;
|
||||
style?: React.CSSProperties;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const WherebyProvider = forwardRef<HTMLElement, VideoPlatformComponentProps>(
|
||||
({ meeting, roomRef, onReady, onConsentGiven, onConsentDeclined }, ref) => {
|
||||
const [wherebyLoaded, setWherebyLoaded] = useState(false);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
const elementRef = roomRef || internalRef;
|
||||
|
||||
// Expose the element ref through the forwarded ref
|
||||
useImperativeHandle(ref, () => elementRef.current!, [elementRef]);
|
||||
|
||||
// Load Whereby SDK
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
import("@whereby.com/browser-sdk/embed")
|
||||
.then(() => {
|
||||
setWherebyLoaded(true);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle leave event
|
||||
const handleLeave = useCallback(() => {
|
||||
// This will be handled by the parent component
|
||||
// through router navigation or other means
|
||||
}, []);
|
||||
|
||||
// Handle ready event
|
||||
const handleReady = useCallback(() => {
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
// Set up event listeners
|
||||
useEffect(() => {
|
||||
if (!wherebyLoaded || !elementRef.current) return;
|
||||
|
||||
const element = elementRef.current;
|
||||
|
||||
element.addEventListener("leave", handleLeave);
|
||||
element.addEventListener("ready", handleReady);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("leave", handleLeave);
|
||||
element.removeEventListener("ready", handleReady);
|
||||
};
|
||||
}, [wherebyLoaded, handleLeave, handleReady, elementRef]);
|
||||
|
||||
if (!wherebyLoaded || !meeting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roomUrl = meeting.host_room_url || meeting.room_url;
|
||||
|
||||
return (
|
||||
<whereby-embed
|
||||
ref={elementRef}
|
||||
room={roomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WherebyProvider.displayName = "WherebyProvider";
|
||||
|
||||
export default WherebyProvider;
|
||||
2
www/app/lib/videoPlatforms/whereby/index.ts
Normal file
2
www/app/lib/videoPlatforms/whereby/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as WherebyProvider } from "./WherebyProvider";
|
||||
export { WherebyAdapter } from "./WherebyAdapter";
|
||||
204
www/app/reflector-api.d.ts
vendored
204
www/app/reflector-api.d.ts
vendored
@@ -104,10 +104,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Rooms Test Webhook
|
||||
* @description Test webhook configuration by sending a sample payload.
|
||||
*/
|
||||
/** Rooms Test Webhook */
|
||||
post: operations["v1_rooms_test_webhook"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -488,6 +485,75 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jitsi/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Jitsi Events Webhook
|
||||
* @description Handle Prosody event-sync webhooks from Jitsi Meet.
|
||||
*
|
||||
* Expected event types:
|
||||
* - muc-occupant-joined: participant joined the room
|
||||
* - muc-occupant-left: participant left the room
|
||||
* - jibri-recording-on: recording started
|
||||
* - jibri-recording-off: recording stopped
|
||||
*/
|
||||
post: operations["v1_jitsi_events_webhook"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jibri/recording-complete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Jibri Recording Complete
|
||||
* @description Handle Jibri recording completion webhook.
|
||||
*
|
||||
* This endpoint is called by the Jibri finalize script when a recording
|
||||
* is completed and uploaded to storage.
|
||||
*/
|
||||
post: operations["v1_jibri_recording_complete"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/jitsi/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Jitsi Health Check
|
||||
* @description Simple health check endpoint for Jitsi webhook configuration.
|
||||
*/
|
||||
get: operations["v1_jitsi_health_check"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -536,6 +602,7 @@ export interface components {
|
||||
webhook_url: string;
|
||||
/** Webhook Secret */
|
||||
webhook_secret: string;
|
||||
platform: components["schemas"]["VideoPlatform"];
|
||||
};
|
||||
/** CreateTranscript */
|
||||
CreateTranscript: {
|
||||
@@ -748,6 +815,39 @@ export interface components {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** JibriRecordingEvent */
|
||||
JibriRecordingEvent: {
|
||||
/** Room Name */
|
||||
room_name: string;
|
||||
/** Recording File */
|
||||
recording_file: string;
|
||||
/** Recording Status */
|
||||
recording_status: string;
|
||||
/**
|
||||
* Timestamp
|
||||
* Format: date-time
|
||||
*/
|
||||
timestamp: string;
|
||||
};
|
||||
/** JitsiWebhookEvent */
|
||||
JitsiWebhookEvent: {
|
||||
/** Event */
|
||||
event: string;
|
||||
/** Room */
|
||||
room: string;
|
||||
/**
|
||||
* Timestamp
|
||||
* Format: date-time
|
||||
*/
|
||||
timestamp: string;
|
||||
/**
|
||||
* Data
|
||||
* @default {}
|
||||
*/
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** Meeting */
|
||||
Meeting: {
|
||||
/** Id */
|
||||
@@ -844,6 +944,8 @@ export interface components {
|
||||
recording_trigger: string;
|
||||
/** Is Shared */
|
||||
is_shared: boolean;
|
||||
/** @default whereby */
|
||||
platform: components["schemas"]["VideoPlatform"];
|
||||
};
|
||||
/** RoomDetails */
|
||||
RoomDetails: {
|
||||
@@ -874,6 +976,8 @@ export interface components {
|
||||
recording_trigger: string;
|
||||
/** Is Shared */
|
||||
is_shared: boolean;
|
||||
/** @default whereby */
|
||||
platform: components["schemas"]["VideoPlatform"];
|
||||
/** Webhook Url */
|
||||
webhook_url: string | null;
|
||||
/** Webhook Secret */
|
||||
@@ -1043,6 +1147,7 @@ export interface components {
|
||||
webhook_url: string;
|
||||
/** Webhook Secret */
|
||||
webhook_secret: string;
|
||||
platform: components["schemas"]["VideoPlatform"];
|
||||
};
|
||||
/** UpdateTranscript */
|
||||
UpdateTranscript: {
|
||||
@@ -1083,6 +1188,11 @@ export interface components {
|
||||
/** Error Type */
|
||||
type: string;
|
||||
};
|
||||
/**
|
||||
* VideoPlatform
|
||||
* @enum {string}
|
||||
*/
|
||||
VideoPlatform: "whereby" | "jitsi";
|
||||
/** WebhookTestResult */
|
||||
WebhookTestResult: {
|
||||
/** Success */
|
||||
@@ -2336,4 +2446,90 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_jitsi_events_webhook: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["JitsiWebhookEvent"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_jibri_recording_complete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["JibriRecordingEvent"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_jitsi_health_check: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user