mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 05:38:02 +00:00
✨(visio) use compatible with summary v2
This commit introduces the compatibility with summary v2. It tecnically doesn't break the compatibility with v1 as v1 params are still sent. But we advise people using the transcribe feature in their own deployments to adapt to the new v2 API, as this compatibility will be removed in a future major version. * RecordingStatusChoices now has EXTERNAL_PROCESS_SUCCESSFUL & EXTERNAL_PROCESS_FAILED Values, which are changed by a new webhook that can be called by the transcribe service. This webhook is protected by its own bearer token. * Title for the document is computed in visio, * Tests are added / updated accordingly
This commit is contained in:
committed by
aleb_the_flash
parent
ecf8f0fe3f
commit
7fa172d100
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
||||
class MachineUser:
|
||||
"""Represent a non-interactive system user for automated storage operations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, username: str = "storage_event_user") -> None:
|
||||
self.pk = None
|
||||
self.username = "storage_event_user"
|
||||
self.username = username
|
||||
self.is_active = True
|
||||
|
||||
@property
|
||||
@@ -91,3 +91,46 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||
|
||||
|
||||
class RecordingProcessWebhookAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for recording process webhook requests.
|
||||
Validates the API key in the Authorization header.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a two-tuple of (user, token).
|
||||
"""
|
||||
required_token = settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN
|
||||
if not required_token:
|
||||
raise AuthenticationFailed("Webhook authentication is not configured.")
|
||||
|
||||
auth_header: str = request.headers.get("Authorization") or ""
|
||||
if not auth_header.startswith("Bearer "):
|
||||
logger.warning(
|
||||
"Authentication failed: Invalid authorization header format (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Invalid authorization header format.")
|
||||
token = auth_header[7:] # len("Bearer ") == 7
|
||||
|
||||
if not secrets.compare_digest(
|
||||
token,
|
||||
required_token,
|
||||
):
|
||||
logger.warning(
|
||||
"Authentication failed: Bad Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed()
|
||||
|
||||
return MachineUser("external_process_user"), None
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='External process webhook API'"
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -17,6 +18,7 @@ from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,8 +180,49 @@ class NotificationService:
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _generate_title(
|
||||
*,
|
||||
locale: str,
|
||||
room: str,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: str | None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if recording_datetime is None:
|
||||
with override(locale):
|
||||
return _("Transcription")
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
try:
|
||||
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||
except (KeyError, ZoneInfoNotFoundError):
|
||||
pass # Keep the original UTC datetime
|
||||
|
||||
with override(locale):
|
||||
translated_template = _(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
)
|
||||
return translated_template.format(
|
||||
room=room,
|
||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||
room_recording_time=dt.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -253,5 +296,119 @@ class NotificationService:
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||
form_link = (
|
||||
f"{form_base_url}?room_id={recording.room.id}"
|
||||
if (form_base_url and metadata_filename is not None)
|
||||
else None
|
||||
)
|
||||
metadata_payload = None
|
||||
if started_at and ended_at and metadata_filename:
|
||||
metadata_payload = {
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
metadata_filename,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": owner_access.user.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner_access.user.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=owner_access.user.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
# For now the feature flag logic is handled on summary side
|
||||
"auto_create_summary": True,
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
# We do not require a job_id to avoid a breaking change
|
||||
job_id = response_json.get("job_id")
|
||||
if not isinstance(job_id, str):
|
||||
raise ValueError("job_id is not a string")
|
||||
|
||||
recording.external_process_id = job_id
|
||||
recording.save()
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
Reference in New Issue
Block a user