mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-27 18:56:58 +00:00
7369379106
On rooms with the `trusted` access level, any authenticated user connected to the meeting can now manage the lobby. Requested by several organizations, and a step toward generalized lobby management once hubs and groups land (same organization only). Being authenticated is not enough to grant the capability: a `trusted` room means "trusted to join", not "trusted to decide who else joins from outside the call". The new `CanManageLobby` permission therefore also requires the requester to be currently connected to the meeting, verified against LiveKit and failing closed, like `IsPresentInMeeting`. The access level itself is never cached and always read fresh, so an owner switching the room back to `restricted` revokes the capability on the very next request - the one guarantee we did not want to trade for performance. Performance is traded elsewhere: the waiting list is polled by every lobby manager, and on a trusted room that audience grows from a few admins to potentially the whole meeting. Hitting LiveKit once per poll per participant would not survive that fan-out, so presence is memoized in Redis (`PresenceCache`, `PRESENCE_CACHE_TIMEOUT`, 1h). Entries are created lazily because only the minority of participants who actually manage a lobby ever need one, and only positive answers are cached because a sticky negative would lock out someone joining right after a miss for the whole TTL. Eager invalidation on `participant_left`, `room_finished` and admin kick keeps the cache honest; the TTL is the safety net when an event is lost, and its value bounds how long a departed participant could still act. Trade-offs in this v0: * `PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` gates the eager invalidation on `participant_left`: its cost is one Redis DELETE per departure, for every departure, so we want to be able to measure it in production and turn it off independently of the feature. When disabled, invalidation relies on `room_finished` and the TTL only, widening the stale window above. * This can put non-trivial pressure on the cache at scale; the rollout will need to be monitored closely. * The `participant_left` webhook must be enabled in the LiveKit deployment, otherwise eager invalidation silently degrades to the TTL-only behavior.
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""LiveKit Events Service"""
|
|
|
|
# pylint: disable=no-member
|
|
|
|
import re
|
|
import uuid
|
|
from enum import Enum
|
|
from logging import getLogger
|
|
|
|
from django.conf import settings
|
|
|
|
from livekit import api
|
|
|
|
from core import models
|
|
from core.recording.services.metadata_collector import (
|
|
MetadataCollectorException,
|
|
MetadataCollectorService,
|
|
)
|
|
from core.recording.services.recording_events import (
|
|
RecordingEventsError,
|
|
RecordingEventsService,
|
|
RecordingNotSavableError,
|
|
)
|
|
|
|
from .lobby import LobbyService
|
|
from .presence import PresenceCache
|
|
from .room_management import (
|
|
RoomManagement,
|
|
RoomManagementException,
|
|
RoomNotFoundException,
|
|
)
|
|
from .sip_management import SIPException, SIPManagement
|
|
|
|
logger = getLogger(__name__)
|
|
|
|
|
|
class LiveKitWebhookError(Exception):
|
|
"""Base exception for LiveKit webhook processing errors."""
|
|
|
|
status_code = 500
|
|
|
|
|
|
class AuthenticationError(LiveKitWebhookError):
|
|
"""Authentication failed."""
|
|
|
|
status_code = 401
|
|
|
|
|
|
class InvalidPayloadError(LiveKitWebhookError):
|
|
"""Invalid webhook payload."""
|
|
|
|
status_code = 400
|
|
|
|
|
|
class UnsupportedEventTypeError(LiveKitWebhookError):
|
|
"""Unsupported event type."""
|
|
|
|
status_code = 422
|
|
|
|
|
|
class ActionFailedError(LiveKitWebhookError):
|
|
"""Webhook action fails to process or complete."""
|
|
|
|
status_code = 500
|
|
|
|
|
|
class LiveKitWebhookEventType(Enum):
|
|
"""LiveKit webhook event types."""
|
|
|
|
# Room events
|
|
ROOM_STARTED = "room_started"
|
|
ROOM_FINISHED = "room_finished"
|
|
|
|
# Participant events
|
|
PARTICIPANT_JOINED = "participant_joined"
|
|
PARTICIPANT_LEFT = "participant_left"
|
|
|
|
# Track events
|
|
TRACK_PUBLISHED = "track_published"
|
|
TRACK_UNPUBLISHED = "track_unpublished"
|
|
|
|
# Egress events
|
|
EGRESS_STARTED = "egress_started"
|
|
EGRESS_UPDATED = "egress_updated"
|
|
EGRESS_ENDED = "egress_ended"
|
|
|
|
# Ingress events
|
|
INGRESS_STARTED = "ingress_started"
|
|
INGRESS_ENDED = "ingress_ended"
|
|
|
|
|
|
class LiveKitEventsService:
|
|
"""Service for processing and handling LiveKit webhook events and notifications."""
|
|
|
|
def __init__(self):
|
|
"""Initialize with required services."""
|
|
|
|
self._webhook_handlers = {
|
|
"egress_updated": self._handle_egress_updated,
|
|
"egress_ended": self._handle_egress_ended,
|
|
"room_started": self._handle_room_started,
|
|
"room_finished": self._handle_room_finished,
|
|
"participant_left": self._handle_participant_left,
|
|
}
|
|
|
|
token_verifier = api.TokenVerifier(
|
|
settings.LIVEKIT_CONFIGURATION["api_key"],
|
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
|
)
|
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
|
self.lobby_service = LobbyService()
|
|
self.presence_cache = PresenceCache()
|
|
self.sip_management = SIPManagement()
|
|
self.recording_events = RecordingEventsService()
|
|
|
|
self._filter_regex = None
|
|
if settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX:
|
|
try:
|
|
self._filter_regex = re.compile(
|
|
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX
|
|
)
|
|
except re.error:
|
|
logger.exception(
|
|
"Invalid LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX. Webhook filtering disabled."
|
|
)
|
|
|
|
def receive(self, request):
|
|
"""Process webhook and route to appropriate handler."""
|
|
|
|
auth_token = request.headers.get("Authorization")
|
|
if not auth_token:
|
|
raise AuthenticationError("Authorization header missing")
|
|
|
|
try:
|
|
data = self.webhook_receiver.receive(
|
|
request.body.decode("utf-8"), auth_token
|
|
)
|
|
except Exception as e:
|
|
raise InvalidPayloadError("Invalid webhook payload") from e
|
|
|
|
room_name = data.room.name or data.egress_info.room_name
|
|
|
|
if self._is_connection_test_room(room_name):
|
|
logger.info(
|
|
"Ignoring webhook event for connection test room '%s'.",
|
|
room_name,
|
|
)
|
|
return
|
|
|
|
if self._filter_regex and not self._filter_regex.search(room_name):
|
|
logger.info("Filtered webhook event for room '%s'", room_name)
|
|
return
|
|
|
|
try:
|
|
webhook_type = LiveKitWebhookEventType(data.event)
|
|
except ValueError as e:
|
|
raise UnsupportedEventTypeError(
|
|
f"Unknown webhook type: {data.event}"
|
|
) from e
|
|
|
|
# Handle according to received webhook type
|
|
handler = self._webhook_handlers.get(webhook_type.value)
|
|
|
|
if handler is not None:
|
|
handler(data)
|
|
|
|
def _handle_egress_updated(self, data):
|
|
"""Handle 'egress_updated' event."""
|
|
|
|
egress_id = data.egress_info.egress_id
|
|
try:
|
|
recording = models.Recording.objects.get(worker_id=egress_id)
|
|
except models.Recording.DoesNotExist as err:
|
|
raise ActionFailedError(
|
|
f"Recording with worker ID {egress_id} does not exist"
|
|
) from err
|
|
|
|
egress_status = data.egress_info.status
|
|
self.recording_events.handle_update(recording, egress_status)
|
|
|
|
def _handle_egress_ended(self, data):
|
|
"""Handle 'egress_ended' event."""
|
|
|
|
try:
|
|
recording = models.Recording.objects.select_related("room").get(
|
|
worker_id=data.egress_info.egress_id
|
|
)
|
|
except models.Recording.DoesNotExist as err:
|
|
raise ActionFailedError(
|
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
|
) from err
|
|
|
|
try:
|
|
room_name = str(recording.room.id)
|
|
RoomManagement().update_metadata(
|
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
|
)
|
|
except RoomNotFoundException:
|
|
logger.info(
|
|
"LiveKit room %s no longer exists, skipping metadata update",
|
|
room_name,
|
|
)
|
|
except RoomManagementException as e:
|
|
logger.exception("Failed to update room's metadata: %s", e)
|
|
|
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
|
try:
|
|
MetadataCollectorService().stop(recording)
|
|
except MetadataCollectorException:
|
|
logger.warning("Failed to stop the MetadataCollectorService")
|
|
|
|
if (
|
|
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
|
and recording.status == models.RecordingStatusChoices.ACTIVE
|
|
):
|
|
try:
|
|
self.recording_events.handle_limit_reached(recording)
|
|
except RecordingEventsError as e:
|
|
raise ActionFailedError(
|
|
f"Failed to process limit reached event for recording {recording}"
|
|
) from e
|
|
|
|
# Fallback for completion when no MinIO/S3 webhooks are configured
|
|
if (
|
|
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
|
) and data.egress_info.status in [
|
|
api.EgressStatus.EGRESS_COMPLETE,
|
|
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
|
]:
|
|
try:
|
|
self.recording_events.handle_complete(recording)
|
|
except RecordingNotSavableError:
|
|
logger.warning(
|
|
"Recording %s is not savable on egress complete "
|
|
"(already saved or in an error state); ignoring.",
|
|
recording.id,
|
|
)
|
|
|
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
|
|
|
@staticmethod
|
|
def _is_connection_test_room(room_name: str) -> bool:
|
|
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
|
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
|
|
|
def _handle_room_started(self, data):
|
|
"""Handle 'room_started' event."""
|
|
|
|
try:
|
|
room_id = uuid.UUID(data.room.name)
|
|
except ValueError as e:
|
|
logger.warning(
|
|
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
|
data.room.name,
|
|
)
|
|
raise ActionFailedError("Failed to process room started event") from e
|
|
|
|
try:
|
|
room = models.Room.objects.get(id=room_id)
|
|
except models.Room.DoesNotExist as err:
|
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
|
|
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
|
try:
|
|
self.sip_management.ensure_dispatch_rule(room)
|
|
except SIPException as e:
|
|
raise ActionFailedError(
|
|
f"Failed to create sip dispatch rule for room {room_id}"
|
|
) from e
|
|
|
|
def _handle_room_finished(self, data):
|
|
"""Handle 'room_finished' event."""
|
|
|
|
try:
|
|
room_id = uuid.UUID(data.room.name)
|
|
except ValueError as e:
|
|
logger.warning(
|
|
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
|
data.room.name,
|
|
)
|
|
raise ActionFailedError("Failed to process room finished event") from e
|
|
|
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
|
try:
|
|
self.sip_management.delete_dispatch_rule(room_id)
|
|
except SIPException as e:
|
|
raise ActionFailedError(
|
|
f"Failed to delete sip dispatch rule for room {room_id}"
|
|
) from e
|
|
|
|
self.presence_cache.clear_room(room_id)
|
|
|
|
try:
|
|
self.lobby_service.clear_room_cache(room_id)
|
|
except Exception as e:
|
|
raise ActionFailedError(
|
|
f"Failed to clear room cache for room {room_id}"
|
|
) from e
|
|
|
|
def _handle_participant_left(self, data):
|
|
"""Handle 'participant_left': invalidate the presence cache.
|
|
|
|
Presence entries are created lazily (only for users who administrate
|
|
the lobby of a trusted room), so for most participants this delete is
|
|
a no-op DEL on a key that never existed. Eager invalidation shrinks
|
|
the window during which a departed participant could still act on a
|
|
trusted room's lobby (cache hit until TTL expiry). It is gated behind
|
|
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
|
|
measured and the behaviour reverted independently of the feature.
|
|
When disabled, invalidation relies on `room_finished` and the TTL.
|
|
"""
|
|
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
|
|
return
|
|
|
|
identity = data.participant.identity
|
|
if not identity:
|
|
return
|
|
self.presence_cache.clear(data.room.name, identity)
|