Compare commits

..

4 Commits

Author SHA1 Message Date
lebaudantoine 5790bb1e47 🐛(backend) ensure SIP dispatch rule instead of creating it
The roomkit can now create a SIP dispatch rule before the LiveKit
webhook that used to trigger this creation is fired. In practice,
when the roomkit connects to the room, it also triggers the
webhook, leading to a duplicated dispatch rule.

Switch from "create dispatch rule" to "ensure dispatch rule exists"
semantics, so subsequent calls are idempotent and no duplicate rule
is created.
2026-07-30 21:19:40 +02:00
lebaudantoine ca80540f7e 🚚(backend) rename TelephonyService to SIPManagement
Rename the telephony service to a more descriptive name,
SIPManagementService, which clearly states what the service is used
for.

It is no longer used only by the telephony feature; the roomkit
feature also relies on it now.
2026-07-30 21:19:40 +02:00
lebaudantoine 80f3af0690 (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.
2026-07-30 21:18:49 +02:00
lebaudantoine 399617e247 wip prototype using orbstack K8s service in the dev stack 2026-07-30 20:08:34 +02:00
23 changed files with 1029 additions and 78 deletions
+2
View File
@@ -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
@@ -26,6 +27,7 @@ and this project adheres to
- 📝(legal) update terms of service - 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase - 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list - 💄(frontend) improve participant name rendering in the list
- 🚚(backend) rename TelephonyService to SIPManagement
## Fixed ## Fixed
+6
View File
@@ -389,6 +389,12 @@ build-k8s-cluster: \
./bin/start-kind.sh ./bin/start-kind.sh
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
build-k8s-cluster-orbstack: \
env.d/development/kube-secret
./bin/start-orbstack.sh
.PHONY: build-k8s-cluster-orbstack
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+6
View File
@@ -1,5 +1,11 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location') load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject') load('ext://namespace', 'namespace_create', 'namespace_inject')
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
# a no-op for kind and a safety net for older Tilt versions.
allow_k8s_contexts('orbstack')
namespace_create('meet') namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak') DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
# cluster (macOS) instead of kind.
#
# This replicates what bin/start-kind.sh (numerique-gouv/tools
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
# - no kind cluster: OrbStack ships a lightweight single-node cluster
# - no local registry (kind-registry): OrbStack's cluster shares the
# Docker image store, so images built by Tilt are directly visible
# to pods. Tilt detects the "orbstack" context as a local cluster
# and skips pushing images entirely.
#
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
set -o errexit
APPLICATION=${1:-meet}
CONTEXT="orbstack"
echo "0. Check OrbStack Kubernetes is available"
if ! command -v mkcert >/dev/null 2>&1; then
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
exit 1
fi
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
if command -v orb >/dev/null 2>&1; then
orb start k8s
else
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
exit 1
fi
fi
kubectl config use-context "${CONTEXT}"
echo "0b. Check ports 80/443 are free on localhost"
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
# cluster is still running, its docker proxy already holds 80/443.
# Skip the check if ingress-nginx is already installed here: in that case
# the listener on 80/443 is our own LoadBalancer.
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
for port in 80 443; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "❌ Port ${port} is already in use on the host."
echo " If the kind cluster is running, delete it first:"
echo " kind delete cluster --name suite"
exit 1
fi
done
fi
echo "1. Create ca"
CURRENT_DIR=$(pwd)
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd "${CURRENT_DIR}"
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
# guarded individually so the script is safe to re-run after a partial
# failure (unlike the upstream kind script, which guards the whole block
# on namespace existence).
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
# (there is no registry on OrbStack).
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
fi
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# The meet charts render Ingresses without ingressClassName. The kind
# provider manifest handles this via --watch-ingress-without-class=true;
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
]'
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
]'
fi
cat <<EOF | kubectl apply -n ingress-nginx -f -
apiVersion: v1
data:
allow-snippet-annotations: "true"
annotations-risk-level: Critical
custom-http-errors: 500,501,502,503,504
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
EOF
echo "2b. Wait for the ingress controller to be ready"
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
# Rewrite these names to the ingress-nginx service, like the kind setup does.
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
>/tmp/Corefile.orbstack
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kube-system rollout restart deployments/coredns
fi
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
echo "4. Setup namespace"
kubectl create ns "${APPLICATION}"
fi
kubectl config set-context --current --namespace="${APPLICATION}"
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
echo "5. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
fi
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
# Before Tilt deploys the app this returns the styled 404 from the default
# backend — that still proves LB -> controller works. 000 means the
# LoadBalancer is not bound to localhost.
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
if [ "${HTTP_CODE}" = "000" ]; then
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
else
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
fi
echo "6. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
+21
View File
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
``` ```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/). Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
### Alternative: OrbStack's built-in Kubernetes (macOS)
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
```shellscript
$ make build-k8s-cluster-orbstack
```
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
```shellscript
$ make start-tilt-keycloak
```
Notes:
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
+3
View File
@@ -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"
+1
View File
@@ -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
+12
View File
@@ -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"
+8 -1
View File
@@ -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
) )
+1
View File
@@ -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'"
+21
View File
@@ -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
+89
View File
@@ -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.sip_management import SIPException, SIPManagement
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 SIPManagement.
"""
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 = SIPManagement().ensure_dispatch_rule(room)
except SIPException 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,
)
+10 -10
View File
@@ -28,7 +28,7 @@ from .room_management import (
RoomManagementException, RoomManagementException,
RoomNotFoundException, RoomNotFoundException,
) )
from .telephony import TelephonyException, TelephonyService from .sip_management import SIPException, SIPManagement
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -107,7 +107,7 @@ class LiveKitEventsService:
) )
self.webhook_receiver = api.WebhookReceiver(token_verifier) self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService() self.lobby_service = LobbyService()
self.telephony_service = TelephonyService() self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService() self.recording_events = RecordingEventsService()
self._filter_regex = None self._filter_regex = None
@@ -245,12 +245,12 @@ 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.sip_management.ensure_dispatch_rule(room)
except TelephonyException as e: except SIPException as e:
raise ActionFailedError( raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}" f"Failed to create sip dispatch rule for room {room_id}"
) from e ) from e
def _handle_room_finished(self, data): def _handle_room_finished(self, data):
@@ -265,12 +265,12 @@ 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.sip_management.delete_dispatch_rule(room_id)
except TelephonyException as e: except SIPException as e:
raise ActionFailedError( raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}" f"Failed to delete sip dispatch rule for room {room_id}"
) from e ) from e
try: try:
@@ -1,9 +1,9 @@
"""Telephony service for managing SIP dispatch rules for room access.""" """SIP management service for managing SIP dispatch rules for room access."""
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,
@@ -17,12 +17,16 @@ from core import utils
logger = getLogger(__name__) logger = getLogger(__name__)
class TelephonyException(Exception): class SIPException(Exception):
"""Exception raised when telephony operations fail.""" """Exception raised when SIP operations fail."""
class TelephonyService: class DispatchRuleConflictError(SIPException):
"""Service for managing participant access through the telephony system (SIP).""" """Raised when a dispatch rule already exists for the same routing criteria."""
class SIPManagement:
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
def _rule_name(self, room_id): def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID.""" """Generate the rule name for a room based on its ID."""
@@ -32,7 +36,7 @@ class TelephonyService:
async def create_dispatch_rule(self, room): async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing. """Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room Configures livekit-sip to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication. using the room's ID and PIN code for authentication.
""" """
@@ -51,10 +55,12 @@ 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
) )
raise TelephonyException("Could not create dispatch rule") from e raise SIPException("Could not create dispatch rule") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@@ -79,7 +85,7 @@ class TelephonyService:
) )
except TwirpError as e: except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id) logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e raise SIPException("Could not list dispatch rules") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@@ -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."""
@@ -118,7 +146,7 @@ class TelephonyService:
except TwirpError as e: except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id) logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e raise SIPException("Could not delete dispatch rules") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@@ -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.sip_management import SIPException
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_sip_management():
"""Mock the SIPManagement used by the roomkit viewset."""
with mock.patch("core.roomkit.viewsets.SIPManagement") as mock_service_class:
yield mock_service_class.return_value
def test_join_anonymous(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_malformed_authorization_header(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_wrong_bearer(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_token_not_configured(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_roomkit_disabled(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_missing_pin(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_blank_pin(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_wrong_pin_length(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_unknown_pin(settings, mock_sip_management, 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_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_success(settings, mock_sip_management, 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_sip_management.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_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
def test_join_dispatch_rule_already_exists(settings, mock_sip_management, 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_sip_management.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_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
def test_join_tracks_analytics_event(settings, mock_sip_management, 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_sip_management.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_sip_failure(settings, mock_sip_management, client):
"""Requests should fail with a server error when the sip management service fails."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_management.ensure_dispatch_rule.side_effect = SIPException(
"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_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
mock_capture.assert_not_called()
@@ -21,7 +21,10 @@ from core.services.livekit_events import (
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException from core.services.room_management import RoomManagementException
from core.services.telephony import TelephonyException, TelephonyService from core.services.sip_management import (
SIPException,
SIPManagement,
)
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -59,7 +62,7 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret) mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value) mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService) assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService) assert isinstance(service.sip_management, SIPManagement)
assert isinstance(service.recording_events, RecordingEventsService) assert isinstance(service.recording_events, RecordingEventsService)
@@ -469,11 +472,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule") @mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule( def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings mock_delete_dispatch_rule, mock_clear_cache, service, settings
): ):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes.""" """Should clear lobby cache and delete SIP dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4() mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -486,12 +489,31 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule") @mock.patch.object(SIPManagement, "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(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled( def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings mock_delete_dispatch_rule, mock_clear_cache, service, settings
): ):
"""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)
@@ -505,7 +527,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
@mock.patch.object( @mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error") LobbyService, "clear_room_cache", side_effect=Exception("Test error")
) )
@mock.patch.object(TelephonyService, "delete_dispatch_rule") @mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails( def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings mock_delete_dispatch_rule, mock_clear_cache, service, settings
): ):
@@ -528,9 +550,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object( @mock.patch.object(
TelephonyService, SIPManagement,
"delete_dispatch_rule", "delete_dispatch_rule",
side_effect=TelephonyException("Test error"), side_effect=SIPException("Test error"),
) )
def test_handle_room_finished_raises_error_when_telephony_deletion_fails( def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings mock_delete_dispatch_rule, mock_clear_cache, service, settings
@@ -541,7 +563,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"
) )
@@ -562,11 +584,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
service._handle_room_finished(mock_data) service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule") @mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully( def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings mock_ensure_dispatch_rule, service, settings
): ):
"""Should create telephony dispatch rule when room starts successfully.""" """Should ensure the SIP dispatch rule exists when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory() room = RoomFactory()
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -574,22 +596,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
service._handle_room_started(mock_data) service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room) mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule") @mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled( def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
mock_create_dispatch_rule, service, settings mock_ensure_dispatch_rule, service, settings
): ):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start.""" """Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
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)
service._handle_room_started(mock_data) service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called() mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
def test_handle_room_started_ignores_existing_dispatch_rule(
mock_ensure_dispatch_rule, service, settings
):
"""Should proceed silently when the dispatch rule already exists when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Test error"),
)
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
mock_ensure_dispatch_rule, service, settings
):
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule, service, settings
):
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service): def test_handle_room_started_raises_error_for_invalid_room_name(service):
@@ -1,5 +1,5 @@
""" """
Test telephony service. Test SIP mamagement service.
""" """
# pylint: disable=W0212 # pylint: disable=W0212
@@ -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.sip_management import (
DispatchRuleConflictError,
SIPException,
SIPManagement,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
def test_rule_name(): def test_rule_name():
"""Test rule name generation.""" """Test rule name generation."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id) rule_name = sip_management._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}" assert rule_name == f"SIP_{str(room.id)}"
@@ -45,14 +49,14 @@ def test_rule_name():
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory): def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation.""" """Test successful dispatch rule creation."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock() mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room) sip_management.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once() mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"] create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
@@ -67,7 +71,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory): def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails.""" """Test dispatch rule creation when API fails."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
@@ -76,8 +80,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"): with pytest.raises(SIPException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room) sip_management.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once() mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@@ -86,7 +90,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory): def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs.""" """Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [ mock_rules = [
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert len(result) == 2 assert len(result) == 2
assert "rule-1" in result assert "rule-1" in result
@@ -127,7 +131,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory): def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist.""" """Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
@@ -136,7 +140,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert result == [] assert result == []
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@@ -145,7 +149,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory): def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room.""" """Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [ mock_rules = [
@@ -163,7 +167,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
assert result == [] assert result == []
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@@ -172,7 +176,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory): def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails.""" """Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
@@ -181,34 +185,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"): with pytest.raises(SIPException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id) async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once() mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") @mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules): def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist.""" """Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = [] mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id) result = sip_management.delete_dispatch_rule(room.id)
assert result is False assert result is False
mock_list_rules.assert_called_once_with(room.id) mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called() mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") @mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules): def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule.""" """Test deleting a single dispatch rule."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"] mock_list_rules.return_value = ["rule-1"]
@@ -216,7 +220,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock() mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id) result = sip_management.delete_dispatch_rule(room.id)
assert result is True assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once() mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
@@ -226,11 +230,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") @mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules): def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules.""" """Test deleting multiple dispatch rules."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"] mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -238,7 +242,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock() mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id) result = sip_management.delete_dispatch_rule(room.id)
assert result is True assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3 assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
@@ -253,11 +257,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") @mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules): def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails.""" """Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"] mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -277,18 +281,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"): with pytest.raises(SIPException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id) sip_management.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2 assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once() mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids") @mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client") @mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules): def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately.""" """Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService() sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"] mock_list_rules.return_value = ["rule-1"]
@@ -298,8 +302,131 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"): with pytest.raises(SIPException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id) sip_management.delete_dispatch_rule(room.id)
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."""
sip_management = SIPManagement()
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):
sip_management.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."""
sip_management = SIPManagement()
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 = sip_management.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."""
sip_management = SIPManagement()
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 = sip_management.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.
"""
sip_management = SIPManagement()
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 = sip_management.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."""
sip_management = SIPManagement()
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(SIPException, match="Could not create dispatch rule"):
sip_management.ensure_dispatch_rule(room)
+15 -2
View File
@@ -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."""
+6
View File
@@ -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,
+20
View File
@@ -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
+1 -2
View File
@@ -59,10 +59,9 @@ export const Avatar = React.memo(
<text <text
x="50" x="50"
y="50" y="50"
dy="-0.08em"
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize="52" fontSize={initials.length > 1 ? 48 : 52}
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
> >