You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
279 lines
12 KiB
279 lines
12 KiB
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
|