Browse Source

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 support
master
mortezaei 7 months ago
parent
commit
bf09333bdb
  1. 5
      Dockerfile.prod
  2. 328
      README_WEBHOOK.md
  3. 42
      apps/course/services/plugnmeet.py
  4. 3
      apps/course/urls.py
  5. 3
      apps/course/views/__init__.py
  6. 65
      apps/course/views/course.py
  7. 564
      apps/course/views/webhook.py
  8. 86
      create_live_room.sh
  9. 360
      docs/plugnmeet_webhook.md
  10. 233
      scripts/test_webhook.py

5
Dockerfile.prod

@ -8,7 +8,7 @@ WORKDIR /usr/src/app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# install psycopg2 dependencies
# install psycopg2 dependencies and ffmpeg
RUN apk update && apk add --no-cache \
git \
wget \
@ -29,7 +29,8 @@ RUN apk update && apk add --no-cache \
freetype \
ttf-freefont \
mesa-gl \
alsa-lib
alsa-lib \
ffmpeg
# Set environment variables for Chrome

328
README_WEBHOOK.md

@ -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)

42
apps/course/services/plugnmeet.py

@ -39,6 +39,48 @@ class PlugNMeetClient:
def is_room_active(self, room_id: str) -> Dict[str, Any]:
return self._post("/auth/room/isRoomActive", {"roomId": room_id})
def get_recording_info(self, record_id: str) -> Dict[str, Any]:
"""Get detailed information about a recording."""
return self._post("/auth/recording/recordingInfo", {"recordId": record_id})
def get_recording_download_token(self, record_id: str) -> Dict[str, Any]:
"""Get a temporary download token for a recording."""
return self._post("/auth/recording/getDownloadToken", {"recordId": record_id})
def download_file(self, download_path: str, save_to: str) -> bool:
"""
Download a file from PlugNMeet server.
Args:
download_path: The download path (e.g., '/download/recording/token_xxx')
save_to: Local file path to save the downloaded file
Returns:
True if download successful, False otherwise
"""
import logging
logger = logging.getLogger(__name__)
url = urljoin(f"{self.base_url}/", download_path.lstrip("/"))
logger.info(f"[PlugNMeet] Downloading file from {url}")
try:
response = requests.get(url, stream=True, timeout=300) # 5 minute timeout for large files
response.raise_for_status()
# Write file in chunks
with open(save_to, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
logger.info(f"[PlugNMeet] File downloaded successfully to {save_to}")
return True
except requests.RequestException as exc:
logger.error(f"[PlugNMeet] Failed to download file - error={str(exc)}")
raise PlugNMeetError(f"Failed to download file: {str(exc)}") from exc
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))

3
apps/course/urls.py

@ -19,6 +19,9 @@ urlpatterns = [
path('<slug:slug>/online/room/create/', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'),
path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'),
# PlugNMeet webhook endpoint
path('plugnmeet/webhook/', views.PlugNMeetWebhookAPIView.as_view(), name='plugnmeet-webhook'),
path('<slug:slug>/', views.CourseDetailAPIView.as_view(), name='course-detail'),
path('<slug:slug>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),
path('<slug:slug>/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'),

3
apps/course/views/__init__.py

@ -2,4 +2,5 @@ from .course import *
from .lesson import *
from .participant import *
from .professor import *
from .live_session import *
from .live_session import *
from .webhook import *

65
apps/course/views/course.py

@ -400,9 +400,9 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
course = get_object_or_404(queryset, slug=slug)
user = request.user
# TODO: This room activity check should be replaced by webhook integration in the future
# Webhook will automatically notify when room becomes inactive, eliminating polling
self._sync_room_status_with_plugnmeet(course)
# DEPRECATED: Polling approach replaced by webhook integration
# Room status is now updated automatically via PlugNMeet webhooks
# self._sync_room_status_with_plugnmeet(course)
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
@ -566,35 +566,36 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
value = timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value).isoformat()
def _sync_room_status_with_plugnmeet(self, course: Course):
"""
Check if active live session's room is still active in PlugNMeet.
If room is inactive, close the session and all related user entries.
TODO: This should be replaced by webhook integration in the future.
PlugNMeet should send webhooks when rooms end, eliminating the need for polling.
"""
active_session = CourseLiveSession.objects.filter(
course=course,
ended_at__isnull=True
).first()
if not active_session or not active_session.room_id:
return
try:
client = PlugNMeetClient()
response = client.is_room_active(active_session.room_id)
is_active = response.get('isActive', False)
if not is_active:
logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}")
self._close_live_session(active_session)
else:
logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}")
except (PlugNMeetError, Exception) as e:
logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
# DEPRECATED: This polling approach is inefficient and has been replaced by webhook integration
# def _sync_room_status_with_plugnmeet(self, course: Course):
# """
# Check if active live session's room is still active in PlugNMeet.
# If room is inactive, close the session and all related user entries.
#
# DEPRECATED: This should be replaced by webhook integration.
# PlugNMeet now sends webhooks when rooms end, eliminating the need for polling.
# """
# active_session = CourseLiveSession.objects.filter(
# course=course,
# ended_at__isnull=True
# ).first()
#
# if not active_session or not active_session.room_id:
# return
#
# try:
# client = PlugNMeetClient()
# response = client.is_room_active(active_session.room_id)
# is_active = response.get('isActive', False)
#
# if not is_active:
# logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}")
# self._close_live_session(active_session)
# else:
# logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}")
#
# except (PlugNMeetError, Exception) as e:
# logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
@staticmethod
def _close_live_session(session: CourseLiveSession):

564
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

86
create_live_room.sh

@ -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 "=========================================="

360
docs/plugnmeet_webhook.md

@ -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.

233
scripts/test_webhook.py

@ -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()
Loading…
Cancel
Save