mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-19 14:56:48 +00:00
✨(backend) add roomkit viewset to start a room without WebRTC join
Introduce a new viewset that lets the roomkit start a room even when no WebRTC participant has joined yet. This is a first entry point that will be extended over time with more actions a roomkit needs to be able to trigger. Known limitations: * The responsibility around SIP rules is currently split between the telephony feature and the roomkit one. This may need a refactor later on to consolidate ownership in a single place. * The default throttle might be too low for production usage and will likely need to be revisited.
This commit is contained in:
@@ -14,6 +14,7 @@ and this project adheres to
|
|||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
|
||||||
|
# Roomkit (meeting-room SIP devices)
|
||||||
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class FeatureFlag:
|
|||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
|
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||||
|
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
||||||
|
|
||||||
|
The roomkit endpoints are authenticated as a machine user, so all requests
|
||||||
|
share a single throttle bucket. This is not a security measure against
|
||||||
|
brute-force attacks but a guard against accidental hammering from a buggy
|
||||||
|
SIP module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scope = "roomkit_join"
|
||||||
|
|||||||
@@ -429,7 +429,14 @@ class Room(Resource):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""Generate a unique n-digit pin code for new rooms."""
|
||||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
|
||||||
|
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
||||||
|
# either integration is enabled.
|
||||||
|
if (
|
||||||
|
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||||
|
and not self.pk
|
||||||
|
and not self.pin_code
|
||||||
|
):
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Authentication for the roomkit API of the Meet core app."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
from core.recording.event.authentication import MachineUser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerToServerAuthentication(BaseAuthentication):
|
||||||
|
"""Custom authentication class for roomkit server-to-server requests.
|
||||||
|
|
||||||
|
Validates the Authorization header against the roomkit server-to-server
|
||||||
|
token. A valid PIN code is intentionally not enough to authenticate: the
|
||||||
|
endpoints are restricted to the LiveKit SIP module's credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AUTH_HEADER = "Authorization"
|
||||||
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""Validate the Bearer token from the Authorization header.
|
||||||
|
|
||||||
|
Returns a (MachineUser, token) pair on success, and raises
|
||||||
|
AuthenticationFailed if the header is missing, malformed, or contains
|
||||||
|
an invalid token.
|
||||||
|
"""
|
||||||
|
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
||||||
|
if not required_token:
|
||||||
|
raise AuthenticationFailed("Server-to-server token is not configured.")
|
||||||
|
|
||||||
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
if not auth_header:
|
||||||
|
logger.warning(
|
||||||
|
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed("Authorization header is missing.")
|
||||||
|
|
||||||
|
# Validate token format and existence
|
||||||
|
auth_parts = auth_header.split(" ")
|
||||||
|
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||||
|
raise AuthenticationFailed("Invalid authorization header.")
|
||||||
|
|
||||||
|
token = auth_parts[1]
|
||||||
|
|
||||||
|
# Use constant-time comparison to prevent timing attacks
|
||||||
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
|
logger.warning(
|
||||||
|
"Roomkit authentication failed: invalid token (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||||
|
|
||||||
|
return MachineUser(username="roomkit"), token
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""Return the WWW-Authenticate header value."""
|
||||||
|
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Serializers for the roomkit API of the Meet core app."""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.api.serializers import BaseValidationOnlySerializer
|
||||||
|
|
||||||
|
|
||||||
|
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate roomkit join requests from the LiveKit SIP module."""
|
||||||
|
|
||||||
|
pin_code = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
def validate_pin_code(self, value):
|
||||||
|
"""Ensure the PIN code matches the configured length."""
|
||||||
|
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
||||||
|
raise serializers.ValidationError("PIN code length is invalid.")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from rest_framework import decorators, viewsets
|
||||||
|
from rest_framework import (
|
||||||
|
exceptions as drf_exceptions,
|
||||||
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
response as drf_response,
|
||||||
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
status as drf_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core import analytics, models
|
||||||
|
from core.api import permissions, throttling
|
||||||
|
from core.api.feature_flag import FeatureFlag
|
||||||
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
|
from . import authentication, serializers
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomKitViewSet(viewsets.ViewSet):
|
||||||
|
"""Server-to-server API endpoints for the roomkit integration.
|
||||||
|
|
||||||
|
Groups all interactions between roomkit (SIP) devices and the backend,
|
||||||
|
brokered by the LiveKit SIP module. All endpoints are authenticated
|
||||||
|
with the roomkit server-to-server tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [authentication.ServerToServerAuthentication]
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="join",
|
||||||
|
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("roomkit")
|
||||||
|
def join(self, request):
|
||||||
|
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
||||||
|
|
||||||
|
Called by the LiveKit SIP module when a meeting-room device dials in
|
||||||
|
with a PIN code before any WebRTC participant has joined. Resolves the
|
||||||
|
room by PIN and creates its SIP dispatch rule, so the device can enter
|
||||||
|
without waiting for a WebRTC user.
|
||||||
|
|
||||||
|
The webhook-based creation path is kept: both converge on the same rule
|
||||||
|
through the shared TelephonyService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = models.Room.objects.get(
|
||||||
|
pin_code=serializer.validated_data["pin_code"]
|
||||||
|
)
|
||||||
|
except models.Room.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = TelephonyService().ensure_dispatch_rule(room)
|
||||||
|
except TelephonyException as e:
|
||||||
|
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"dispatch_rule_created": created,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
||||||
|
room.id,
|
||||||
|
created,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"status": "success"},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
@@ -245,7 +245,7 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.create_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
except TelephonyException as e:
|
except TelephonyException as e:
|
||||||
@@ -265,7 +265,7 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room finished event") from e
|
raise ActionFailedError("Failed to process room finished event") from e
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.delete_dispatch_rule(room_id)
|
self.telephony_service.delete_dispatch_rule(room_id)
|
||||||
except TelephonyException as e:
|
except TelephonyException as e:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError, TwirpErrorCode
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -21,6 +21,10 @@ class TelephonyException(Exception):
|
|||||||
"""Exception raised when telephony operations fail."""
|
"""Exception raised when telephony operations fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchRuleConflictError(TelephonyException):
|
||||||
|
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
||||||
|
|
||||||
|
|
||||||
class TelephonyService:
|
class TelephonyService:
|
||||||
"""Service for managing participant access through the telephony system (SIP)."""
|
"""Service for managing participant access through the telephony system (SIP)."""
|
||||||
|
|
||||||
@@ -51,6 +55,8 @@ class TelephonyService:
|
|||||||
try:
|
try:
|
||||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
|
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
||||||
|
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error creating dispatch rule for room %s", room.id
|
"Unexpected error creating dispatch rule for room %s", room.id
|
||||||
)
|
)
|
||||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
|||||||
if existing_rule.name == rule_name
|
if existing_rule.name == rule_name
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
async def has_dispatch_rule(self, room_id):
|
||||||
|
"""Check whether at least one dispatch rule exists for a specific room."""
|
||||||
|
return bool(await self._list_dispatch_rules_ids(room_id))
|
||||||
|
|
||||||
|
def ensure_dispatch_rule(self, room):
|
||||||
|
"""Create the SIP dispatch rule for a room if it does not already exist.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if a rule was created, False if it already existed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.has_dispatch_rule(room.pk):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.create_dispatch_rule(room)
|
||||||
|
except DispatchRuleConflictError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def delete_dispatch_rule(self, room_id):
|
async def delete_dispatch_rule(self, room_id):
|
||||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the roomkit API of the Meet core app."""
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Test the roomkit join server-to-server API endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ...factories import RoomFactory
|
||||||
|
from ...services.telephony import TelephonyException
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_telephony_service():
|
||||||
|
"""Mock the TelephonyService used by the roomkit viewset."""
|
||||||
|
with mock.patch("core.roomkit.viewsets.TelephonyService") as mock_service_class:
|
||||||
|
yield mock_service_class.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_anonymous(settings, mock_telephony_service, client):
|
||||||
|
"""Requests without an Authorization header should be rejected."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Authorization header is missing."}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_malformed_authorization_header(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with a malformed Authorization header should be rejected."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Invalid authorization header."}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_wrong_bearer(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with an incorrect bearer token should be rejected."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json() == {"detail": "Invalid server-to-server token."}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_token_not_configured(settings, mock_telephony_service, client):
|
||||||
|
"""Requests should be rejected when no server-to-server token is configured."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_roomkit_disabled(settings, mock_telephony_service, client):
|
||||||
|
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_missing_pin(settings, mock_telephony_service, client):
|
||||||
|
"""Requests without a PIN code should be rejected."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"pin_code": ["This field is required."]}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_blank_pin(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with a blank PIN code should be rejected."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": ""},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_wrong_pin_length(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with a PIN code of unexpected length should be rejected."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": "123"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_unknown_pin(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
RoomFactory(pin_code="1234567890")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": "0987654321"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "No room found for this PIN code."}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_success(settings, mock_telephony_service, client):
|
||||||
|
"""Requests with a valid PIN should create the dispatch rule."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.return_value = True
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "success"}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_dispatch_rule_already_exists(settings, mock_telephony_service, client):
|
||||||
|
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.return_value = False
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "success"}
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_tracks_analytics_event(settings, mock_telephony_service, client):
|
||||||
|
"""Successful joins should be tracked with an analytics event."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.return_value = True
|
||||||
|
|
||||||
|
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
_user, event, properties = mock_capture.call_args[0]
|
||||||
|
assert str(event) == "roomkit_joined"
|
||||||
|
assert properties == {
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"dispatch_rule_created": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_telephony_failure(settings, mock_telephony_service, client):
|
||||||
|
"""Requests should fail with a server error when the telephony service fails."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.side_effect = TelephonyException(
|
||||||
|
"Could not create dispatch rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/roomkit/join/",
|
||||||
|
{"pin_code": room.pin_code},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
raise_request_exception=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
mock_capture.assert_not_called()
|
||||||
@@ -485,6 +485,24 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
|||||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
|
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
|
||||||
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
|
):
|
||||||
|
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
mock_room_name = uuid.uuid4()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(mock_room_name)
|
||||||
|
|
||||||
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
||||||
|
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||||
@@ -492,6 +510,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
|||||||
):
|
):
|
||||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(mock_room_name)
|
mock_data.room.name = str(mock_room_name)
|
||||||
@@ -541,7 +560,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
"Failed to delete telephony dispatch rule for room "
|
"Failed to delete sip dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -577,12 +596,29 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
|
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
||||||
|
mock_create_dispatch_rule, service, settings
|
||||||
|
):
|
||||||
|
"""Should create dispatch rule when only roomkit is enabled during room start."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||||
mock_create_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(room.id)
|
mock_data.room.name = str(room.id)
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
|
|||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.telephony import (
|
||||||
|
DispatchRuleConflictError,
|
||||||
|
TelephonyException,
|
||||||
|
TelephonyService,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -303,3 +307,126 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
|||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
||||||
|
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
||||||
|
telephony_service = TelephonyService()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError(
|
||||||
|
msg=(
|
||||||
|
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||||
|
"PIN combination already exists in dispatch rule"
|
||||||
|
),
|
||||||
|
code="already_exists",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(DispatchRuleConflictError):
|
||||||
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
||||||
|
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
||||||
|
telephony_service = TelephonyService()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||||
|
)
|
||||||
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
created = telephony_service.ensure_dispatch_rule(room)
|
||||||
|
|
||||||
|
assert created is True
|
||||||
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
|
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||||
|
|
||||||
|
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
||||||
|
assert create_request.name == f"SIP_{str(room.id)}"
|
||||||
|
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
||||||
|
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
||||||
|
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
||||||
|
telephony_service = TelephonyService()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
existing_rule = SIPDispatchRuleInfo(
|
||||||
|
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
||||||
|
)
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
||||||
|
)
|
||||||
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
created = telephony_service.ensure_dispatch_rule(room)
|
||||||
|
|
||||||
|
assert created is False
|
||||||
|
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
||||||
|
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
||||||
|
|
||||||
|
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
||||||
|
between the existence check and the creation, LiveKit rejects the
|
||||||
|
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
||||||
|
"""
|
||||||
|
telephony_service = TelephonyService()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||||
|
)
|
||||||
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError(
|
||||||
|
msg=(
|
||||||
|
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||||
|
"PIN combination already exists in dispatch rule"
|
||||||
|
),
|
||||||
|
code="already_exists",
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
created = telephony_service.ensure_dispatch_rule(room)
|
||||||
|
|
||||||
|
assert created is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
|
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
||||||
|
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
||||||
|
telephony_service = TelephonyService()
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
|
mock_api = create_mock_livekit_client()
|
||||||
|
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||||
|
)
|
||||||
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||||
|
)
|
||||||
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||||
|
telephony_service.ensure_dispatch_rule(room)
|
||||||
|
|||||||
@@ -184,12 +184,13 @@ def test_models_rooms_is_public_property():
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||||
def test_telephony_disabled_skips_pin_generation(
|
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
):
|
):
|
||||||
"""Telephony disabled should not generate pin codes."""
|
"""Telephony and roomkit both disabled should not generate pin codes."""
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation(
|
|||||||
assert room.pin_code is None
|
assert room.pin_code is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_roomkit_enabled_generates_pin_code(settings):
|
||||||
|
"""Roomkit enabled alone should generate pin codes, even without telephony."""
|
||||||
|
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
assert room.pin_code is not None
|
||||||
|
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
|
|
||||||
|
|
||||||
def test_default_and_custom_pin_length(settings):
|
def test_default_and_custom_pin_length(settings):
|
||||||
"""Pin codes should be created with correct configured length."""
|
"""Pin codes should be created with correct configured length."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
from core.roomkit import viewsets as roomkit_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -19,6 +20,11 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
|||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
|
router.register(
|
||||||
|
"roomkit",
|
||||||
|
roomkit_viewsets.RoomKitViewSet,
|
||||||
|
basename="roomkit",
|
||||||
|
)
|
||||||
router.register(
|
router.register(
|
||||||
"addons/sessions",
|
"addons/sessions",
|
||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"roomkit_join": values.Value(
|
||||||
|
default="300/minute",
|
||||||
|
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -881,6 +886,21 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Roomkit (meeting-room SIP devices) integration
|
||||||
|
ROOMKIT_ENABLED = values.BooleanValue(
|
||||||
|
False,
|
||||||
|
environ_name="ROOMKIT_ENABLED",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Server-to-server API token allowing the LiveKit SIP module to call the
|
||||||
|
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
|
||||||
|
# dialing in before any WebRTC participant).
|
||||||
|
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
|
||||||
|
None,
|
||||||
|
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Subtitles settings
|
# Subtitles settings
|
||||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||||
|
|||||||
Reference in New Issue
Block a user