Browse Source

update webhooks for handling online class recordings

master
Mohsen Taba 1 month ago
parent
commit
3367762e6a
  1. 907
      apps/course/views/webhook.py

907
apps/course/views/webhook.py

@ -1,721 +1,92 @@
# 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 drf_yasg.utils import swagger_auto_schema
# from drf_yasg import openapi
# 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]
# @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"),
# 400: openapi.Response(description="Invalid webhook signature or data"),
# 500: openapi.Response(description="Internal server error")
# }
# )
# 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 _verify_webhook_signature(self, request) -> bool:
# """
# DEBUG VERSION: Prints details to find the mismatch.
# """
# hash_token = request.headers.get('Hash-Token')
# if not hash_token:
# logger.error("[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("[PlugNMeet Webhook] PLUGNMEET_API_SECRET is missing in settings.py")
# raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured")
# # DEBUG: Print the first/last few chars of the secret to ensure it's loaded correctly
# # DO NOT log the whole secret in production!
# logger.info(f"[DEBUG] Secret in Django: {api_secret[:4]}...{api_secret[-4:]}")
# # Calculate expected signature
# body = request.body
# # DEBUG: Check if body is empty (common middleware issue)
# if len(body) == 0:
# logger.error("[DEBUG] Request Body is EMPTY! Middleware might have consumed it.")
# expected_signature = hmac.new(
# api_secret.encode('utf-8'),
# body,
# hashlib.sha256
# ).hexdigest()
# # DEBUG: Compare them visually in logs
# logger.info(f"[DEBUG] Received Token: {hash_token}")
# logger.info(f"[DEBUG] Calculated: {expected_signature}")
# is_valid = hmac.compare_digest(hash_token, expected_signature)
# if not is_valid:
# logger.error("[PlugNMeet Webhook] Signature MISMATCH")
# 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
from rest_framework.parsers import BaseParser
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 import status
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__)
# 1. Custom Parser to preserve the raw body for HMAC verification
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):
# We return the raw bytes so we can verify the signature first
return stream.read()
@method_decorator(csrf_exempt, name='dispatch')
class PlugNMeetWebhookAPIView(APIView):
# 2. CRITICAL: Disable Global Authentication logic for this specific endpoint
"""
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]
# 3. Use the raw parser to prevent DRF from consuming the stream prematurely
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")
logger.info("⚡ [PlugNMeet Webhook] Request Hit the View!")
# --- 1. ROBUST HEADER EXTRACTION ---
# Try standard way
hash_token = request.headers.get('Hash-Token')
# 1. Extract Signature
hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_HASH_TOKEN')
# Try Django internal way (Fall back to META)
# Django converts "Hash-Token" -> "HTTP_HASH_TOKEN"
if not hash_token:
hash_token = request.META.get('HTTP_HASH_TOKEN')
# --- 2. DEBUG IF MISSING ---
if not hash_token:
logger.error("❌ [PlugNMeet Webhook] MISSING Hash-Token.")
# Print ALL headers to see if it's there under a different name
# This is critical for debugging Nginx stripping issues
logger.info("--- DUMPING ALL HEADERS ---")
for key, value in request.headers.items():
logger.info(f"Header: {key} = {value}")
# Also dump META keys starting with HTTP
meta_headers = {k: v for k, v in request.META.items() if k.startswith('HTTP_')}
logger.info(f"META Headers: {meta_headers}")
logger.info("-----------------------------")
logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header")
return Response({'message': 'Missing Hash-Token header'}, status=403)
# # --- SIGNATURE VERIFICATION ---
# hash_token = request.headers.get('Hash-Token')
# if not hash_token:
# logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token")
# return Response({'message': 'Missing Hash-Token'}, status=403)
api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None)
# We use request.data because our RawJSONParser returned the bytes into it
body_bytes = request.data
# Debug Logs (Remove in production)
logger.info(f"🔍 [DEBUG] Secret: {api_secret[:5]}...")
logger.info(f"🔍 [DEBUG] Body Bytes Len: {len(body_bytes)}")
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] Mismatch! \nExpected: {expected_signature}\nReceived: {hash_token}")
# 2. Verify Signature
if not self._verify_webhook_signature(request, hash_token):
return Response({'message': 'Invalid webhook signature'}, status=403)
# --- PARSING ---
# 3. Parse Payload
try:
# Now we manually parse the JSON since we used a RawParser
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] JSON Error: {e}")
logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}")
return Response({'message': 'Invalid JSON'}, status=400)
# --- EVENT ROUTING ---
event = payload.get('event')
logger.info(f"✅ [PlugNMeet Webhook] Processing Event: {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,
@ -725,18 +96,184 @@ class PlugNMeetWebhookAPIView(APIView):
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] Logic Error: {e}", exc_info=True)
logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {e}", exc_info=True)
return Response({'status': 'error', 'message': str(e)}, status=500)
# ... (Keep your existing _handle methods exactly as they were) ...
# Copy _handle_room_finished, _handle_participant_joined, etc. here
def _handle_room_finished(self, payload):
# ... your existing code ...
return {'status': 'ok'} # Placeholder
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
Loading…
Cancel
Save