import json import hmac import hashlib import logging import os import tempfile import subprocess 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 django.core.files.base import ContentFile from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework.parsers import BaseParser from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant, LiveSessionRecording from apps.account.models import User from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from utils.exceptions import AppAPIException logger = logging.getLogger(__name__) class RawJSONParser(BaseParser): """ Parser that preserves the raw body bytes for HMAC signature verification. """ media_type = 'application/json' def parse(self, stream, media_type=None, parser_context=None): return stream.read() @method_decorator(csrf_exempt, name='dispatch') class PlugNMeetWebhookAPIView(APIView): """ Webhook endpoint to receive and process events from the PlugNMeet server. Handles: - room_finished: Closes the live session record. - participant_joined: Tracks student entry. - participant_left: Tracks student exit. - end_recording: Downloads and saves session recordings. """ authentication_classes = [] permission_classes = [AllowAny] parser_classes = [RawJSONParser] @swagger_auto_schema( operation_description="Handle webhook events from PlugNMeet server for live sessions", tags=["Imam-Javad - Course"], responses={ 200: openapi.Response(description="Webhook processed successfully"), 403: openapi.Response(description="Invalid signature"), 400: openapi.Response(description="Invalid payload") } ) def post(self, request, *args, **kwargs): logger.info("⚡ [PlugNMeet Webhook] Request received") # 1. Extract Signature hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_HASH_TOKEN') if not hash_token: logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header") return Response({'message': 'Missing Hash-Token header'}, status=403) # 2. Verify Signature if not self._verify_webhook_signature(request, hash_token): return Response({'message': 'Invalid webhook signature'}, status=403) # 3. Parse Payload try: body_bytes = request.data # RawJSONParser puts bytes here payload = json.loads(body_bytes.decode('utf-8')) except Exception as e: logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}") return Response({'message': 'Invalid JSON'}, status=400) event = payload.get('event') logger.info(f"✅ [PlugNMeet Webhook] Event: {event}") # 4. Route Event 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] Event {event} ignored") return Response({'status': 'ok', 'message': f'Event {event} ignored'}) try: result = handler(payload) return Response({'status': 'ok', **result}) except Exception as e: logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {e}", exc_info=True) return Response({'status': 'error', 'message': str(e)}, status=500) def _verify_webhook_signature(self, request, hash_token: str) -> bool: api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) if not api_secret: logger.error("❌ [PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") return False body_bytes = request.data expected_signature = hmac.new( api_secret.encode('utf-8'), body_bytes, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(hash_token, expected_signature): logger.error(f"❌ [PlugNMeet Webhook] Signature mismatch! \nReceived: {hash_token[:10]}...\nExpected: {expected_signature[:10]}...") return False return True def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: room_data = payload.get('room', {}) room_id = room_data.get('identity') if not room_id: return {'message': 'Missing room identity'} try: session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) now = timezone.now() session.ended_at = now session.save(update_fields=['ended_at', 'updated_at']) # Close 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] Session {session.id} ended. Users disconnected: {updated_count}") return {'session_id': session.id, 'closed_users': updated_count} except CourseLiveSession.DoesNotExist: return {'message': 'No active session found for this room'} def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: room_data = payload.get('room', {}) participant_data = payload.get('participant', {}) room_id = room_data.get('identity') user_id = participant_data.get('identity') if not room_id or not user_id: return {'message': 'Missing required metadata'} try: session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) user = User.objects.get(id=int(user_id)) role = 'moderator' if user.can_manage_course(session.course) else 'participant' session_user, created = LiveSessionUser.objects.update_or_create( session=session, user=user, defaults={ 'role': role, 'is_online': True, 'exited_at': None, 'entered_at': timezone.now() } ) logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id}") return {'session_user_id': session_user.id, 'created': created} except Exception as e: return {'error': str(e)} def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: room_data = payload.get('room', {}) participant_data = payload.get('participant', {}) room_id = room_data.get('identity') user_id = participant_data.get('identity') try: session = CourseLiveSession.objects.get(room_id=room_id) user = User.objects.get(id=int(user_id)) updated = LiveSessionUser.objects.filter( session=session, user=user, is_online=True ).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now()) logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id}") return {'updated': bool(updated)} except Exception as e: return {'error': str(e)} def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: 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: return {'message': 'Missing recording metadata'} try: session = CourseLiveSession.objects.get(room_id=room_id) client = PlugNMeetClient() # 1. Fetch download token token_response = client.get_recording_download_token(recording_id) if not token_response.get('status'): return {'error': 'Failed to get download token'} download_token = token_response.get('token') download_path = f"/download/recording/{download_token}" # 2. Download to 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 {recording_id}...") client.download_file(download_path, tmp_file_path) # 3. Save to Database with open(tmp_file_path, 'rb') as f: content = f.read() recording = LiveSessionRecording.objects.create( session=session, title=f"{session.subject} - Recording", file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, recording_type='video' if file_name.lower().endswith('.mp4') else 'voice' ) recording.file.save(file_name, ContentFile(content), save=True) # 4. Generate thumbnail (Optional) self._generate_video_thumbnail(tmp_file_path, recording) logger.info(f"💾 [PlugNMeet Webhook] Recording saved successfully: {recording.id}") return {'recording_id': recording.id, 'file': file_name} finally: if os.path.exists(tmp_file_path): os.unlink(tmp_file_path) except Exception as e: logger.error(f"❌ [PlugNMeet Webhook] End Recording Error: {e}", exc_info=True) return {'error': str(e)} def _generate_video_thumbnail(self, video_path: str, recording: LiveSessionRecording) -> bool: try: with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: thumbnail_path = tmp_thumb.name cmd = [ 'ffmpeg', '-ss', '1', '-i', video_path, '-frames:v', '1', '-q:v', '2', '-vf', 'scale=640:-1', '-y', thumbnail_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) if result.returncode == 0 and os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0: with open(thumbnail_path, 'rb') as f: recording.thumbnail.save(f"thumb_{recording.id}.jpg", ContentFile(f.read()), save=True) os.unlink(thumbnail_path) return True return False except Exception as e: logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}") return False