Browse Source
feat(course): add PlugNMeet webhook for session events and recordings
feat(course): add PlugNMeet webhook for session events and recordings
Introduce PlugNMeetWebhookAPIView to handle: - room_finished: close live session and mark users offline - participant_joined: create/reactivate LiveSessionUser - participant_left: mark user offline with exit timestamp - end_recording: fetch info, obtain token, download file, save LiveSessionRecording, and generate video thumbnails via ffmpeg Add new PlugNMeetClient methods: - get_recording_info - get_recording_download_token - download_file Expose webhook route at /api/course/plugnmeet/webhook/ with HMAC SHA256 signature verification (Hash-Token header). Deprecate polling-based room status sync in CourseOnlineClassTokenValidateAPIView in favor of webhooks. build(docker): add ffmpeg to production image for thumbnail generation docs: add webhook setup and usage guides (README_WEBHOOK.md, docs/plugnmeet_webhook.md) chore(scripts): add create_live_room.sh and scripts/test_webhook.py for manual testing and workflow supportmaster
10 changed files with 1654 additions and 35 deletions
-
5Dockerfile.prod
-
328README_WEBHOOK.md
-
42apps/course/services/plugnmeet.py
-
3apps/course/urls.py
-
3apps/course/views/__init__.py
-
65apps/course/views/course.py
-
564apps/course/views/webhook.py
-
86create_live_room.sh
-
360docs/plugnmeet_webhook.md
-
233scripts/test_webhook.py
@ -0,0 +1,328 @@ |
|||
# PlugNMeet Webhook Integration - Quick Setup Guide |
|||
|
|||
## Overview |
|||
|
|||
This project implements automatic webhook integration with PlugNMeet to handle live session events in real-time. |
|||
|
|||
## Features |
|||
|
|||
✅ **Room Management** |
|||
- Automatically close sessions when room ends |
|||
- Real-time session status updates |
|||
|
|||
✅ **Participant Tracking** |
|||
- Track when users join/leave sessions |
|||
- Maintain accurate online status |
|||
|
|||
✅ **Recording Management** |
|||
- Automatically download completed recordings |
|||
- Generate video thumbnails |
|||
- Save to database with metadata |
|||
|
|||
## Prerequisites |
|||
|
|||
### Required Software |
|||
|
|||
```bash |
|||
# Install FFmpeg (required for video thumbnail generation) |
|||
sudo apt-get update |
|||
sudo apt-get install ffmpeg |
|||
|
|||
# Verify installation |
|||
ffmpeg -version |
|||
``` |
|||
|
|||
### Django Settings |
|||
|
|||
Ensure these settings are configured in your `settings.py`: |
|||
|
|||
```python |
|||
# PlugNMeet Configuration |
|||
PLUGNMEET_SERVER_URL = "https://your-plugnmeet-server.com" |
|||
PLUGNMEET_API_KEY = "your-api-key" |
|||
PLUGNMEET_API_SECRET = "your-api-secret" |
|||
PLUGNMEET_TIMEOUT = 10.0 |
|||
|
|||
# Media files (for recordings) |
|||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') |
|||
MEDIA_URL = '/media/' |
|||
``` |
|||
|
|||
## PlugNMeet Server Configuration |
|||
|
|||
Configure webhook in your PlugNMeet server settings: |
|||
|
|||
```yaml |
|||
# plugnmeet config.yaml |
|||
webhooks: |
|||
- url: "https://your-django-backend.com/api/course/plugnmeet/webhook/" |
|||
events: |
|||
- room_finished |
|||
- participant_joined |
|||
- participant_left |
|||
- end_recording |
|||
``` |
|||
|
|||
## Webhook Endpoint |
|||
|
|||
**URL:** `https://your-domain.com/api/course/plugnmeet/webhook/` |
|||
|
|||
**Method:** `POST` |
|||
|
|||
**Security:** HMAC SHA256 signature verification |
|||
|
|||
## Events Handled |
|||
|
|||
### 1. room_finished |
|||
- Closes the live session |
|||
- Marks all participants as offline |
|||
- Sets `ended_at` timestamp |
|||
|
|||
### 2. participant_joined |
|||
- Creates `LiveSessionUser` entry |
|||
- Sets user as online |
|||
- Records join timestamp |
|||
|
|||
### 3. participant_left |
|||
- Updates `LiveSessionUser` entry |
|||
- Sets user as offline |
|||
- Records exit timestamp |
|||
|
|||
### 4. end_recording |
|||
- Fetches recording from PlugNMeet |
|||
- Downloads recording file |
|||
- Saves to `LiveSessionRecording` model |
|||
- Generates video thumbnail (if applicable) |
|||
|
|||
## Testing |
|||
|
|||
### Using the Test Script |
|||
|
|||
```bash |
|||
# Test room_finished event |
|||
python scripts/test_webhook.py room_finished |
|||
|
|||
# Test participant_joined event |
|||
python scripts/test_webhook.py participant_joined |
|||
|
|||
# Test participant_left event |
|||
python scripts/test_webhook.py participant_left |
|||
|
|||
# Test end_recording event |
|||
python scripts/test_webhook.py end_recording |
|||
|
|||
# Dry run (show payload without sending) |
|||
python scripts/test_webhook.py room_finished --dry-run |
|||
``` |
|||
|
|||
### Manual Testing with cURL |
|||
|
|||
```bash |
|||
#!/bin/bash |
|||
|
|||
# Configuration |
|||
SECRET="your-api-secret" |
|||
URL="https://your-domain.com/api/course/plugnmeet/webhook/" |
|||
|
|||
# Sample payload |
|||
PAYLOAD='{ |
|||
"event": "room_finished", |
|||
"room": { |
|||
"identity": "test-room-20240101120000" |
|||
} |
|||
}' |
|||
|
|||
# Calculate signature |
|||
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) |
|||
|
|||
# Send request |
|||
curl -X POST "$URL" \ |
|||
-H "Content-Type: application/webhook+json" \ |
|||
-H "Hash-Token: $SIGNATURE" \ |
|||
-d "$PAYLOAD" |
|||
``` |
|||
|
|||
## Monitoring |
|||
|
|||
### Check Logs |
|||
|
|||
```bash |
|||
# Django logs |
|||
tail -f logs/django.log | grep "PlugNMeet Webhook" |
|||
|
|||
# Specific events |
|||
tail -f logs/django.log | grep "end_recording" |
|||
tail -f logs/django.log | grep "participant_joined" |
|||
``` |
|||
|
|||
### Log Messages |
|||
|
|||
``` |
|||
[PlugNMeet Webhook] Received webhook request |
|||
[PlugNMeet Webhook] Processing event=room_finished |
|||
[PlugNMeet Webhook] Session closed - session_id=123 room_id=test-room |
|||
[PlugNMeet Webhook] User sessions closed - session_id=123 count=5 |
|||
[PlugNMeet Webhook] Event processed successfully - event=room_finished |
|||
``` |
|||
|
|||
### Recording Download Logs |
|||
|
|||
``` |
|||
[PlugNMeet Webhook] end_recording - room_id=test-room recording_id=rec_123 |
|||
[PlugNMeet Webhook] Fetching recording info - recording_id=rec_123 |
|||
[PlugNMeet Webhook] Getting download token - recording_id=rec_123 |
|||
[PlugNMeet Webhook] Downloading recording file - recording_id=rec_123 |
|||
[PlugNMeet Webhook] File downloaded - size=524288000 bytes |
|||
[PlugNMeet Webhook] Recording saved - recording_id=456 file=recording.mp4 |
|||
[PlugNMeet Webhook] Thumbnail generated - recording_id=456 |
|||
``` |
|||
|
|||
## Database Models |
|||
|
|||
### CourseLiveSession |
|||
|
|||
```python |
|||
{ |
|||
'id': 123, |
|||
'course': Course instance, |
|||
'room_id': 'test-room-20240101120000', |
|||
'subject': 'Test Session', |
|||
'started_at': datetime, |
|||
'ended_at': datetime, # Set by webhook |
|||
} |
|||
``` |
|||
|
|||
### LiveSessionUser |
|||
|
|||
```python |
|||
{ |
|||
'id': 456, |
|||
'session': CourseLiveSession instance, |
|||
'user': User instance, |
|||
'role': 'participant' or 'moderator', |
|||
'entered_at': datetime, # Set by webhook |
|||
'exited_at': datetime, # Set by webhook |
|||
'is_online': True/False, # Updated by webhook |
|||
} |
|||
``` |
|||
|
|||
### LiveSessionRecording |
|||
|
|||
```python |
|||
{ |
|||
'id': 789, |
|||
'session': CourseLiveSession instance, |
|||
'title': 'Test Session - Recording', |
|||
'file': FileField, # Downloaded by webhook |
|||
'file_time': DurationField, |
|||
'recording_type': 'video' or 'voice', |
|||
'thumbnail': ImageField, # Generated by webhook |
|||
'is_active': True, |
|||
} |
|||
``` |
|||
|
|||
## Troubleshooting |
|||
|
|||
### Webhook Not Receiving Events |
|||
|
|||
1. Check PlugNMeet server configuration |
|||
2. Verify webhook URL is accessible from PlugNMeet server |
|||
3. Check firewall rules |
|||
4. Review PlugNMeet server logs |
|||
|
|||
### Signature Verification Failed |
|||
|
|||
1. Ensure `PLUGNMEET_API_SECRET` matches PlugNMeet config |
|||
2. Check for extra whitespace in settings |
|||
3. Verify request is coming from PlugNMeet server |
|||
|
|||
### Recording Download Failed |
|||
|
|||
1. Check PlugNMeet server is accessible |
|||
2. Verify recording exists: `POST /auth/recording/recordingInfo` |
|||
3. Check disk space |
|||
4. Review media directory permissions |
|||
|
|||
### Thumbnail Generation Failed |
|||
|
|||
1. Verify ffmpeg is installed: `ffmpeg -version` |
|||
2. Check ffmpeg has permissions to read/write temp files |
|||
3. Review video file format (mp4, webm, mkv supported) |
|||
4. Check server resources (CPU, memory) |
|||
|
|||
### File Upload Errors |
|||
|
|||
```python |
|||
# Check media directory permissions |
|||
ls -la media/ |
|||
chmod -R 755 media/ |
|||
|
|||
# Check Django settings |
|||
python manage.py shell |
|||
>>> from django.conf import settings |
|||
>>> print(settings.MEDIA_ROOT) |
|||
>>> print(settings.MEDIA_URL) |
|||
``` |
|||
|
|||
## Performance Considerations |
|||
|
|||
### Disk Space |
|||
|
|||
- Monitor disk space for recordings |
|||
- Implement cleanup policy for old recordings |
|||
- Consider using external storage (S3, MinIO) |
|||
|
|||
### Processing Time |
|||
|
|||
- Large recordings may take time to download |
|||
- Thumbnail generation adds 1-3 seconds per video |
|||
- Consider async processing for large files (Celery) |
|||
|
|||
### Concurrent Webhooks |
|||
|
|||
- Django handles webhooks synchronously by default |
|||
- For high-traffic scenarios, consider: |
|||
- Queue system (Celery, RQ) |
|||
- Async views (Django 4.1+) |
|||
- Horizontal scaling |
|||
|
|||
## Migration from Polling |
|||
|
|||
The old polling approach has been deprecated and commented out: |
|||
|
|||
```python |
|||
# OLD (Deprecated) - in apps/course/views/course.py |
|||
# def _sync_room_status_with_plugnmeet(self, course: Course): |
|||
# client = PlugNMeetClient() |
|||
# response = client.is_room_active(active_session.room_id) |
|||
# ... |
|||
|
|||
# NEW (Webhook-based) |
|||
# Room status is automatically updated via webhooks |
|||
# No polling required |
|||
``` |
|||
|
|||
## Security Best Practices |
|||
|
|||
1. ✅ **Signature Verification**: Always enabled (HMAC SHA256) |
|||
2. ✅ **HTTPS Only**: Webhook endpoint requires HTTPS |
|||
3. ✅ **IP Whitelist**: Consider restricting to PlugNMeet server IP |
|||
4. ✅ **Rate Limiting**: Implement rate limiting on webhook endpoint |
|||
5. ✅ **Input Validation**: All webhook payloads are validated |
|||
6. ✅ **Error Handling**: Comprehensive error handling and logging |
|||
|
|||
## Support |
|||
|
|||
For issues or questions: |
|||
|
|||
1. Check logs: `logs/django.log` |
|||
2. Review documentation: `docs/plugnmeet_webhook.md` |
|||
3. Test with script: `scripts/test_webhook.py` |
|||
4. Check PlugNMeet docs: https://www.plugnmeet.org/docs |
|||
|
|||
## References |
|||
|
|||
- [PlugNMeet Webhook Documentation](docs/plugnmeet_webhook.md) |
|||
- [PlugNMeet API Documentation](docs/plugnmeet_api.md) |
|||
- [Test Script](scripts/test_webhook.py) |
|||
- [Webhook Implementation](apps/course/views/webhook.py) |
|||
@ -0,0 +1,564 @@ |
|||
import json |
|||
import hmac |
|||
import hashlib |
|||
import logging |
|||
from typing import Dict, Any |
|||
|
|||
from django.conf import settings |
|||
from django.core.exceptions import ImproperlyConfigured |
|||
from django.utils import timezone |
|||
from django.utils.decorators import method_decorator |
|||
from django.views.decorators.csrf import csrf_exempt |
|||
|
|||
from rest_framework import status |
|||
from rest_framework.views import APIView |
|||
from rest_framework.response import Response |
|||
from rest_framework.permissions import AllowAny |
|||
|
|||
from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant |
|||
from apps.account.models import User |
|||
from utils.exceptions import AppAPIException |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
@method_decorator(csrf_exempt, name='dispatch') |
|||
class PlugNMeetWebhookAPIView(APIView): |
|||
""" |
|||
Webhook endpoint to receive events from PlugNMeet server. |
|||
|
|||
Events handled: |
|||
- room_finished: Close the live session |
|||
- participant_joined: Create LiveSessionUser entry |
|||
- participant_left: Mark LiveSessionUser as offline/exited |
|||
- end_recording: (Future implementation) |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
|
|||
def post(self, request, *args, **kwargs): |
|||
logger.info(f"[PlugNMeet Webhook] Received webhook request") |
|||
|
|||
# Verify webhook signature |
|||
if not self._verify_webhook_signature(request): |
|||
logger.warning(f"[PlugNMeet Webhook] Invalid signature") |
|||
raise AppAPIException( |
|||
{'message': 'Invalid webhook signature'}, |
|||
status_code=status.HTTP_403_FORBIDDEN |
|||
) |
|||
|
|||
try: |
|||
payload = json.loads(request.body.decode('utf-8')) |
|||
except (json.JSONDecodeError, UnicodeDecodeError) as e: |
|||
logger.error(f"[PlugNMeet Webhook] Invalid JSON payload - error={str(e)}") |
|||
raise AppAPIException( |
|||
{'message': 'Invalid JSON payload'}, |
|||
status_code=status.HTTP_400_BAD_REQUEST |
|||
) |
|||
|
|||
event = payload.get('event') |
|||
if not event: |
|||
logger.warning(f"[PlugNMeet Webhook] Missing event field") |
|||
raise AppAPIException( |
|||
{'message': 'Missing event field'}, |
|||
status_code=status.HTTP_400_BAD_REQUEST |
|||
) |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] Processing event={event}") |
|||
|
|||
# Route to appropriate handler |
|||
handler_map = { |
|||
'room_finished': self._handle_room_finished, |
|||
'participant_joined': self._handle_participant_joined, |
|||
'participant_left': self._handle_participant_left, |
|||
'end_recording': self._handle_end_recording, |
|||
} |
|||
|
|||
handler = handler_map.get(event) |
|||
if not handler: |
|||
logger.info(f"[PlugNMeet Webhook] Unhandled event={event}, ignoring") |
|||
return Response({'status': 'ok', 'message': f'Event {event} ignored'}) |
|||
|
|||
try: |
|||
result = handler(payload) |
|||
logger.info(f"[PlugNMeet Webhook] Event processed successfully - event={event}") |
|||
return Response({'status': 'ok', **result}) |
|||
except Exception as e: |
|||
logger.error(f"[PlugNMeet Webhook] Error processing event={event} - error={str(e)}", exc_info=True) |
|||
return Response( |
|||
{'status': 'error', 'message': str(e)}, |
|||
status=status.HTTP_500_INTERNAL_SERVER_ERROR |
|||
) |
|||
|
|||
def _verify_webhook_signature(self, request) -> bool: |
|||
""" |
|||
Verify webhook signature using SHA256 HMAC. |
|||
Expects Hash-Token header with SHA256 signature of request body. |
|||
""" |
|||
hash_token = request.headers.get('Hash-Token') |
|||
if not hash_token: |
|||
logger.warning(f"[PlugNMeet Webhook] Missing Hash-Token header") |
|||
return False |
|||
|
|||
# Get API secret from settings |
|||
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) |
|||
if not api_secret: |
|||
logger.error(f"[PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") |
|||
raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") |
|||
|
|||
# Calculate expected signature |
|||
body = request.body |
|||
expected_signature = hmac.new( |
|||
api_secret.encode('utf-8'), |
|||
body, |
|||
hashlib.sha256 |
|||
).hexdigest() |
|||
|
|||
# Compare signatures (constant time comparison) |
|||
is_valid = hmac.compare_digest(hash_token, expected_signature) |
|||
|
|||
if not is_valid: |
|||
logger.warning(f"[PlugNMeet Webhook] Signature mismatch - expected={expected_signature[:10]}... got={hash_token[:10]}...") |
|||
|
|||
return is_valid |
|||
|
|||
def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|||
""" |
|||
Handle room_finished event: Close the live session and all user sessions. |
|||
|
|||
Payload structure: |
|||
{ |
|||
"event": "room_finished", |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "course-slug-20240101120000", |
|||
"name": "کلاس جبر", |
|||
"duration": 3600 |
|||
} |
|||
} |
|||
""" |
|||
room_data = payload.get('room', {}) |
|||
room_id = room_data.get('identity') |
|||
|
|||
if not room_id: |
|||
logger.warning(f"[PlugNMeet Webhook] room_finished: Missing room identity") |
|||
return {'message': 'Missing room identity'} |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] room_finished - room_id={room_id}") |
|||
|
|||
try: |
|||
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|||
except CourseLiveSession.DoesNotExist: |
|||
logger.warning(f"[PlugNMeet Webhook] room_finished: Session not found or already ended - room_id={room_id}") |
|||
return {'message': 'Session not found or already ended'} |
|||
|
|||
# Close the session |
|||
now = timezone.now() |
|||
session.ended_at = now |
|||
session.save(update_fields=['ended_at', 'updated_at']) |
|||
logger.info(f"[PlugNMeet Webhook] Session closed - session_id={session.id} room_id={room_id}") |
|||
|
|||
# Close all active user sessions |
|||
updated_count = LiveSessionUser.objects.filter( |
|||
session=session, |
|||
is_online=True, |
|||
exited_at__isnull=True |
|||
).update( |
|||
is_online=False, |
|||
exited_at=now, |
|||
updated_at=now |
|||
) |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] User sessions closed - session_id={session.id} count={updated_count}") |
|||
|
|||
return { |
|||
'message': 'Room finished', |
|||
'session_id': session.id, |
|||
'users_disconnected': updated_count |
|||
} |
|||
|
|||
def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|||
""" |
|||
Handle participant_joined event: Create or update LiveSessionUser entry. |
|||
|
|||
Payload structure: |
|||
{ |
|||
"event": "participant_joined", |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "course-slug-20240101120000" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-27", |
|||
"identity": "27", |
|||
"name": "دانشجو نمونه", |
|||
"metadata": "{\"is_admin\": false}", |
|||
"joinedAt": 1697497300 |
|||
} |
|||
} |
|||
""" |
|||
room_data = payload.get('room', {}) |
|||
participant_data = payload.get('participant', {}) |
|||
|
|||
room_id = room_data.get('identity') |
|||
user_identity = participant_data.get('identity') |
|||
joined_at_timestamp = participant_data.get('joinedAt') |
|||
|
|||
if not room_id or not user_identity: |
|||
logger.warning(f"[PlugNMeet Webhook] participant_joined: Missing room_id or user_identity") |
|||
return {'message': 'Missing required fields'} |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] participant_joined - room_id={room_id} user_identity={user_identity}") |
|||
|
|||
# Get session |
|||
try: |
|||
session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) |
|||
except CourseLiveSession.DoesNotExist: |
|||
logger.warning(f"[PlugNMeet Webhook] participant_joined: Active session not found - room_id={room_id}") |
|||
return {'message': 'Active session not found'} |
|||
|
|||
# Get user |
|||
try: |
|||
user = User.objects.get(id=int(user_identity)) |
|||
except (User.DoesNotExist, ValueError): |
|||
logger.warning(f"[PlugNMeet Webhook] participant_joined: User not found - user_identity={user_identity}") |
|||
return {'message': 'User not found'} |
|||
|
|||
# Determine user role |
|||
is_admin = user.can_manage_course(session.course) |
|||
role = 'moderator' if is_admin else 'participant' |
|||
|
|||
# Parse joined_at timestamp |
|||
if joined_at_timestamp: |
|||
entered_at = timezone.datetime.fromtimestamp(joined_at_timestamp, tz=timezone.utc) |
|||
else: |
|||
entered_at = timezone.now() |
|||
|
|||
# Check if user has an existing session entry that was marked as offline |
|||
# If so, reactivate it instead of creating a new one |
|||
existing_session = LiveSessionUser.objects.filter( |
|||
session=session, |
|||
user=user, |
|||
is_online=False |
|||
).order_by('-entered_at').first() |
|||
|
|||
if existing_session: |
|||
# Reactivate existing session |
|||
existing_session.is_online = True |
|||
existing_session.exited_at = None |
|||
existing_session.save(update_fields=['is_online', 'exited_at', 'updated_at']) |
|||
logger.info(f"[PlugNMeet Webhook] User rejoined (reactivated) - session_user_id={existing_session.id} user_id={user.id}") |
|||
return { |
|||
'message': 'Participant rejoined', |
|||
'session_user_id': existing_session.id, |
|||
'created': False |
|||
} |
|||
|
|||
# Create new LiveSessionUser entry |
|||
try: |
|||
session_user = LiveSessionUser.objects.create( |
|||
session=session, |
|||
user=user, |
|||
role=role, |
|||
entered_at=entered_at, |
|||
is_online=True, |
|||
) |
|||
logger.info(f"[PlugNMeet Webhook] User joined - session_user_id={session_user.id} user_id={user.id} role={role}") |
|||
except Exception as e: |
|||
logger.error(f"[PlugNMeet Webhook] Failed to create session user - error={str(e)}") |
|||
return {'message': f'Failed to create session user: {str(e)}'} |
|||
|
|||
return { |
|||
'message': 'Participant joined', |
|||
'session_user_id': session_user.id, |
|||
'created': created |
|||
} |
|||
|
|||
def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|||
""" |
|||
Handle participant_left event: Mark LiveSessionUser as offline/exited. |
|||
|
|||
Payload structure: |
|||
{ |
|||
"event": "participant_left", |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "course-slug-20240101120000" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-27", |
|||
"identity": "27", |
|||
"state": "DISCONNECTED", |
|||
"joinedAt": 1697497300, |
|||
"duration": 1800 |
|||
} |
|||
} |
|||
""" |
|||
room_data = payload.get('room', {}) |
|||
participant_data = payload.get('participant', {}) |
|||
|
|||
room_id = room_data.get('identity') |
|||
user_identity = participant_data.get('identity') |
|||
|
|||
if not room_id or not user_identity: |
|||
logger.warning(f"[PlugNMeet Webhook] participant_left: Missing room_id or user_identity") |
|||
return {'message': 'Missing required fields'} |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] participant_left - room_id={room_id} user_identity={user_identity}") |
|||
|
|||
# Get session |
|||
try: |
|||
session = CourseLiveSession.objects.get(room_id=room_id) |
|||
except CourseLiveSession.DoesNotExist: |
|||
logger.warning(f"[PlugNMeet Webhook] participant_left: Session not found - room_id={room_id}") |
|||
return {'message': 'Session not found'} |
|||
|
|||
# Get user |
|||
try: |
|||
user = User.objects.get(id=int(user_identity)) |
|||
except (User.DoesNotExist, ValueError): |
|||
logger.warning(f"[PlugNMeet Webhook] participant_left: User not found - user_identity={user_identity}") |
|||
return {'message': 'User not found'} |
|||
|
|||
# Update LiveSessionUser |
|||
now = timezone.now() |
|||
updated_count = LiveSessionUser.objects.filter( |
|||
session=session, |
|||
user=user, |
|||
is_online=True, |
|||
exited_at__isnull=True |
|||
).update( |
|||
is_online=False, |
|||
exited_at=now, |
|||
updated_at=now |
|||
) |
|||
|
|||
if updated_count > 0: |
|||
logger.info(f"[PlugNMeet Webhook] User left - session_id={session.id} user_id={user.id}") |
|||
else: |
|||
logger.warning(f"[PlugNMeet Webhook] participant_left: No active session found for user - session_id={session.id} user_id={user.id}") |
|||
|
|||
return { |
|||
'message': 'Participant left', |
|||
'updated': updated_count > 0 |
|||
} |
|||
|
|||
def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: |
|||
""" |
|||
Handle end_recording event: Download and save recording file. |
|||
|
|||
Payload structure: |
|||
{ |
|||
"event": "end_recording", |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "course-slug-20240101120000" |
|||
}, |
|||
"recording_info": { |
|||
"recordingId": "rec-123456", |
|||
"roomId": "course-slug-20240101120000", |
|||
"recordingType": "COMPOSITE", |
|||
"fileName": "algebra-1402-20231016.mp4", |
|||
"duration": 3600, |
|||
"status": "FINISHED" |
|||
} |
|||
} |
|||
""" |
|||
from django.core.files.base import ContentFile |
|||
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError |
|||
from apps.course.models import LiveSessionRecording |
|||
import os |
|||
import tempfile |
|||
import subprocess |
|||
|
|||
room_data = payload.get('room', {}) |
|||
recording_info = payload.get('recording_info', {}) |
|||
|
|||
room_id = room_data.get('identity') |
|||
recording_id = recording_info.get('recordingId') |
|||
file_name = recording_info.get('fileName', 'recording.mp4') |
|||
duration = recording_info.get('duration', 0) |
|||
|
|||
if not room_id or not recording_id: |
|||
logger.warning(f"[PlugNMeet Webhook] end_recording: Missing room_id or recording_id") |
|||
return {'message': 'Missing required fields'} |
|||
|
|||
logger.info(f"[PlugNMeet Webhook] end_recording - room_id={room_id} recording_id={recording_id}") |
|||
|
|||
# Get session |
|||
try: |
|||
session = CourseLiveSession.objects.get(room_id=room_id) |
|||
except CourseLiveSession.DoesNotExist: |
|||
logger.warning(f"[PlugNMeet Webhook] end_recording: Session not found - room_id={room_id}") |
|||
return {'message': 'Session not found'} |
|||
|
|||
try: |
|||
client = PlugNMeetClient() |
|||
|
|||
# Step 1: Get recording info |
|||
logger.info(f"[PlugNMeet Webhook] Fetching recording info - recording_id={recording_id}") |
|||
recording_data = client.get_recording_info(recording_id) |
|||
|
|||
if not recording_data.get('status'): |
|||
logger.error(f"[PlugNMeet Webhook] Failed to get recording info - recording_id={recording_id}") |
|||
return {'message': 'Failed to get recording info', 'error': recording_data.get('msg')} |
|||
|
|||
# Step 2: Get download token |
|||
logger.info(f"[PlugNMeet Webhook] Getting download token - recording_id={recording_id}") |
|||
token_response = client.get_recording_download_token(recording_id) |
|||
|
|||
if not token_response.get('status'): |
|||
logger.error(f"[PlugNMeet Webhook] Failed to get download token - recording_id={recording_id}") |
|||
return {'message': 'Failed to get download token', 'error': token_response.get('msg')} |
|||
|
|||
download_token = token_response.get('token') |
|||
if not download_token: |
|||
logger.error(f"[PlugNMeet Webhook] No download token in response - recording_id={recording_id}") |
|||
return {'message': 'No download token received'} |
|||
|
|||
# Step 3: Download file |
|||
download_path = f"/download/recording/{download_token}" |
|||
|
|||
# Create temporary file |
|||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file: |
|||
tmp_file_path = tmp_file.name |
|||
|
|||
try: |
|||
logger.info(f"[PlugNMeet Webhook] Downloading recording file - recording_id={recording_id}") |
|||
client.download_file(download_path, tmp_file_path) |
|||
|
|||
# Get file size |
|||
file_size = os.path.getsize(tmp_file_path) |
|||
logger.info(f"[PlugNMeet Webhook] File downloaded - size={file_size} bytes") |
|||
|
|||
# Determine recording type (video or audio) |
|||
is_video = file_name.lower().endswith(('.mp4', '.webm', '.mkv')) |
|||
recording_type = 'video' if is_video else 'voice' |
|||
|
|||
# Read file content |
|||
with open(tmp_file_path, 'rb') as f: |
|||
file_content = f.read() |
|||
|
|||
# Create LiveSessionRecording entry |
|||
recording = LiveSessionRecording.objects.create( |
|||
session=session, |
|||
title=f"{session.subject} - Recording", |
|||
file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, |
|||
recording_type=recording_type, |
|||
) |
|||
|
|||
# Save file to Django FileField |
|||
recording.file.save(file_name, ContentFile(file_content), save=True) |
|||
logger.info(f"[PlugNMeet Webhook] Recording saved - recording_id={recording.id} file={file_name}") |
|||
|
|||
# Generate thumbnail for video recordings (if ffmpeg is available) |
|||
thumbnail_generated = False |
|||
if is_video and file_size > 0: |
|||
try: |
|||
thumbnail_generated = self._generate_video_thumbnail(tmp_file_path, recording) |
|||
if thumbnail_generated: |
|||
logger.info(f"[PlugNMeet Webhook] Thumbnail generated - recording_id={recording.id}") |
|||
except Exception as e: |
|||
logger.warning(f"[PlugNMeet Webhook] Thumbnail generation skipped - error={str(e)}") |
|||
|
|||
return { |
|||
'message': 'Recording downloaded and saved successfully', |
|||
'recording_id': recording.id, |
|||
'file_name': file_name, |
|||
'file_size': file_size, |
|||
'thumbnail_generated': thumbnail_generated |
|||
} |
|||
|
|||
finally: |
|||
# Clean up temporary file |
|||
if os.path.exists(tmp_file_path): |
|||
os.unlink(tmp_file_path) |
|||
logger.debug(f"[PlugNMeet Webhook] Temporary file deleted - path={tmp_file_path}") |
|||
|
|||
except PlugNMeetError as e: |
|||
logger.error(f"[PlugNMeet Webhook] PlugNMeet API error - recording_id={recording_id} error={str(e)}") |
|||
return {'message': f'PlugNMeet API error: {str(e)}'} |
|||
except Exception as e: |
|||
logger.error(f"[PlugNMeet Webhook] Unexpected error - recording_id={recording_id} error={str(e)}", exc_info=True) |
|||
return {'message': f'Unexpected error: {str(e)}'} |
|||
|
|||
def _generate_video_thumbnail(self, video_path: str, recording: 'LiveSessionRecording') -> bool: |
|||
""" |
|||
Generate thumbnail from video file using ffmpeg. |
|||
|
|||
Args: |
|||
video_path: Path to the video file |
|||
recording: LiveSessionRecording instance |
|||
|
|||
Returns: |
|||
True if thumbnail generated successfully, False otherwise |
|||
""" |
|||
from django.core.files.base import ContentFile |
|||
import subprocess |
|||
import tempfile |
|||
import os |
|||
|
|||
try: |
|||
# Create temporary file for thumbnail |
|||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: |
|||
thumbnail_path = tmp_thumb.name |
|||
|
|||
# Extract frame at 1 second using ffmpeg |
|||
# -ss 1: seek to 1 second |
|||
# -i: input file |
|||
# -frames:v 1: extract 1 frame |
|||
# -q:v 2: quality (2 is high quality) |
|||
cmd = [ |
|||
'ffmpeg', |
|||
'-ss', '1', |
|||
'-i', video_path, |
|||
'-frames:v', '1', |
|||
'-q:v', '2', |
|||
'-vf', 'scale=640:-1', # scale to width 640, maintain aspect ratio |
|||
'-y', # overwrite output file |
|||
thumbnail_path |
|||
] |
|||
|
|||
result = subprocess.run( |
|||
cmd, |
|||
stdout=subprocess.PIPE, |
|||
stderr=subprocess.PIPE, |
|||
timeout=30 |
|||
) |
|||
|
|||
if result.returncode != 0: |
|||
logger.warning(f"[PlugNMeet Webhook] ffmpeg failed - return_code={result.returncode}") |
|||
return False |
|||
|
|||
# Check if thumbnail was created |
|||
if not os.path.exists(thumbnail_path) or os.path.getsize(thumbnail_path) == 0: |
|||
logger.warning(f"[PlugNMeet Webhook] Thumbnail file not created or empty") |
|||
return False |
|||
|
|||
# Save thumbnail to recording |
|||
with open(thumbnail_path, 'rb') as f: |
|||
thumbnail_content = f.read() |
|||
|
|||
thumbnail_filename = f"thumb_{recording.id}.jpg" |
|||
recording.thumbnail.save(thumbnail_filename, ContentFile(thumbnail_content), save=True) |
|||
|
|||
# Clean up temporary file |
|||
os.unlink(thumbnail_path) |
|||
|
|||
return True |
|||
|
|||
except subprocess.TimeoutExpired: |
|||
logger.error(f"[PlugNMeet Webhook] ffmpeg timeout during thumbnail generation") |
|||
return False |
|||
except FileNotFoundError: |
|||
logger.error(f"[PlugNMeet Webhook] ffmpeg not found - please install ffmpeg") |
|||
return False |
|||
except Exception as e: |
|||
logger.error(f"[PlugNMeet Webhook] Thumbnail generation error - error={str(e)}", exc_info=True) |
|||
return False |
|||
finally: |
|||
# Clean up temporary file if it still exists |
|||
if 'thumbnail_path' in locals() and os.path.exists(thumbnail_path): |
|||
try: |
|||
os.unlink(thumbnail_path) |
|||
except: |
|||
pass |
|||
@ -0,0 +1,86 @@ |
|||
#!/bin/bash |
|||
|
|||
set -e |
|||
|
|||
API_BASE="https://imamjavad.newhorizonco.uk/api" |
|||
LIVE_BASE="https://live.newhorizonco.uk" |
|||
|
|||
DEFAULT_COURSE_SLUG="test-1-ghorbani" |
|||
DEFAULT_AUTH_TOKEN="e5ec00c7660302c3225276eaf7be99459c9a7012" |
|||
|
|||
AUTH_TOKEN="$DEFAULT_AUTH_TOKEN" |
|||
COURSE_SLUG="$DEFAULT_COURSE_SLUG" |
|||
|
|||
print_usage() { |
|||
echo "Usage: $0 [-t <token>] [-s <course_slug>] [-h]" |
|||
echo "" |
|||
echo "Options:" |
|||
echo " -t <token> User authentication token (default: test-1-ghorbani user token)" |
|||
echo " -s <course_slug> Course slug (default: test-1-ghorbani)" |
|||
echo " -h Show this help message" |
|||
echo "" |
|||
echo "Example:" |
|||
echo " $0" |
|||
echo " $0 -s my-course" |
|||
echo " $0 -t custom-token -s custom-course" |
|||
} |
|||
|
|||
while getopts "t:s:h" opt; do |
|||
case $opt in |
|||
t) AUTH_TOKEN="$OPTARG" ;; |
|||
s) COURSE_SLUG="$OPTARG" ;; |
|||
h) print_usage; exit 0 ;; |
|||
*) print_usage; exit 1 ;; |
|||
esac |
|||
done |
|||
|
|||
echo "✓ Using authentication token" |
|||
echo "" |
|||
|
|||
echo "Step 1: Creating live session room..." |
|||
ROOM_RESPONSE=$(curl -s -X POST "$API_BASE/courses/$COURSE_SLUG/online/room/create/" \ |
|||
-H "Content-Type: application/json" \ |
|||
-H "Authorization: Token $AUTH_TOKEN" \ |
|||
-d '{}') |
|||
|
|||
CREATED_ROOM_ID=$(echo "$ROOM_RESPONSE" | grep -o '"room_id":"[^"]*' | cut -d'"' -f4 | head -1) |
|||
|
|||
if [ -z "$CREATED_ROOM_ID" ]; then |
|||
echo "Error: Failed to create room" |
|||
echo "Response: $ROOM_RESPONSE" |
|||
exit 1 |
|||
fi |
|||
|
|||
echo "✓ Room created: $CREATED_ROOM_ID" |
|||
echo "" |
|||
|
|||
echo "Step 2: Getting join token..." |
|||
TOKEN_RESPONSE=$(curl -s -X POST "$API_BASE/courses/online/room/token/" \ |
|||
-H "Content-Type: application/json" \ |
|||
-H "Authorization: Token $AUTH_TOKEN" \ |
|||
-d "{\"course_slug\": \"$COURSE_SLUG\"}") |
|||
|
|||
JOIN_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"token":"[^"]*' | cut -d'"' -f4) |
|||
|
|||
if [ -z "$JOIN_TOKEN" ]; then |
|||
echo "Error: Failed to get join token" |
|||
echo "Response: $TOKEN_RESPONSE" |
|||
exit 1 |
|||
fi |
|||
|
|||
echo "✓ Join token generated" |
|||
echo "" |
|||
|
|||
FULL_URL="$LIVE_BASE/?access_token=$JOIN_TOKEN" |
|||
|
|||
echo "==========================================" |
|||
echo "Room created successfully!" |
|||
echo "==========================================" |
|||
echo "" |
|||
echo "Full Room Link:" |
|||
echo "$FULL_URL" |
|||
echo "" |
|||
echo "Room Details:" |
|||
echo " Room ID: $CREATED_ROOM_ID" |
|||
echo " Course: $COURSE_SLUG" |
|||
echo "==========================================" |
|||
@ -0,0 +1,360 @@ |
|||
# PlugNMeet Webhook Integration |
|||
|
|||
## Overview |
|||
|
|||
This document describes the webhook integration between PlugNMeet and the Django backend to handle live session events. |
|||
|
|||
## Webhook Endpoint |
|||
|
|||
**URL:** `https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/` |
|||
|
|||
**Method:** `POST` |
|||
|
|||
**Content-Type:** `application/webhook+json` |
|||
|
|||
## Authentication |
|||
|
|||
The webhook endpoint is secured using SHA256 HMAC signature verification. |
|||
|
|||
### Headers |
|||
|
|||
- `Hash-Token`: SHA256 HMAC signature of the request body using `PLUGNMEET_API_SECRET` |
|||
- `Content-Type`: `application/webhook+json` |
|||
- `Authorization`: JWT token (optional, for additional verification) |
|||
|
|||
### Signature Verification |
|||
|
|||
```python |
|||
import hmac |
|||
import hashlib |
|||
|
|||
# Calculate signature |
|||
signature = hmac.new( |
|||
PLUGNMEET_API_SECRET.encode('utf-8'), |
|||
request_body, |
|||
hashlib.sha256 |
|||
).hexdigest() |
|||
|
|||
# Compare with Hash-Token header |
|||
is_valid = hmac.compare_digest(hash_token_header, signature) |
|||
``` |
|||
|
|||
## Supported Events |
|||
|
|||
### 1. room_finished |
|||
|
|||
Triggered when a live session room is closed. |
|||
|
|||
**Action:** Closes the `CourseLiveSession` and marks all active `LiveSessionUser` entries as offline. |
|||
|
|||
**Payload:** |
|||
```json |
|||
{ |
|||
"event": "room_finished", |
|||
"id": "550e8400-e29b-41d4-a716-446655440000", |
|||
"createdAt": 1697500800, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "algebra-1402", |
|||
"name": "کلاس جبر", |
|||
"maxParticipants": 100, |
|||
"creationTime": 1697497200, |
|||
"metadata": "{}", |
|||
"numParticipants": 0, |
|||
"duration": 3600 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Response:** |
|||
```json |
|||
{ |
|||
"status": "ok", |
|||
"message": "Room finished", |
|||
"session_id": 123, |
|||
"users_disconnected": 5 |
|||
} |
|||
``` |
|||
|
|||
### 2. participant_joined |
|||
|
|||
Triggered when a user joins the live session. |
|||
|
|||
**Action:** Creates a new `LiveSessionUser` entry or reactivates an existing offline entry. |
|||
|
|||
**Payload:** |
|||
```json |
|||
{ |
|||
"event": "participant_joined", |
|||
"id": "660e8400-e29b-41d4-a716-446655440001", |
|||
"createdAt": 1697497300, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "algebra-1402", |
|||
"name": "کلاس جبر" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-27", |
|||
"identity": "27", |
|||
"state": "ACTIVE", |
|||
"name": "دانشجو نمونه", |
|||
"metadata": "{\"is_admin\": false}", |
|||
"permission": { |
|||
"canPublish": true, |
|||
"canPublishData": true, |
|||
"canSubscribe": true |
|||
}, |
|||
"tracks": [], |
|||
"joinedAt": 1697497300 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Response:** |
|||
```json |
|||
{ |
|||
"status": "ok", |
|||
"message": "Participant joined", |
|||
"session_user_id": 456, |
|||
"created": true |
|||
} |
|||
``` |
|||
|
|||
### 3. participant_left |
|||
|
|||
Triggered when a user leaves the live session. |
|||
|
|||
**Action:** Marks the user's `LiveSessionUser` entry as offline and sets `exited_at` timestamp. |
|||
|
|||
**Payload:** |
|||
```json |
|||
{ |
|||
"event": "participant_left", |
|||
"id": "770e8400-e29b-41d4-a716-446655440002", |
|||
"createdAt": 1697499000, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "algebra-1402", |
|||
"name": "کلاس جبر" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-27", |
|||
"identity": "27", |
|||
"state": "DISCONNECTED", |
|||
"name": "دانشجو نمونه", |
|||
"metadata": "{\"is_admin\": false}", |
|||
"permission": { |
|||
"canPublish": true, |
|||
"canPublishData": true, |
|||
"canSubscribe": true |
|||
}, |
|||
"tracks": [], |
|||
"joinedAt": 1697497300, |
|||
"duration": 1800 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Response:** |
|||
```json |
|||
{ |
|||
"status": "ok", |
|||
"message": "Participant left", |
|||
"updated": true |
|||
} |
|||
``` |
|||
|
|||
### 4. end_recording |
|||
|
|||
Triggered when a recording finishes. |
|||
|
|||
**Action:** |
|||
1. Fetches recording info from PlugNMeet API |
|||
2. Gets download token |
|||
3. Downloads the recording file |
|||
4. Saves to `LiveSessionRecording` model |
|||
5. Generates thumbnail for video recordings (requires `ffmpeg`) |
|||
|
|||
**Payload:** |
|||
```json |
|||
{ |
|||
"event": "end_recording", |
|||
"id": "880e8400-e29b-41d4-a716-446655440003", |
|||
"createdAt": 1697500800, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "algebra-1402", |
|||
"name": "کلاس جبر" |
|||
}, |
|||
"recording_info": { |
|||
"recordingId": "rec-123456", |
|||
"roomId": "algebra-1402", |
|||
"recordingType": "COMPOSITE", |
|||
"fileName": "algebra-1402-20231016.mp4", |
|||
"duration": 3600, |
|||
"status": "FINISHED" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Response (Success):** |
|||
```json |
|||
{ |
|||
"status": "ok", |
|||
"message": "Recording downloaded and saved successfully", |
|||
"recording_id": 123, |
|||
"file_name": "algebra-1402-20231016.mp4", |
|||
"file_size": 524288000, |
|||
"thumbnail_generated": true |
|||
} |
|||
``` |
|||
|
|||
**Response (Error):** |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"message": "Failed to get recording info", |
|||
"error": "Recording not found" |
|||
} |
|||
``` |
|||
|
|||
**Requirements:** |
|||
- `ffmpeg` must be installed for thumbnail generation |
|||
- Sufficient disk space for recording files |
|||
- Write permissions on media directories |
|||
|
|||
## Configuration |
|||
|
|||
Add these settings to your Django settings file: |
|||
|
|||
```python |
|||
# PlugNMeet Integration |
|||
PLUGNMEET_SERVER_URL = "https://plugnmeet.example.com" |
|||
PLUGNMEET_API_KEY = "your-api-key" |
|||
PLUGNMEET_API_SECRET = "your-api-secret" |
|||
PLUGNMEET_TIMEOUT = 10.0 |
|||
``` |
|||
|
|||
## PlugNMeet Configuration |
|||
|
|||
Configure the webhook URL in your PlugNMeet server settings: |
|||
|
|||
```yaml |
|||
webhooks: |
|||
- url: "https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/" |
|||
events: |
|||
- room_finished |
|||
- participant_joined |
|||
- participant_left |
|||
- end_recording |
|||
``` |
|||
|
|||
## Error Handling |
|||
|
|||
The webhook endpoint returns appropriate HTTP status codes: |
|||
|
|||
- `200 OK`: Event processed successfully |
|||
- `400 Bad Request`: Invalid payload or missing required fields |
|||
- `403 Forbidden`: Invalid webhook signature |
|||
- `500 Internal Server Error`: Server error during processing |
|||
|
|||
All errors are logged with detailed information for debugging. |
|||
|
|||
## Testing |
|||
|
|||
To test the webhook locally, you can use curl: |
|||
|
|||
```bash |
|||
# Calculate signature |
|||
SECRET="your-api-secret" |
|||
PAYLOAD='{"event":"room_finished","room":{"identity":"test-room"}}' |
|||
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) |
|||
|
|||
# Send webhook request |
|||
curl -X POST https://habibmeet.nwhco.ir/api/course/plugnmeet/webhook/ \ |
|||
-H "Content-Type: application/webhook+json" \ |
|||
-H "Hash-Token: $SIGNATURE" \ |
|||
-d "$PAYLOAD" |
|||
``` |
|||
|
|||
## Migration from Polling |
|||
|
|||
Previously, the system used a polling approach where it would check if a room was still active using the `is_room_active` API call. This has been deprecated in favor of the webhook approach: |
|||
|
|||
**Old (Deprecated):** |
|||
```python |
|||
# This code has been commented out |
|||
def _sync_room_status_with_plugnmeet(self, course: Course): |
|||
client = PlugNMeetClient() |
|||
response = client.is_room_active(active_session.room_id) |
|||
if not response.get('isActive', False): |
|||
self._close_live_session(active_session) |
|||
``` |
|||
|
|||
**New (Webhook-based):** |
|||
```python |
|||
# Room status is automatically updated via webhooks |
|||
# No polling required |
|||
``` |
|||
|
|||
## Benefits |
|||
|
|||
1. **Real-time updates**: No polling delay, events are processed immediately |
|||
2. **Reduced server load**: No need for periodic API calls to check room status |
|||
3. **Accurate tracking**: Precise participant join/leave timestamps |
|||
4. **Scalability**: Webhook approach scales better than polling |
|||
5. **Lower latency**: Users see status updates immediately |
|||
6. **Automatic recording management**: Recordings are automatically downloaded and saved when ready |
|||
|
|||
## Recording Management |
|||
|
|||
The webhook automatically handles recording downloads when the `end_recording` event is received: |
|||
|
|||
### Process Flow |
|||
|
|||
1. **Webhook receives end_recording event** |
|||
2. **Fetch recording info** from PlugNMeet API (`/auth/recording/recordingInfo`) |
|||
3. **Get download token** (`/auth/recording/getDownloadToken`) |
|||
4. **Download file** to temporary location |
|||
5. **Determine recording type** (video/voice) based on file extension |
|||
6. **Create database record** (`LiveSessionRecording`) |
|||
7. **Generate thumbnail** (for video files using ffmpeg) |
|||
8. **Clean up** temporary files |
|||
|
|||
### File Storage |
|||
|
|||
- **Location**: Configured by `MEDIA_ROOT` in Django settings |
|||
- **Upload path**: `recorded_sessions/` (from model definition) |
|||
- **Thumbnails**: `recording_thumbnails/` (for video recordings) |
|||
|
|||
### Thumbnail Generation |
|||
|
|||
For video recordings, a thumbnail is automatically generated using `ffmpeg`: |
|||
- Extracts frame at 1 second |
|||
- Scaled to width 640px (maintains aspect ratio) |
|||
- High quality JPEG (quality level 2) |
|||
- Saved to `recording.thumbnail` field |
|||
|
|||
**Note**: `ffmpeg` must be installed on the server for thumbnail generation to work. |
|||
|
|||
### Error Handling |
|||
|
|||
The recording download process includes comprehensive error handling: |
|||
- Missing recording: Returns appropriate error message |
|||
- Download failures: Logs error and returns failure status |
|||
- Thumbnail generation: Non-critical, failures are logged but don't stop the process |
|||
- Cleanup: Temporary files are always cleaned up, even on errors |
|||
|
|||
## Logging |
|||
|
|||
All webhook events are logged with detailed information: |
|||
|
|||
``` |
|||
[PlugNMeet Webhook] Received webhook request |
|||
[PlugNMeet Webhook] Processing event=room_finished |
|||
[PlugNMeet Webhook] Session closed - session_id=123 room_id=algebra-1402 |
|||
[PlugNMeet Webhook] User sessions closed - session_id=123 count=5 |
|||
[PlugNMeet Webhook] Event processed successfully - event=room_finished |
|||
``` |
|||
|
|||
Check Django logs for webhook activity and debugging information. |
|||
@ -0,0 +1,233 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
Test script for PlugNMeet webhook endpoint. |
|||
|
|||
Usage: |
|||
python scripts/test_webhook.py [event_type] |
|||
|
|||
Event types: |
|||
- room_finished |
|||
- participant_joined |
|||
- participant_left |
|||
- end_recording |
|||
""" |
|||
|
|||
import sys |
|||
import os |
|||
import json |
|||
import hmac |
|||
import hashlib |
|||
import requests |
|||
from datetime import datetime |
|||
|
|||
# Django setup |
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') |
|||
|
|||
import django |
|||
django.setup() |
|||
|
|||
from django.conf import settings |
|||
|
|||
|
|||
def calculate_signature(payload: str, secret: str) -> str: |
|||
"""Calculate HMAC SHA256 signature.""" |
|||
return hmac.new( |
|||
secret.encode('utf-8'), |
|||
payload.encode('utf-8'), |
|||
hashlib.sha256 |
|||
).hexdigest() |
|||
|
|||
|
|||
def get_webhook_url() -> str: |
|||
"""Get webhook URL from settings or use default.""" |
|||
base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') |
|||
return f"{base_url}/api/course/plugnmeet/webhook/" |
|||
|
|||
|
|||
def get_test_payload(event_type: str) -> dict: |
|||
"""Generate test payload for given event type.""" |
|||
timestamp = int(datetime.now().timestamp()) |
|||
|
|||
payloads = { |
|||
'room_finished': { |
|||
"event": "room_finished", |
|||
"id": "550e8400-e29b-41d4-a716-446655440000", |
|||
"createdAt": timestamp, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "test-room-20240101120000", |
|||
"name": "Test Class", |
|||
"maxParticipants": 100, |
|||
"creationTime": timestamp - 3600, |
|||
"metadata": "{}", |
|||
"numParticipants": 0, |
|||
"duration": 3600 |
|||
} |
|||
}, |
|||
'participant_joined': { |
|||
"event": "participant_joined", |
|||
"id": "660e8400-e29b-41d4-a716-446655440001", |
|||
"createdAt": timestamp, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "test-room-20240101120000", |
|||
"name": "Test Class" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-1", |
|||
"identity": "1", |
|||
"state": "ACTIVE", |
|||
"name": "Test User", |
|||
"metadata": '{"is_admin": false}', |
|||
"permission": { |
|||
"canPublish": True, |
|||
"canPublishData": True, |
|||
"canSubscribe": True |
|||
}, |
|||
"tracks": [], |
|||
"joinedAt": timestamp |
|||
} |
|||
}, |
|||
'participant_left': { |
|||
"event": "participant_left", |
|||
"id": "770e8400-e29b-41d4-a716-446655440002", |
|||
"createdAt": timestamp, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "test-room-20240101120000", |
|||
"name": "Test Class" |
|||
}, |
|||
"participant": { |
|||
"sid": "participant-user-1", |
|||
"identity": "1", |
|||
"state": "DISCONNECTED", |
|||
"name": "Test User", |
|||
"metadata": '{"is_admin": false}', |
|||
"permission": { |
|||
"canPublish": True, |
|||
"canPublishData": True, |
|||
"canSubscribe": True |
|||
}, |
|||
"tracks": [], |
|||
"joinedAt": timestamp - 1800, |
|||
"duration": 1800 |
|||
} |
|||
}, |
|||
'end_recording': { |
|||
"event": "end_recording", |
|||
"id": "880e8400-e29b-41d4-a716-446655440003", |
|||
"createdAt": timestamp, |
|||
"room": { |
|||
"sid": "room-123456", |
|||
"identity": "test-room-20240101120000", |
|||
"name": "Test Class" |
|||
}, |
|||
"recording_info": { |
|||
"recordingId": "rec-123456", |
|||
"roomId": "test-room-20240101120000", |
|||
"recordingType": "COMPOSITE", |
|||
"fileName": "test-room-20240101120000.mp4", |
|||
"duration": 3600, |
|||
"status": "FINISHED" |
|||
} |
|||
} |
|||
} |
|||
|
|||
return payloads.get(event_type) |
|||
|
|||
|
|||
def send_webhook(event_type: str, dry_run: bool = False): |
|||
"""Send webhook request to the endpoint.""" |
|||
# Get configuration |
|||
webhook_url = get_webhook_url() |
|||
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', '') |
|||
|
|||
if not api_secret: |
|||
print("❌ Error: PLUGNMEET_API_SECRET not configured in settings") |
|||
return False |
|||
|
|||
# Get test payload |
|||
payload = get_test_payload(event_type) |
|||
if not payload: |
|||
print(f"❌ Error: Unknown event type '{event_type}'") |
|||
print("Available event types: room_finished, participant_joined, participant_left, end_recording") |
|||
return False |
|||
|
|||
# Convert payload to JSON string |
|||
payload_json = json.dumps(payload, ensure_ascii=False) |
|||
|
|||
# Calculate signature |
|||
signature = calculate_signature(payload_json, api_secret) |
|||
|
|||
# Print test information |
|||
print("\n" + "="*80) |
|||
print(f"🧪 Testing PlugNMeet Webhook: {event_type}") |
|||
print("="*80) |
|||
print(f"\n📍 URL: {webhook_url}") |
|||
print(f"\n📦 Payload:") |
|||
print(json.dumps(payload, indent=2, ensure_ascii=False)) |
|||
print(f"\n🔐 Signature: {signature[:20]}...") |
|||
|
|||
if dry_run: |
|||
print("\n⚠️ DRY RUN - Not sending actual request") |
|||
return True |
|||
|
|||
# Send request |
|||
try: |
|||
headers = { |
|||
'Content-Type': 'application/webhook+json', |
|||
'Hash-Token': signature, |
|||
} |
|||
|
|||
print("\n📤 Sending request...") |
|||
response = requests.post( |
|||
webhook_url, |
|||
data=payload_json.encode('utf-8'), |
|||
headers=headers, |
|||
timeout=10 |
|||
) |
|||
|
|||
print(f"\n✅ Response Status: {response.status_code}") |
|||
print(f"📄 Response Body:") |
|||
try: |
|||
print(json.dumps(response.json(), indent=2, ensure_ascii=False)) |
|||
except: |
|||
print(response.text) |
|||
|
|||
if response.status_code == 200: |
|||
print("\n✅ Webhook test successful!") |
|||
return True |
|||
else: |
|||
print(f"\n❌ Webhook test failed with status {response.status_code}") |
|||
return False |
|||
|
|||
except requests.exceptions.RequestException as e: |
|||
print(f"\n❌ Error sending request: {e}") |
|||
return False |
|||
finally: |
|||
print("\n" + "="*80 + "\n") |
|||
|
|||
|
|||
def main(): |
|||
"""Main function.""" |
|||
if len(sys.argv) < 2: |
|||
print("Usage: python scripts/test_webhook.py [event_type]") |
|||
print("\nAvailable event types:") |
|||
print(" - room_finished") |
|||
print(" - participant_joined") |
|||
print(" - participant_left") |
|||
print(" - end_recording") |
|||
print("\nOptions:") |
|||
print(" --dry-run Show payload without sending request") |
|||
sys.exit(1) |
|||
|
|||
event_type = sys.argv[1] |
|||
dry_run = '--dry-run' in sys.argv |
|||
|
|||
success = send_webhook(event_type, dry_run=dry_run) |
|||
sys.exit(0 if success else 1) |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
main() |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue