From bf09333bdb869dff6931d7d52b8d6ba1b38240d8 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 18 Oct 2025 00:20:37 +0330 Subject: [PATCH] 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 --- Dockerfile.prod | 5 +- README_WEBHOOK.md | 328 +++++++++++++++++ apps/course/services/plugnmeet.py | 42 +++ apps/course/urls.py | 3 + apps/course/views/__init__.py | 3 +- apps/course/views/course.py | 65 ++-- apps/course/views/webhook.py | 564 ++++++++++++++++++++++++++++++ create_live_room.sh | 86 +++++ docs/plugnmeet_webhook.md | 360 +++++++++++++++++++ scripts/test_webhook.py | 233 ++++++++++++ 10 files changed, 1654 insertions(+), 35 deletions(-) create mode 100644 README_WEBHOOK.md create mode 100644 apps/course/views/webhook.py create mode 100755 create_live_room.sh create mode 100644 docs/plugnmeet_webhook.md create mode 100755 scripts/test_webhook.py diff --git a/Dockerfile.prod b/Dockerfile.prod index f814ce5..5e9d65d 100644 --- a/Dockerfile.prod +++ b/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 diff --git a/README_WEBHOOK.md b/README_WEBHOOK.md new file mode 100644 index 0000000..9fc6833 --- /dev/null +++ b/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) diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py index 4bff408..b93734a 100644 --- a/apps/course/services/plugnmeet.py +++ b/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=(",", ":")) diff --git a/apps/course/urls.py b/apps/course/urls.py index e8eb33b..33ad058 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -19,6 +19,9 @@ urlpatterns = [ path('/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('/', views.CourseDetailAPIView.as_view(), name='course-detail'), path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), path('/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py index 014ada2..d5399d4 100644 --- a/apps/course/views/__init__.py +++ b/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 * \ No newline at end of file +from .live_session import * +from .webhook import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 3e7f322..34bb494 100644 --- a/apps/course/views/course.py +++ b/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): diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py new file mode 100644 index 0000000..ead1190 --- /dev/null +++ b/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 diff --git a/create_live_room.sh b/create_live_room.sh new file mode 100755 index 0000000..7568f71 --- /dev/null +++ b/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 ] [-s ] [-h]" + echo "" + echo "Options:" + echo " -t User authentication token (default: test-1-ghorbani user token)" + echo " -s 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 "==========================================" diff --git a/docs/plugnmeet_webhook.md b/docs/plugnmeet_webhook.md new file mode 100644 index 0000000..ee0d253 --- /dev/null +++ b/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. diff --git a/scripts/test_webhook.py b/scripts/test_webhook.py new file mode 100755 index 0000000..120f8c9 --- /dev/null +++ b/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()