Compare commits

..

32 Commits

Author SHA1 Message Date
lebaudantoine 7fbcbc89ed 🧑‍💻(devx) prototype OrbStack as local Kubernetes provider on macOS
Add a prototype setup that uses OrbStack as the local Kubernetes
provider on macOS, aiming to save a few GB of RAM compared to the
current stack.

The script has been tested locally but was mostly built through vibe
coding, so it has not been thoroughly reviewed yet.

Follow-ups to look into:

* Simplify the ingress and CoreDNS setup if possible.
* Wire up a way to run tests and lint against the Tilt stack.
2026-07-31 14:18:37 +02:00
lebaudantoine 89f8480e0b 🚸(frontend) show two initials in the Avatar when possible
Display two initials in the Avatar whenever the participant's name
allows it, instead of a single letter.

A single initial makes it too hard to distinguish participants when
their cameras are off, especially in larger
2026-07-29 00:00:35 +02:00
lebaudantoine d9bf6efa2a 💄(frontend) improve participant name rendering in the list
Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
2026-07-29 00:00:35 +02:00
lebaudantoine 4cb7412194 (frontend) introduce an "unauthenticated" participant badge
Add a visual badge on participants who are not authenticated, so it
is immediately clear who could be an anonymous participant. This is
a small but explicit security signal in the participant list.

Beyond that, the badge also plays a functional role: since only
authenticated participants can be promoted or demoted, the badge
helps users see at a glance who is eligible for a role change.
2026-07-29 00:00:35 +02:00
lebaudantoine e8df597055 (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-07-29 00:00:35 +02:00
lebaudantoine 05dfcd11ca 🐛(frontend) fall back to user.full_name on request-entry
Since the username refactoring, the username in the store could be
undefined when the join input was pre-filled from user.full_name,
because no keystroke was needed to populate the store.

This led to a 400 error on the request-entry endpoint whenever the
user joined without editing the pre-filled name.

Fall back to user.full_name when the store username is missing, so
the endpoint always receives a value.

Acknowledged as a somewhat wobbly fix, but ships as-is until the
underlying flow is reworked.
2026-07-29 00:00:35 +02:00
lebaudantoine b018832fcf (frontend) close admin side panel when the user is demoted
Listen to role changes in the admin panel and close the side panel
if the current user is demoted while it is open. Without this,
unprivileged users could still see the admin side panel until they
closed it manually.

I checked the other features that could be affected by hot role
changes; this was the only one still exposing admin-only UI after a
demotion. Everything else already handles live permission updates
correctly.
2026-07-29 00:00:35 +02:00
lebaudantoine a269160f6f (frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the
authenticated participants of the meeting to a role with additional
privileges.

Known limitations:

* Only authenticated participants can be promoted, but there is no
  visual indicator yet distinguishing authenticated from anonymous
  participants. This will be added in a follow-up commit.
* The resource_access data fetched in the initial API call becomes
  stale after a promotion. It is not currently used in the product,
  so this is not visible, but it should either be refreshed later
  or removed from the initial fetch.
* Demoting a promoted user turns them into a member, which is still
  a privileged role. This is a deliberate choice until we introduce
  finer-grained tuning of participant roles.
2026-07-29 00:00:35 +02:00
lebaudantoine c3afa84d9b ♻️(frontend) extract closeSidePanel action at the store level
Extract the logic that closes the side panel into a utility function
declared at the store module level, as recommended by Valtio.

This avoids re-creating the function on every render and prevents
extra re-renders in components that use it.
2026-07-29 00:00:35 +02:00
lebaudantoine 8c6752a29f 🐛(backend) allow any string as sub in the API serializer
The API serializer was too restrictive on the `sub` field, expecting
a UUID. This worked in our development and production setups because
our Keycloak is configured to emit UUID subs, but it broke for other
providers.

Per the OIDC spec and the DB model, `sub` can be any string. Align
the serializer with this and accept arbitrary string values.

Fixes #1525.
2026-07-29 00:00:35 +02:00
lebaudantoine 4c57432a03 💄(frontend) render Avatar initials in uppercase
Uppercase the initials rendered in the Avatar so their vertical
centering stays consistent.

With lowercase letters, the initials were slightly shifted toward
the bottom of the Avatar, which broke the alignment.
2026-07-29 00:00:35 +02:00
lebaudantoine 3b7bfd999c 🔥(frontend) remove leftover console.count call
Drop a stray console.count call that was accidentally committed in a
previous PR and had been left in the codebase.
2026-07-29 00:00:35 +02:00
lebaudantoine 7f386b2e2f ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

Refactor the frontend so it derives this flag from the participant
role carried in the participant metadata instead.

Two benefits:

* The flag now updates live along with the participant
  attributes/metadata, so role changes are reflected immediately.
* It removes the duplication between the API response and the
  metadata, which both used to determine the user's capabilities.
2026-07-29 00:00:35 +02:00
lebaudantoine c55d8235fd (frontend) add client for the update participant role endpoint
Add the frontend client that calls the update participant role
endpoint. Straightforward API call, no special handling.
2026-07-29 00:00:35 +02:00
lebaudantoine c7b23abd68 (backend) expose is_authenticated in the LiveKit token
Include the is_authenticated flag on the user in the LiveKit token
and participant metadata.

The frontend needs this information (used in the next commit) to
know whether it can offer to promote a user with access to the room
admin.
2026-07-29 00:00:35 +02:00
lebaudantoine 5a641a4366 ♻️(backend) pass the participant role in the LiveKit token
The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
2026-07-29 00:00:35 +02:00
lebaudantoine f043ad6f98 (backend) add endpoint to update a participant role during a meeting
Add an endpoint that allows updating a user's role while in a
meeting. The goal is to let users promote other connected
participants to admin or moderator, so the burden of administrating
a meeting can be shared.
2026-07-29 00:00:35 +02:00
lebaudantoine 1141c1cecd ️(backend) add permission class checking the user is in the call
Introduce a new permission class that verifies the caller making a
request is both authenticated and actually present in the call.

It will be used to gate actions that require the user to be live in
the room, for example:

* allowing someone in from the waiting room
* promoting another participant to a different role

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-07-29 00:00:35 +02:00
Arnaud Robin 33792b050a 📝(legal) update terms of service
Update terms of service content after legal review
2026-07-28 13:52:50 +02:00
leo f4569c64e5 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-07-28 10:54:03 +02:00
snyk-bot 6a00d3d087 ⬆️(frontend) upgrade i18next from 26.3.2 to 26.3.4
Snyk has created this PR to upgrade i18next from 26.3.2 to 26.3.4.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-27 18:21:10 +02:00
Camille Moulin 2e975e2643 📝(metadata): Add publiccode.yml file
Recommended for Public Administration Open Source software projects.
See https://yml.publiccode.tools/

Signed-off-by: Camille Moulin <camille.moulin@numerique.gouv.fr>
2026-07-27 18:20:34 +02:00
lebaudantoine 4c63aa827f 📝(frontend) add changelog entry for PR #1510
Document in the CHANGELOG the set of changes shipped in PR #1510,
which groups the recent chat, layout and participant tile render
optimizations.
2026-07-24 18:31:47 +02:00
lebaudantoine 67e9bf2fef 🐛(frontend) reset chat state when the ChatProvider mounts
Reset the chat state on the first render of the ChatProvider, to
make sure no chat messages from a previous room leak into the new
one.

This covers SPA navigations where the user switches from one room
to another without a full page reload.
2026-07-24 18:31:47 +02:00
lebaudantoine 7124167947 🐛(frontend) fix pinnedTrackRef always evaluating to true
pinnedTrackRef was always truthy when evaluated in this
code path.
2026-07-24 18:31:47 +02:00
lebaudantoine 759388c72f ️(frontend) tripwire promotion of off-screen active speakers
Introduce a tripwire component that listens to
RoomEvent.ActiveSpeakersChanged imperatively and forces a single
re-render of its host only when an active speaker has none of their
tiles within the visible span (maxVisibleTiles). That re-render
re-runs useVisualStableUpdate, which reads live isSpeaking state
and performs the actual tile swap.

Speakers already visible are ignored, so this costs zero React work
in the common case.

This lets us drop the ActiveSpeakersChanged subscription from
useTracks in the StageLayout upstream (updateOnlyOn: []), which was
re-rendering the whole stage on every speaker change.
2026-07-24 18:31:47 +02:00
lebaudantoine a663b4dc76 ️(frontend) memoize the EffectsButton
Memoize the EffectsButton so it does not re-render on unrelated
parent updates when its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 73aa162dc8 ️(frontend) reduce re-renders of the ParticipantTile focus overlay
Optimize the idle mouse handling and the FocusOverlay component so
they no longer trigger frequent re-renders of the ParticipantTile
focus.

State related to hover and focus is now scoped closer to where it
is used, keeping updates local instead of propagating up the tile.
2026-07-24 18:31:47 +02:00
lebaudantoine a3851842e9 🚚(frontend) extract ParticipantTile sub-components into their own files
Split the sub-components currently declared inside ParticipantTile
into dedicated files.

This makes the ParticipantTile file easier to read and lets each
sub-component be imported and reasoned about on its own.
2026-07-24 18:31:47 +02:00
lebaudantoine 77964c6a74 ♻️(frontend) harmonize participant name handling in ParticipantTile
Align how the participant name is retrieved and rendered inside the
ParticipantTile, so the different code paths use a single consistent
approach instead of a mix of ad hoc logic.
2026-07-24 18:31:47 +02:00
lebaudantoine 72b863e794 ️(frontend) memoize the Placeholder component
Turn the Placeholder into a pure leaf component and memoize it, so
it does not re-render on unrelated parent updates when its props
have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 0e3c978af2 ️(frontend) size the Avatar with CSS instead of useSize
Stop using the useSize hook to compute the Avatar size in JS. Rely
on CSS to size the Avatar responsively instead.

This removes a set of unnecessary re-renders triggered by the
useSize subscription every time the container resized.
2026-07-24 18:31:47 +02:00
203 changed files with 2188 additions and 8187 deletions
+29
View File
@@ -0,0 +1,29 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+2 -60
View File
@@ -8,66 +8,17 @@ and this project adheres to
## [Unreleased]
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
- ✨(backend) add roomkit viewset to start a room without WebRTC join
- ✨(frontend) let users set default configuration for generated links
- ✨(frontend) expose media state to external gateways
- ✨(frontend) add connection test feature
- ✨(sdk) allow passing a background color to the calendar iframe
- ✨(sdk) add a room configuration popup from CreateMeetingButton
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.6
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
@@ -75,10 +26,8 @@ and this project adheres to
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
- 🚚(backend) rename TelephonyService to SIPManagement
- ⬆️(dependencies) update python dependencies
### Fixed
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
@@ -87,13 +36,6 @@ and this project adheres to
- 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
- 🚸(frontend) show two initials in the Avatar when possible
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
- 🐛(frontend) stop the installed app reopening the room it came from
- 🐛(backend) serialize lazy title in summary payload
- 💄(frontend) show pointer cursor on interactive switches
- 🐛(frontend) fix icon centering in the Switch primitive
- 🐛(frontend) keep Unicode initials intact in avatar
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
## [1.24.0] - 2026-07-21
+14 -14
View File
@@ -44,7 +44,6 @@ COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
@@ -52,14 +51,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Linters
LINT_RUFF_FORMAT = ruff format .
LINT_RUFF_CHECK = ruff check . --fix
LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -133,7 +124,6 @@ logs: ## display app-dev logs (follow mode)
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up -d livekit
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -198,23 +188,27 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
lint: \
lint-ruff-format \
lint-ruff-check \
lint-pylint
.PHONY: lint
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
@$(COMPOSE_RUN_APP) ruff format .
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
@$(COMPOSE_RUN_APP) ruff check . --fix
.PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@@ -395,6 +389,12 @@ build-k8s-cluster: \
./bin/start-kind.sh
.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
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+6
View File
@@ -1,5 +1,11 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
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')
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
+3 -2
View File
@@ -85,6 +85,7 @@ services:
- postgresql
- mailcatcher
- redis
- livekit
- createbuckets
- createwebhook
extra_hosts:
@@ -96,7 +97,7 @@ services:
celery-dev:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
@@ -131,7 +132,7 @@ services:
celery:
user: ${DOCKER_USER:-1000}
image: meet:backend-production
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
-5
View File
@@ -65,11 +65,6 @@ server {
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+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/).
### 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).
+1 -8
View File
@@ -63,7 +63,7 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=False
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN=password
@@ -85,10 +85,6 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
# RoomKit
# ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# Metadata
METADATA_COLLECTOR_ENABLED=True
@@ -104,6 +100,3 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
# Diagnostics
CONNECTION_TEST_ENABLED = True
+5 -5
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "^26.3.6",
"i18next": "^26.3.4",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9364,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [
{
"type": "individual",
@@ -9383,7 +9383,7 @@
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.6",
"i18next": "26.3.4",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+6 -6
View File
@@ -1,22 +1,22 @@
[project]
name = "agents"
version = "1.26.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.6.4",
"livekit-plugins-deepgram==1.6.4",
"livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"sentry-sdk==2.60.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.15.19",
]
[tool.uv]
+53 -53
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.26.0"
version = "1.24.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
@@ -29,15 +29,15 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "livekit-agents", specifier = "==1.6.7" },
{ name = "livekit-plugins-deepgram", specifier = "==1.6.7" },
{ name = "livekit-agents", specifier = "==1.6.4" },
{ name = "livekit-plugins-deepgram", specifier = "==1.6.4" },
{ name = "livekit-plugins-kyutai-lasuite", specifier = "==0.0.6" },
{ name = "livekit-plugins-silero", specifier = "==1.6.7" },
{ name = "livekit-plugins-silero", specifier = "==1.6.4" },
{ name = "minio", specifier = "==7.2.20" },
{ name = "protobuf", specifier = "==6.33.6" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" },
{ name = "sentry-sdk", specifier = "==2.66.1" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.19" },
{ name = "sentry-sdk", specifier = "==2.60.0" },
]
provides-extras = ["dev"]
@@ -762,16 +762,16 @@ wheels = [
[[package]]
name = "json-repair"
version = "0.60.1"
version = "0.59.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a6/d69888cb4ffde30e80db1e6c32caaadd2f984a80067d5ea72c2cb3f61c3f/json_repair-0.60.1.tar.gz", hash = "sha256:841661cdd2df507c9a4e189097f38ca6bc372e06d4b4e36d72e590f68176c290", size = 49451, upload-time = "2026-06-03T17:28:44.451Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/7c/e95bb03068572146eba37e8175c760f470ea0a6097310e16bbf2bc6e6457/json_repair-0.59.10.tar.gz", hash = "sha256:2e4b85537c752d8a513ea28fdad891e5ede32c83de745366b97f648b8c34ede7", size = 49133, upload-time = "2026-05-14T06:41:51.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049", size = 48045, upload-time = "2026-06-03T17:28:43.038Z" },
{ url = "https://files.pythonhosted.org/packages/ee/87/49b20c6b81493d55c311f711ed87319d0fbad8bd0bbfbe36e52103af36bd/json_repair-0.59.10-py3-none-any.whl", hash = "sha256:5468fa3eaadcc9b4a5646776bc4176e2fe5f374b5848a15f468cce3b60e3db0e", size = 47742, upload-time = "2026-05-14T06:41:49.812Z" },
]
[[package]]
name = "livekit"
version = "1.1.13"
version = "1.1.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -779,18 +779,18 @@ dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/92/dcd4f295913533ddd0d48153cc28f1358d550bea651460bd895256981c4d/livekit-1.1.13.tar.gz", hash = "sha256:aa2bd89cf0c2ebcaa71a240275964c23900a0422a2a3d43d274e88a211a0ecfc", size = 370211, upload-time = "2026-06-30T11:54:00.321Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/2c/3e8412615a2f4b9abdd1dec54138f8810b58e1e5c366443c098d52ef1957/livekit-1.1.12.tar.gz", hash = "sha256:a8e3aa59a7299b136a2a5442f90a9c8a5a8188afe94ffeeb85873d6ceaabf939", size = 369150, upload-time = "2026-06-24T19:50:49.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/bd/15100217109595aedbb9bcfdfc1c77513c0f44940d72644d16b611476941/livekit-1.1.13-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2a19b023de9a573fe5da629e3ee514f2962c87e1afec95a6b19bbbdb6ea8f703", size = 10147642, upload-time = "2026-06-30T11:53:48.781Z" },
{ url = "https://files.pythonhosted.org/packages/97/dd/4f001a9c5ccde361a53437a09bc04dc9cad8003c6711c2bcc0734e18e626/livekit-1.1.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:90b6796e0c4515bc1e8a1e109a40b88d82c36b12452b7ae01fe35be7ee8357ba", size = 8968740, upload-time = "2026-06-30T11:53:51.223Z" },
{ url = "https://files.pythonhosted.org/packages/26/1a/7e97a45a4b6e10ce3a4e9938e3d3fa0a6512a6557f5990685eab4f6e88d1/livekit-1.1.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:43779c0f3bb27589cd517d60c442c674c2f967a97db158829a533974a29a3997", size = 9980507, upload-time = "2026-06-30T11:53:53.349Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9d/389bbdf39ccd2c464a4749ba7be1584dea608518c32a5ddbc511db6b0cf7/livekit-1.1.13-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f4e83f0e272f4b2e9cc39dbefd7bb23b75bd8c105478d867c2869254ca4e149a", size = 11367692, upload-time = "2026-06-30T11:53:55.409Z" },
{ url = "https://files.pythonhosted.org/packages/da/ce/a3d3e0566dbd2586c325240d44afec6d44421eb794bcf8dbaca15463a7b7/livekit-1.1.13-py3-none-win_amd64.whl", hash = "sha256:22dff7a39cb3d590a4757e20d3ce5d326ab882350386f5070f45cbd6d9ccf839", size = 10717013, upload-time = "2026-06-30T11:53:57.701Z" },
{ url = "https://files.pythonhosted.org/packages/39/6f/b2a1486f9f217043dbf52ed93bf6a77febd4d86615b02cddd7758a225f52/livekit-1.1.12-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:7fe82d7f2ef76ef6f7d856581f4519c2870fcffe3c33a8b2e74315d7c0bbdadc", size = 10140961, upload-time = "2026-06-24T19:50:38.69Z" },
{ url = "https://files.pythonhosted.org/packages/fb/de/656428ad72ce5b6911be2f084ead1e9c3b04d46015981de7e94177628a8d/livekit-1.1.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3c33d6b6df872447d3295443eb4886b84b6d6045ce8b3627504ee840ddf908fb", size = 8965085, upload-time = "2026-06-24T19:50:40.945Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ce/572f3f15571625ee9f99f103f2a13503797ed80636d70ce0dd58034e5e1d/livekit-1.1.12-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0f452415ec4c7c789bd99bc098dc19710f49249993b13335e5741de50fa423dc", size = 9974856, upload-time = "2026-06-24T19:50:43.329Z" },
{ url = "https://files.pythonhosted.org/packages/db/d7/5a798f8ef40889c8236a05d1a3985a77114c2c8f4584ac118987d6f8d9d5/livekit-1.1.12-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:19611fb5fa6bd1e6366acc7526aecab1c8fa2afe9e579f73d34df575c667aa53", size = 11359455, upload-time = "2026-06-24T19:50:45.654Z" },
{ url = "https://files.pythonhosted.org/packages/1d/45/f28e25888babc83a43769f428545c9ee781366a0ef22b4b479318c0f95bc/livekit-1.1.12-py3-none-win_amd64.whl", hash = "sha256:4e81a366a6c7a83b435d9de15760da70d4c13748b61eb8d5f000c9d279c8a39e", size = 10710076, upload-time = "2026-06-24T19:50:47.663Z" },
]
[[package]]
name = "livekit-agents"
version = "1.6.7"
version = "1.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -825,9 +825,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "watchfiles" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/6d/a1cccc1fa97dd4f4a76bd92d03fe0742607f6a61bd010867f5714a73d5a3/livekit_agents-1.6.7.tar.gz", hash = "sha256:039112aa05cea17328c3d7bbb69f164e75e35e8408480f3479c2c356e4542a17", size = 2626576, upload-time = "2026-07-25T02:04:04.812Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/a1/e681926fd3ddd3323a50b638e5c9f8d5cea68db0eaafcc040dbf65b5efeb/livekit_agents-1.6.4.tar.gz", hash = "sha256:deb1b47a1ab637c93ab675980bb4532fb01244604b41eda9d6e3ca268a52e794", size = 2561744, upload-time = "2026-06-24T20:49:24.032Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9b/46c123ae94e36cd68e1327ce5b9aa378fa2d44916e549ccfb674e795a8e8/livekit_agents-1.6.7-py3-none-any.whl", hash = "sha256:f517fd5d559a48cd776bd13fd955b26889f2666dd9a07d233e1e5225e409563d", size = 2738751, upload-time = "2026-07-25T02:04:02.52Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bb/c50992829fadd273fed66ae761f87ed4296fdd37eba0d0bc8c79cdddd896/livekit_agents-1.6.4-py3-none-any.whl", hash = "sha256:5341849645f768ed8bd1d276688f2b6ea0e72574e2aab7206d4e18c5628a97cd", size = 2669501, upload-time = "2026-06-24T20:49:22.119Z" },
]
[package.optional-dependencies]
@@ -837,7 +837,7 @@ codecs = [
[[package]]
name = "livekit-api"
version = "1.2.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -846,9 +846,9 @@ dependencies = [
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz", hash = "sha256:a89817b3bca9584873786ff07209839308217537a42f95ecb2609aafaa109ddc", size = 20778, upload-time = "2026-07-11T23:20:54.781Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/03/00e0ec173f247e1f7ea63cb5591d5680a64c7a74ea4d5d558e5aed6cc399/livekit_api-1.1.1.tar.gz", hash = "sha256:70c7b80eecbc297b40756ebd76e4f52d00b0348fb7d212a21c1f69cc57fd9c83", size = 15196, upload-time = "2026-06-24T01:36:19.686Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e7/8926f16d4bc1b2e0ae46d4a507321bb899396d263a757f1adaabcd3b3867/livekit_api-1.2.0-py3-none-any.whl", hash = "sha256:307f8e5cfb0358c3ca091814ab768af55896022151bcd7f951954ccefa036a24", size = 26499, upload-time = "2026-07-11T23:20:53.736Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/d5f3ff74ab5db2d06f173801ec934885d11a754b9fb9ad768c8ede0a6c89/livekit_api-1.1.1-py3-none-any.whl", hash = "sha256:ce8c327676c366e66cf68782934368dd0ba92b9d48f578275227e255c890fe88", size = 19471, upload-time = "2026-06-24T01:36:18.42Z" },
]
[[package]]
@@ -902,15 +902,15 @@ wheels = [
[[package]]
name = "livekit-plugins-deepgram"
version = "1.6.7"
version = "1.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "livekit-agents", extra = ["codecs"] },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/0b/32b40c498d76d23283e02df8410b5adba21919b8d6adac8e88e83c8d5bad/livekit_plugins_deepgram-1.6.7.tar.gz", hash = "sha256:76f102d69c5159d87aa53581b8402aa28838d512203ccbc79212a3edda0646b0", size = 22610, upload-time = "2026-07-25T02:04:38.185Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/75/f61c70b7bb85f2f246ef438acd6f3f240de700fe59d8d53bdb0c7898886f/livekit_plugins_deepgram-1.6.4.tar.gz", hash = "sha256:d01292efcd0dd3875ba87d652efe14e6e3c5d26a327d8e8e86a1983bbe94b7e3", size = 18347, upload-time = "2026-06-24T20:49:45.321Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/f9/f9e25f08321c24f85cdb00e75163c6b534d254dec7755e8481f0ecc6e270/livekit_plugins_deepgram-1.6.7-py3-none-any.whl", hash = "sha256:741ceb59ae181b5eeb9f95bd5dec0c8381e3f7532a10d67c7ab1fcf024085643", size = 26084, upload-time = "2026-07-25T02:04:36.935Z" },
{ url = "https://files.pythonhosted.org/packages/2d/00/c04d24daed22fee9f3cec3cea6f9e5e0217fd9c1df725c585ac08a42c3c4/livekit_plugins_deepgram-1.6.4-py3-none-any.whl", hash = "sha256:a590f2251d5ccda4a555077cbfccbb66194b6ec55911e7bd33d89565eba15611", size = 23084, upload-time = "2026-06-24T20:49:44.126Z" },
]
[[package]]
@@ -929,29 +929,29 @@ wheels = [
[[package]]
name = "livekit-plugins-silero"
version = "1.6.7"
version = "1.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "livekit-agents" },
{ name = "numpy" },
{ name = "onnxruntime" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/6a/213524075717b84d140efec953fe46ec7c6ad408014f7d20bee48195e4f6/livekit_plugins_silero-1.6.7.tar.gz", hash = "sha256:481503ece53c44bd36bbfdea0f97d067bd4dc2ef1c04f9a0244c3397dd9d1966", size = 1955926, upload-time = "2026-07-25T02:06:27.383Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/85/ae6e73640ade39968d64bf0737235a5ffe11e78948a061ddf7b7f4dbf894/livekit_plugins_silero-1.6.4.tar.gz", hash = "sha256:4a9bdf6d3ccb1c0433fd9c39ae7174f7275d4de183acb59a60ff8edb62fcaef0", size = 1956517, upload-time = "2026-06-24T20:51:20.554Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/9f/0622854a2a99a6a4f08a2e13b59f000f16ab8ccddf897d223b90b2599bd2/livekit_plugins_silero-1.6.7-py3-none-any.whl", hash = "sha256:ab56544919c2046b8fe6c58f688fe74961d4e1eb273ea7e7912bb4917b315d21", size = 3903914, upload-time = "2026-07-25T02:06:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/00/3f53f00632fc07767d4c367815096c3cf80e4c3af28da8a1e29866b6843c/livekit_plugins_silero-1.6.4-py3-none-any.whl", hash = "sha256:b613f93c5aa4c7635ea12691795446ca2bdff6499417b42c7de8b14cc1df7a6f", size = 3904448, upload-time = "2026-06-24T20:51:18.615Z" },
]
[[package]]
name = "livekit-protocol"
version = "1.1.21"
version = "1.1.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ae/9d60fe37d85623e68a2e36ae31d18c671949db67d2a13a6438410d930466/livekit_protocol-1.1.21.tar.gz", hash = "sha256:8bb1ac1aba5d37d0af43e9d56d129a5d16295cbd91518b00fd157e258f20a6ef", size = 122363, upload-time = "2026-07-21T18:28:26.372Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/88/64f2be01a630e249f1dbd0d51876f109b53b7899ae41246d2ca5b647086d/livekit_protocol-1.1.18.tar.gz", hash = "sha256:187af32ebf75333a62117b0db9e551c99060bd4e1f57cfc0fce73bcd7a671da8", size = 115802, upload-time = "2026-06-27T15:31:04.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/d2/ec10b1cdf912235c2b07898be6ba2535e50f23e8980bb719f29decbd8034/livekit_protocol-1.1.21-py3-none-any.whl", hash = "sha256:ce0bb763327c91349ee8831843c4f8bd72132c4d06ac572171f2ae5c0217211d", size = 149245, upload-time = "2026-07-21T18:28:24.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/80/9cc33e4d0280132538850aaf9559d6b8aa9e670c5917f75dab996400ab84/livekit_protocol-1.1.18-py3-none-any.whl", hash = "sha256:30c539410fd3cfc2e551ca3a193aaaaacaaec6dd57dabe2c9be7c7c7d15f0e01", size = 143134, upload-time = "2026-06-27T15:31:02.686Z" },
]
[[package]]
@@ -1753,40 +1753,40 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.0"
version = "0.15.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
{ url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" },
{ url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" },
{ url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" },
{ url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" },
{ url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" },
{ url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" },
{ url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" },
{ url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" },
{ url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" },
{ url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" },
{ url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" },
{ url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" },
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.66.1"
version = "2.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
{ url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" },
]
[[package]]
-3
View File
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
-4
View File
@@ -61,11 +61,7 @@ def get_frontend_configuration(request):
],
},
"telephony": build_telephony_config(),
"resource": {
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
-2
View File
@@ -16,8 +16,6 @@ class FeatureFlag:
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
}
@classmethod
+1 -20
View File
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_room_access_level",
"default_room_configuration",
]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def validate_default_room_configuration(self, value):
"""Validate the default room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
-24
View File
@@ -73,27 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation 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"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
+2 -94
View File
@@ -2,10 +2,8 @@
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from urllib.parse import unquote, urlparse
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -29,9 +27,6 @@ from rest_framework import (
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
@@ -41,7 +36,6 @@ from rest_framework import (
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -99,9 +93,7 @@ from core.services.room_roles import (
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
@@ -316,27 +308,8 @@ class RoomViewSet(
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
Apply the user's default room preferences (access level and configuration)
unless the request explicitly provides its own values.
"""
user = self.request.user
save_kwargs = {}
if (
"access_level" not in serializer.validated_data
and user.default_room_access_level not in (None, "")
):
save_kwargs["access_level"] = user.default_room_access_level
user_default_configuration = user.default_room_configuration
if not serializer.validated_data.get(
"configuration"
) and user_default_configuration not in (None, {}):
save_kwargs["configuration"] = user.default_room_configuration
room = serializer.save(**save_kwargs)
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
@@ -1571,68 +1544,3 @@ class FileViewSet(
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [drf_permissions.AllowAny]
@decorators.action(
detail=False,
methods=["POST"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
@FeatureFlag.require("connection_test")
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
max_age = (
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
)
delete_connection_test_room.apply_async(
args=[room],
countdown=max_age,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username="Connection Test",
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__( # noqa: PLR0917
def __init__(
self,
secret_key,
algorithm,
@@ -1,23 +0,0 @@
# Generated by Django 5.2.14 on 2026-08-03 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AddField(
model_name='user',
name='default_room_access_level',
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
),
migrations.AddField(
model_name='user',
name='default_room_configuration',
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
),
]
+1 -27
View File
@@ -189,25 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
default_room_access_level = models.CharField(
max_length=50,
choices=RoomAccessLevel.choices,
blank=True,
null=True,
verbose_name=_("default room access level"),
help_text=_(
"Access level applied by default to new rooms created by this user. "
"When empty, the instance default is used."
),
)
default_room_configuration = models.JSONField(
blank=True,
default=dict,
verbose_name=_("default room configuration"),
help_text=_(
"Configurations applied by default to new rooms created by this user."
),
)
is_device = models.BooleanField(
_("device"),
default=False,
@@ -448,14 +429,7 @@ class Room(Resource):
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
# 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
):
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
@@ -121,7 +121,7 @@ class NotificationService:
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
@@ -192,7 +192,7 @@ class NotificationService:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return gettext("Transcription")
return _("Transcription")
dt = recording_datetime
if owner_timezone:
-1
View File
@@ -1 +0,0 @@
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
@@ -1,65 +0,0 @@
"""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
@@ -1,21 +0,0 @@
"""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
@@ -1,89 +0,0 @@
"""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,
)
+1 -1
View File
@@ -30,7 +30,7 @@ class TokenDecodeError(JWTError):
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__( # noqa: PLR0917
def __init__(
self,
secret_key: str,
algorithm: str,
+10 -22
View File
@@ -28,7 +28,7 @@ from .room_management import (
RoomManagementException,
RoomNotFoundException,
)
from .sip_management import SIPException, SIPManagement
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -107,7 +107,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.sip_management = SIPManagement()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
self._filter_regex = None
@@ -137,13 +137,6 @@ class LiveKitEventsService:
room_name = data.room.name or data.egress_info.room_name
if self._is_connection_test_room(room_name):
logger.info(
"Ignoring webhook event for connection test room '%s'.",
room_name,
)
return
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
@@ -235,11 +228,6 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -257,12 +245,12 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create sip dispatch rule for room {room_id}"
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
@@ -277,12 +265,12 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.delete_dispatch_rule(room_id)
except SIPException as e:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete sip dispatch rule for room {room_id}"
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
@@ -112,7 +112,7 @@ class ParticipantsManagement:
await lkapi.aclose()
@async_to_sync
async def update( # noqa: PLR0917
async def update(
self,
room_name: str,
identity: str,
@@ -8,7 +8,6 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
@@ -89,30 +88,3 @@ class RoomManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the deletion otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted LiveKit room %s", room_name)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping deletion",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception("Unexpected error deleting room %s", room_name)
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
@@ -1,9 +1,9 @@
"""SIP management service for managing SIP dispatch rules for room access."""
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError, TwirpErrorCode
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
@@ -17,16 +17,12 @@ from core import utils
logger = getLogger(__name__)
class SIPException(Exception):
"""Exception raised when SIP operations fail."""
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class DispatchRuleConflictError(SIPException):
"""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)."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
@@ -36,7 +32,7 @@ class SIPManagement:
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures livekit-sip to route incoming SIP calls directly to the specified room
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
@@ -55,12 +51,10 @@ class SIPManagement:
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
if e.code == TwirpErrorCode.ALREADY_EXISTS:
raise DispatchRuleConflictError("Dispatch rule already exists") from e
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise SIPException("Could not create dispatch rule") from e
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
@@ -85,7 +79,7 @@ class SIPManagement:
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise SIPException("Could not list dispatch rules") from e
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
@@ -100,28 +94,6 @@ class SIPManagement:
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 def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
@@ -146,7 +118,7 @@ class SIPManagement:
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise SIPException("Could not delete dispatch rules") from e
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
-9
View File
@@ -1,9 +0,0 @@
"""Celery tasks for the core app."""
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
)
-7
View File
@@ -1,11 +1,4 @@
"""
Celery task decorator that degrades to a synchronous call when Celery is off.
"""
# The Celery app is imported lazily so that importing this module does not pull
# in Celery when CELERY_ENABLED is false.
# ruff: noqa: PLC0415
# pylint: disable=import-outside-toplevel
from django.conf import settings
-39
View File
@@ -1,39 +0,0 @@
"""Tasks related to connection test rooms."""
import logging
from django.conf import settings
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def delete_connection_test_room(room_name: str):
"""Force-delete an ephemeral connection-test room.
Used as a hard cap so a participant cannot keep an auto-refreshed
LiveKit session open indefinitely after requesting a test token.
"""
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
if not room_name.startswith(prefix):
logger.error(
"Refusing to delete room '%s': expected prefix '%s'.",
room_name,
prefix,
)
return
try:
RoomManagement().delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name)
except RoomManagementException:
logger.exception("Failed to delete connection test room '%s'.", room_name)
@@ -5,7 +5,6 @@ Test event notification.
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
import datetime
import json
import smtplib
from unittest import mock
@@ -419,63 +418,3 @@ def test_notify_summary_service_post_args_without_metadata(
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
settings,
):
"""Regression test for a non-JSON-serializable payload when timestamps are missing.
When the LiveKit egress can no longer be found, ``_get_recording_timestamps``
returns ``(None, None)`` and ``_generate_title`` falls back to its default
title. That default must be a real ``str``: it used to return a lazy
``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real
``requests.post(json=payload)`` call crashed in production with
``TypeError: Object of type __proxy__ is not JSON serializable``.
"""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = False
recording = factories.RecordingFactory(room__name="Daily")
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="fr-fr",
timezone="Europe/Paris",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
# Egress timestamps unavailable -> default-title branch in _generate_title.
mock_get_recording_timestamps.return_value = (None, None)
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-77"}
mock_post.return_value = mock_response
result = NotificationService._notify_summary_service(recording)
assert result is True
payload = mock_post.call_args.kwargs["json"]
title = payload["push_to_docs_config"]["title"]
# The title must be a plain ``str``, not a lazy translation proxy...
assert isinstance(title, str)
# ...so the payload serializes exactly the way ``requests`` serializes it.
json.dumps(payload)
@@ -1 +0,0 @@
"""Tests for the roomkit API of the Meet core app."""
@@ -1,305 +0,0 @@
"""
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
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_anonymous(mock_sip_management, settings, client):
"""Requests without an Authorization header should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_malformed_authorization_header(mock_sip_management, settings, client):
"""Requests with a malformed Authorization header should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_wrong_bearer(mock_sip_management, settings, client):
"""Requests with an incorrect bearer token should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_token_not_configured(mock_sip_management, settings, client):
"""Requests should be rejected when no server-to-server token is configured."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_roomkit_disabled(mock_sip_management, settings, client):
"""The endpoint should not be exposed when the roomkit integration is disabled."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_missing_pin(mock_sip_management, settings, client):
"""Requests without a PIN code should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_blank_pin(mock_sip_management, settings, client):
"""Requests with a blank PIN code should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_wrong_pin_length(mock_sip_management, settings, client):
"""Requests with a PIN code of unexpected length should be rejected."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_unknown_pin(mock_sip_management, settings, client):
"""Requests with a PIN matching no room should return 404 and create no rule."""
mock_sip_instance = mock_sip_management.return_value
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_instance.ensure_dispatch_rule.assert_not_called()
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_success(mock_sip_management, settings, client):
"""Requests with a valid PIN should create the dispatch rule."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.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_instance.ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_dispatch_rule_already_exists(mock_sip_management, settings, client):
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.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_instance.ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch("core.roomkit.viewsets.analytics.capture")
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_tracks_analytics_event(
mock_sip_management, mock_capture, settings, client
):
"""Successful joins should be tracked with an analytics event."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.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
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,
}
@mock.patch("core.roomkit.viewsets.analytics.capture")
@mock.patch("core.roomkit.viewsets.SIPManagement")
def test_join_sip_failure(mock_sip_management, mock_capture, settings, client):
"""Requests should fail with a server error when the sip management service fails."""
mock_sip_instance = mock_sip_management.return_value
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_instance.ensure_dispatch_rule.side_effect = SIPException(
"Could not create dispatch rule"
)
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_instance.ensure_dispatch_rule.assert_called_once_with(room)
mock_capture.assert_not_called()
@@ -3,14 +3,13 @@ Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
from django.conf import settings
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import Room, RoomAccessLevel
from ...models import Room
pytestmark = pytest.mark.django_db
@@ -110,205 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
when the request does not provide one.
"""
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == RoomAccessLevel.RESTRICTED
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
"""
An access level explicitly provided in the request should take precedence
over the user's default room access level.
"""
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"access_level": RoomAccessLevel.TRUSTED,
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == RoomAccessLevel.TRUSTED
def test_api_rooms_create_authenticated_no_user_default_access_level():
"""
When the user has no default room access level, the instance default
should be applied to the new room.
"""
user = UserFactory(default_room_access_level=None)
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
def test_api_rooms_create_authenticated_user_default_configuration():
"""
The user's default room configuration should be applied to the new room
when the request does not provide one.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": False}
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
"""
A configuration explicitly provided in the request should take precedence
over the user's default room configuration.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
"""
An empty configuration in the request should not be considered an explicit
value: the user's default room configuration should still be applied.
"""
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
"configuration": {},
},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": True}
def test_api_rooms_create_authenticated_empty_user_default_configuration():
"""
When the user's default room configuration is empty, the new room should
keep its default empty configuration.
"""
user = UserFactory(default_room_configuration={})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {}
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
"""
When the user's default room configuration is empty, the request should take precedence.
"""
user = UserFactory(default_room_configuration={})
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "configuration": {"everyone_can_mute": True}},
format="json",
)
assert response.status_code == 201
room = Room.objects.get()
assert room.configuration == {"everyone_can_mute": True}
def test_api_rooms_create_authenticated_blank_user_default_access_level():
"""
A blank default room access level (stored as an empty string) should be
treated as unset: the instance default should be applied to the new room
instead of persisting an invalid empty access level.
"""
user = UserFactory(default_room_access_level="")
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "my room",
},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@@ -453,8 +453,6 @@ def test_api_rooms_retrieve_administrators(
{
"id": str(other_user_access.id),
"user": {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
@@ -468,8 +466,6 @@ def test_api_rooms_retrieve_administrators(
{
"id": str(user_access.id),
"user": {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
@@ -21,10 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.sip_management import (
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -62,7 +59,7 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.sip_management, SIPManagement)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@@ -75,12 +72,11 @@ def test_initialization(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
def test_handle_egress_ended_success(
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
@@ -159,12 +155,11 @@ def test_handle_egress_updated_non_handled(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
def test_handle_egress_ended_metadata_update_fails(
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop and save recording when metadata's update fails."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
@@ -350,7 +345,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
@@ -392,7 +387,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913, PLR0917
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
@@ -474,11 +469,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
@@ -491,31 +486,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@mock.patch.object(LobbyService, "clear_room_cache")
@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")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
@@ -529,7 +505,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
@@ -552,9 +528,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
SIPManagement,
TelephonyService,
"delete_dispatch_rule",
side_effect=SIPException("Test error"),
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
@@ -565,7 +541,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete sip dispatch rule for room "
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
@@ -586,11 +562,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
service._handle_room_finished(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
@@ -598,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
mock_ensure_dispatch_rule, service, settings
):
"""Should ensure the dispatch rule exists 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_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")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should skip ensuring the SIP 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.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()
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
@@ -720,7 +643,6 @@ def test_receive_unsupported_event(mock_receive, service):
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
@@ -824,33 +746,3 @@ def test_receive_filter_processes_matching_events(
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_ignores_connection_test_room(
mock_handle_room_started,
mock_handle_room_finished,
mock_receive,
mock_livekit_config,
settings,
):
"""Should ignore all webhook events for connection test rooms in receive()."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called()
@@ -3,7 +3,7 @@ Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913, PLR0917
# ruff: noqa: PLR0913
import uuid
from unittest import mock
@@ -1,60 +0,0 @@
"""Tests for the RoomManagement service."""
from unittest import mock
import pytest
from livekit.api import TwirpError
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_calls_livekit(mock_create_livekit_client):
"""DeleteRoom is forwarded to the LiveKit API."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock()
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
RoomManagement().delete_room("room-abc")
mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
assert request.room == "room-abc"
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_not_found(mock_create_livekit_client):
"""Missing rooms raise RoomNotFoundException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("not_found", "room not found", status=404)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomNotFoundException):
RoomManagement().delete_room("missing-room")
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_management_exception(mock_create_livekit_client):
"""Unexpected Twirp errors raise RoomManagementException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("internal", "boom", status=500)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomManagementException):
RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once()
@@ -1,5 +1,5 @@
"""
Test SIP mamagement service.
Test telephony service.
"""
# pylint: disable=W0212
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.sip_management import (
DispatchRuleConflictError,
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
def test_rule_name():
"""Test rule name generation."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = sip_management._rule_name(room.id)
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@@ -49,14 +45,14 @@ def test_rule_name():
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
sip_management = SIPManagement()
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()
mock_client_factory.return_value = mock_api
sip_management.create_dispatch_rule(room)
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
@@ -71,7 +67,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.create_dispatch_rule(room)
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@@ -90,7 +86,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
@@ -131,7 +127,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -149,7 +145,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -176,7 +172,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not list dispatch rules"):
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -220,7 +216,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -242,7 +238,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
@@ -257,11 +253,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.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)
@@ -1,51 +0,0 @@
"""Tests for connection test Celery tasks."""
from unittest import mock
from django.test.utils import override_settings
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.tasks.connection_test import delete_connection_test_room
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
"""RoomManagement.delete_room is called for connection-test rooms."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("connection-test-abc")
mock_delete_room.assert_called_once_with("connection-test-abc")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
"""Refuse to delete rooms outside the connection-test namespace."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("production-room")
mock_delete_room.assert_not_called()
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
"""Missing rooms are treated as already cleaned up."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
delete_connection_test_room("connection-test-gone")
mock_delete_room.assert_called_once_with("connection-test-gone")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
"""Unexpected LiveKit failures are swallowed after logging."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
delete_connection_test_room("connection-test-fail")
mock_delete_room.assert_called_once_with("connection-test-fail")
@@ -1,166 +0,0 @@
"""Test diagnostics API endpoints."""
import uuid
from unittest import mock
from django.test.utils import override_settings
from django.urls import reverse
import jwt
import pytest
from rest_framework.test import APIClient
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
)
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_api_diagnostics_connection_url():
"""The connection check is exposed under the diagnostics namespace."""
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
def test_api_diagnostics_connection_rejects_get():
"""Only POST is exposed, the endpoint has no side effect to trigger."""
client = APIClient()
response = client.get("/api/v1.0/diagnostics/connection/")
assert response.status_code == 405
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
"""Each request gets a dedicated room and a short-lived token."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response_a = client.post("/api/v1.0/diagnostics/connection/")
response_b = client.post("/api/v1.0/diagnostics/connection/")
assert response_a.status_code == 200
assert response_b.status_code == 200
data_a = response_a.json()
data_b = response_b.json()
room_a = data_a["livekit"]["room"]
room_b = data_b["livekit"]["room"]
assert room_a.startswith("connection-test-")
assert room_b.startswith("connection-test-")
uuid.UUID(room_a.removeprefix("connection-test-"))
uuid.UUID(room_b.removeprefix("connection-test-"))
assert room_a != room_b
assert data_a["livekit"]["url"]
assert data_a["livekit"]["token"]
assert data_a["livekit"]["expires_in"] == 600
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
"""Connection test tokens expire quickly for users."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
client = APIClient()
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
config = response.json()["livekit"]
payload = jwt.decode(
config["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == "Connection Test"
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@override_settings()
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
"""Logged-in users get a token bound to their own identity."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
user = UserFactory()
client.force_login(user)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
payload = jwt.decode(
response.json()["livekit"]["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert payload["sub"] == str(user.sub)
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_schedules_room_deletion(
mock_apply_async, settings, client
):
"""When Celery is enabled, schedule a hard room delete after max age."""
settings.CELERY_ENABLED = True
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = 10
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
room = response.json()["livekit"]["room"]
mock_apply_async.assert_called_once_with(args=[room], countdown=310)
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
mock_apply_async, settings, client
):
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
settings.CELERY_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
@pytest.mark.parametrize(
"throttle_class",
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
)
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
"""Both throttles stay wired to the action once routed through the viewset."""
with (
mock.patch.object(throttle_class, "allow_request", return_value=False),
mock.patch.object(throttle_class, "wait", return_value=42),
):
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 429
def test_api_diagnostics_connection_feature_flag(client, settings):
"""Should return a not found error when the connection diagnostics feature is disabled."""
settings.CONNECTION_TEST_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 404
-2
View File
@@ -119,8 +119,6 @@ def test_api_users_retrieve_me_authenticated(settings):
assert response.status_code == 200
assert response.json() == {
"default_room_access_level": None,
"default_room_configuration": {},
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
@@ -1,111 +0,0 @@
"""
Test the default room preferences exposed on the users API.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_users_me_includes_default_room_preferences():
"""The "me" endpoint should expose the user's default room preferences."""
user = factories.UserFactory(
default_room_access_level="restricted",
default_room_configuration={"everyone_can_mute": False},
)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
content = response.json()
assert content["default_room_access_level"] == "restricted"
assert content["default_room_configuration"] == {"everyone_can_mute": False}
def test_api_users_update_default_room_preferences():
"""Users should be able to update their own default room preferences."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{
"default_room_access_level": "trusted",
"default_room_configuration": {
"can_publish_sources": ["microphone", "camera"],
"everyone_can_mute": False,
},
},
format="json",
)
assert response.status_code == 200
user.refresh_from_db()
assert user.default_room_access_level == "trusted"
assert user.default_room_configuration == {
"can_publish_sources": ["microphone", "camera"],
"everyone_can_mute": False,
}
def test_api_users_update_default_room_access_level_invalid():
"""An invalid access level should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{"default_room_access_level": "invalid"},
format="json",
)
assert response.status_code == 400
user.refresh_from_db()
assert user.default_room_access_level is None
def test_api_users_update_default_room_configuration_invalid():
"""An invalid room configuration should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{user.id!s}/",
{"default_room_configuration": {"unknown_field": True}},
format="json",
)
assert response.status_code == 400
user.refresh_from_db()
assert user.default_room_configuration == {}
def test_api_users_update_other_user_default_room_preferences_forbidden():
"""Users should not be able to update someone else's preferences."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/users/{other_user.id!s}/",
{"default_room_access_level": "restricted"},
format="json",
)
assert response.status_code == 403
other_user.refresh_from_db()
assert other_user.default_room_access_level is None
+2 -15
View File
@@ -184,13 +184,12 @@ def test_models_rooms_is_public_property():
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_and_roomkit_disabled_skips_pin_generation(
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony and roomkit both disabled should not generate pin codes."""
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
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):
"""Pin codes should be created with correct configured length."""
-11
View File
@@ -9,7 +9,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
from core.roomkit import viewsets as roomkit_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -20,21 +19,11 @@ router.register("files", viewsets.FileViewSet, basename="files")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
router.register(
"roomkit",
roomkit_viewsets.RoomKitViewSet,
basename="roomkit",
)
router.register(
"addons/sessions",
addons_viewsets.SessionViewSet,
basename="addons_sessions",
)
router.register(
"diagnostics",
viewsets.DiagnosticsViewSet,
basename="diagnostics",
)
# - External API
external_router = SimpleRouter()
+2 -7
View File
@@ -12,7 +12,6 @@ import mimetypes
import random
import secrets
import string
from datetime import timedelta
from functools import lru_cache
from typing import List, Optional
from uuid import uuid4
@@ -60,7 +59,7 @@ def generate_color(identity: str) -> str:
return f"hsl({hue}, {saturation}%, {lightness}%)"
def generate_token( # noqa: PLR0917
def generate_token(
room: str,
user,
username: Optional[str] = None,
@@ -68,7 +67,6 @@ def generate_token( # noqa: PLR0917
sources: Optional[List[str]] = None,
role: Optional[str] = None,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -84,7 +82,6 @@ def generate_token( # noqa: PLR0917
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
Returns:
str: The LiveKit JWT access token.
@@ -138,13 +135,11 @@ def generate_token( # noqa: PLR0917
}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
def generate_livekit_config( # noqa: PLR0917
def generate_livekit_config(
room_id: str,
user,
username: str,
-51
View File
@@ -349,16 +349,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"roomkit_join": values.Value(
default="300/minute",
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
environ_prefix=None,
),
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -665,30 +655,6 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
default=False,
)
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
300,
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
environ_prefix=None,
)
# The effective room max age is always computed as
# CONNECTION_TEST_TOKEN_TTL_SECONDS + this value. Token expiration does
# not automatically delete rooms, so once that age is reached, the
# cleanup worker will explicitly delete the room if it still exists.
CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = values.PositiveIntegerValue(
10,
environ_name="CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_PREFIX = values.Value(
"connection-test",
environ_name="CONNECTION_TEST_ROOM_PREFIX",
environ_prefix=None,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -915,21 +881,6 @@ class Base(Configuration):
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
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
@@ -1299,8 +1250,6 @@ class Test(Base):
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
CONNECTION_TEST_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+16 -17
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.26.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,7 +24,7 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.56",
"boto3==1.43.36",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
@@ -32,7 +32,7 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==9.0.0",
"django-filter==26.1",
"django-filter==25.2",
"django-lasuite[all]==0.0.27",
"django-parler==2.4",
"redis==5.2.1",
@@ -40,9 +40,9 @@ dependencies = [
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.16",
"django==5.2.14",
"djangorestframework==3.17.1",
"drf_spectacular==0.30.0",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
@@ -50,21 +50,20 @@ dependencies = [
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"posthog==7.29.0",
"posthog==7.16.1",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.66.1",
"sentry-sdk==2.63.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.2.0",
"aiohttp==3.14.3",
"livekit-api==1.1.1",
"aiohttp==3.14.1",
"urllib3==2.7.0",
"phonenumbers==9.0.34",
"cryptography==50.0.0", # CVE-2026-69247
"phonenumbers==9.0.33",
]
[project.urls]
@@ -76,21 +75,21 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.7.1",
"drf-spectacular-sidecar==2026.6.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.15.0",
"ipython==9.14.1",
"pyfakefs==6.2.0",
"pylint-django==2.8.0",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.1.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.2",
"ruff==0.16.0",
"types-requests==2.33.0.20260712",
"responses==0.26.1",
"ruff==0.15.19",
"types-requests==2.33.0.20260518",
]
[tool.uv.build-backend]
+187 -190
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.3"
version = "3.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -24,72 +24,72 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
]
[[package]]
@@ -181,30 +181,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.56"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673, upload-time = "2026-07-24T19:31:48.606Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026, upload-time = "2026-07-24T19:31:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" },
]
[[package]]
name = "botocore"
version = "1.43.62"
version = "1.43.40"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/36af6d99269a701f83809b87a01f4728699eb825ebdedee3a3d515b18f61/botocore-1.43.62.tar.gz", hash = "sha256:94efc419c9f0f41dc2415e4b6b62f04ae21b3ce3930fac47214c4d3f361ea8b8", size = 15818261, upload-time = "2026-07-31T19:35:06.235Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/50/269986277f852cc83029bccbdcdc0b343a685cfd570599e58029792808d8/botocore-1.43.40.tar.gz", hash = "sha256:2085a4314cfd2c8bc1d08ab8039f76c92e99278db0d2a0e2437010526d5d5d70", size = 15639899, upload-time = "2026-07-03T00:28:16.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/65/d5dae96de68ffc55acf87c3bae76e9dabeeca92aadf1223f20e9a7860aef/botocore-1.43.62-py3-none-any.whl", hash = "sha256:76de153de1ba3e242b2e6df6a13ab8a3fb35d17db562462969e661457b63166e", size = 15502622, upload-time = "2026-07-31T19:35:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8c/5f7e73fd66b28f0705bc55d7060d41ef72328b656b86ca53e75765b3ba2c/botocore-1.43.40-py3-none-any.whl", hash = "sha256:0bc9d352267c9e48415c5d7bb61ff05c3f193eac2fc7e69cfd229a05fbab67d6", size = 15323870, upload-time = "2026-07-03T00:28:12.56Z" },
]
[[package]]
@@ -497,52 +497,52 @@ wheels = [
[[package]]
name = "cryptography"
version = "50.0.0"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
@@ -586,16 +586,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.16"
version = "5.2.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
]
[[package]]
@@ -650,14 +650,14 @@ wheels = [
[[package]]
name = "django-filter"
version = "26.1"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/563965173d4cbb5fc308087e7b3d11a115b7b67273d093622480b1e31f78/django_filter-26.1.tar.gz", hash = "sha256:66ea04031b068c77c86e1ac26ced7a3f8f13ce797f5795751707e3deefc58054", size = 144299, upload-time = "2026-07-11T09:27:02.767Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/afffed1e3c4540fb75bf550a18b6176a9f6371b5f3e52b69a28995b6480c/django_filter-26.1-py3-none-any.whl", hash = "sha256:7d98ef2899218e6242619b532cb1b95af14e09dfcf74844aecb550ad27b59ff2", size = 94069, upload-time = "2026-07-11T09:27:01.012Z" },
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
]
[[package]]
@@ -775,7 +775,7 @@ wheels = [
[[package]]
name = "drf-spectacular"
version = "0.30.0"
version = "0.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
@@ -785,21 +785,21 @@ dependencies = [
{ name = "pyyaml" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" },
{ url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" },
]
[[package]]
name = "drf-spectacular-sidecar"
version = "2026.7.1"
version = "2026.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/51/9e038d14bf51a0bd051e8bcb690287349c908fa7ba69021d9f3e5d5ac51f/drf_spectacular_sidecar-2026.7.1.tar.gz", hash = "sha256:40113c4066c7bc3ef15a7ce1c40cda227a907a9986748024a813a9e0595eba25", size = 2593211, upload-time = "2026-07-01T13:39:06.084Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/d8/735b129e7d55c4f6c682ecedfcc0438816e1d859e5e1837f914ac4544f6d/drf_spectacular_sidecar-2026.6.1.tar.gz", hash = "sha256:e159874fa85ccee39b801e260f2a3585fbe36a0c79bf811824eef9010ab98ea9", size = 2589761, upload-time = "2026-06-01T16:45:30.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" },
{ url = "https://files.pythonhosted.org/packages/4f/46/10bcaf965edcb70e7647e62b88b9e0de266f8bea7a7a3224e8f977f77eab/drf_spectacular_sidecar-2026.6.1-py3-none-any.whl", hash = "sha256:4560572773c7e5f636d36cd2903204e2c59560af0548da254e311f94458c51a2", size = 2613235, upload-time = "2026-06-01T16:45:29.031Z" },
]
[[package]]
@@ -1005,7 +1005,7 @@ wheels = [
[[package]]
name = "ipython"
version = "9.15.0"
version = "9.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1015,14 +1015,14 @@ dependencies = [
{ name = "matplotlib-inline" },
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "prompt-toolkit" },
{ name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
{ name = "psutil", marker = "sys_platform != 'emscripten'" },
{ name = "pygments" },
{ name = "stack-data" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" },
{ url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" },
]
[[package]]
@@ -1128,7 +1128,7 @@ redis = [
[[package]]
name = "livekit-api"
version = "1.2.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -1137,22 +1137,22 @@ dependencies = [
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz", hash = "sha256:a89817b3bca9584873786ff07209839308217537a42f95ecb2609aafaa109ddc", size = 20778, upload-time = "2026-07-11T23:20:54.781Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/03/00e0ec173f247e1f7ea63cb5591d5680a64c7a74ea4d5d558e5aed6cc399/livekit_api-1.1.1.tar.gz", hash = "sha256:70c7b80eecbc297b40756ebd76e4f52d00b0348fb7d212a21c1f69cc57fd9c83", size = 15196, upload-time = "2026-06-24T01:36:19.686Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e7/8926f16d4bc1b2e0ae46d4a507321bb899396d263a757f1adaabcd3b3867/livekit_api-1.2.0-py3-none-any.whl", hash = "sha256:307f8e5cfb0358c3ca091814ab768af55896022151bcd7f951954ccefa036a24", size = 26499, upload-time = "2026-07-11T23:20:53.736Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/d5f3ff74ab5db2d06f173801ec934885d11a754b9fb9ad768c8ede0a6c89/livekit_api-1.1.1-py3-none-any.whl", hash = "sha256:ce8c327676c366e66cf68782934368dd0ba92b9d48f578275227e255c890fe88", size = 19471, upload-time = "2026-06-24T01:36:18.42Z" },
]
[[package]]
name = "livekit-protocol"
version = "1.1.21"
version = "1.1.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ae/9d60fe37d85623e68a2e36ae31d18c671949db67d2a13a6438410d930466/livekit_protocol-1.1.21.tar.gz", hash = "sha256:8bb1ac1aba5d37d0af43e9d56d129a5d16295cbd91518b00fd157e258f20a6ef", size = 122363, upload-time = "2026-07-21T18:28:26.372Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/88/64f2be01a630e249f1dbd0d51876f109b53b7899ae41246d2ca5b647086d/livekit_protocol-1.1.18.tar.gz", hash = "sha256:187af32ebf75333a62117b0db9e551c99060bd4e1f57cfc0fce73bcd7a671da8", size = 115802, upload-time = "2026-06-27T15:31:04.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/d2/ec10b1cdf912235c2b07898be6ba2535e50f23e8980bb719f29decbd8034/livekit_protocol-1.1.21-py3-none-any.whl", hash = "sha256:ce0bb763327c91349ee8831843c4f8bd72132c4d06ac572171f2ae5c0217211d", size = 149245, upload-time = "2026-07-21T18:28:24.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/80/9cc33e4d0280132538850aaf9559d6b8aa9e670c5917f75dab996400ab84/livekit_protocol-1.1.18-py3-none-any.whl", hash = "sha256:30c539410fd3cfc2e551ca3a193aaaaacaaec6dd57dabe2c9be7c7c7d15f0e01", size = 143134, upload-time = "2026-06-27T15:31:02.686Z" },
]
[[package]]
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.26.0"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1195,7 +1195,6 @@ dependencies = [
{ name = "brevo-python" },
{ name = "brotli" },
{ name = "celery", extra = ["redis"] },
{ name = "cryptography" },
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-configurations" },
@@ -1255,18 +1254,17 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "boto3", specifier = "==1.43.56" },
{ name = "aiohttp", specifier = "==3.14.1" },
{ name = "boto3", specifier = "==1.43.36" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.16" },
{ name = "django", specifier = "==5.2.14" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==9.0.0" },
{ name = "django-filter", specifier = "==26.1" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.27" },
{ name = "django-parler", specifier = "==2.4" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
@@ -1275,17 +1273,17 @@ requires-dist = [
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.17.1" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "drf-spectacular", specifier = "==0.30.0" },
{ name = "drf-spectacular", specifier = "==0.29.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
{ name = "factory-boy", specifier = "==3.3.3" },
{ name = "gunicorn", specifier = "==26.0.0" },
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "livekit-api", specifier = "==1.1.1" },
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.34" },
{ name = "posthog", specifier = "==7.29.0" },
{ name = "phonenumbers", specifier = "==9.0.33" },
{ name = "posthog", specifier = "==7.16.1" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.13.0" },
@@ -1293,7 +1291,7 @@ requires-dist = [
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "sentry-sdk", specifier = "==2.66.1" },
{ name = "sentry-sdk", specifier = "==2.63.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -1301,21 +1299,21 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "django-extensions", specifier = "==4.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.7.1" },
{ name = "drf-spectacular-sidecar", specifier = "==2026.6.1" },
{ name = "freezegun", specifier = "==1.5.5" },
{ name = "ipdb", specifier = "==0.13.13" },
{ name = "ipython", specifier = "==9.15.0" },
{ name = "ipython", specifier = "==9.14.1" },
{ name = "pyfakefs", specifier = "==6.2.0" },
{ name = "pylint", specifier = "<4.0.0" },
{ name = "pylint-django", specifier = "==2.8.0" },
{ name = "pylint-django", specifier = "==2.7.0" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "pytest-cov", specifier = "==7.1.0" },
{ name = "pytest-django", specifier = "==4.12.0" },
{ name = "pytest-icdiff", specifier = "==0.9" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "responses", specifier = "==0.26.2" },
{ name = "ruff", specifier = "==0.16.0" },
{ name = "types-requests", specifier = "==2.33.0.20260712" },
{ name = "responses", specifier = "==0.26.1" },
{ name = "ruff", specifier = "==0.15.19" },
{ name = "types-requests", specifier = "==2.33.0.20260518" },
]
[[package]]
@@ -1452,11 +1450,11 @@ wheels = [
[[package]]
name = "phonenumbers"
version = "9.0.34"
version = "9.0.33"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/c3/e154829a50679c38ae28ec9c4f151f2c425db5e70fd445266e76f6d6cd65/phonenumbers-9.0.34.tar.gz", hash = "sha256:00751c75d1166485ca80ce02ec15b6a61a2628e9b313381579330bc70c934075", size = 2306776, upload-time = "2026-07-03T06:30:37.358Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/37/dfc4cf24169f1a7169ebaedaf896c818f0add8603409d1e748e3085ccdc0/phonenumbers-9.0.33.tar.gz", hash = "sha256:9ab8a02b940b90c64f3866c0b25a30e567ddf7bb9836a3e11efdb0478f65fc1c", size = 2306756, upload-time = "2026-06-22T10:23:33.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/0a/3a7980f3b071dde9a297d85cf6b18ba4bc2e5024e1c453b3da8a1dc14268/phonenumbers-9.0.34-py2.py3-none-any.whl", hash = "sha256:1221bf8e65bd2c02770226488af806d4636814bc997104d3a1f7de6ed6410bd2", size = 2595344, upload-time = "2026-07-03T06:30:33.913Z" },
{ url = "https://files.pythonhosted.org/packages/e6/29/f7e30e3dbd3c7e3d9c4a55006112c04ee62b4765a31f21bcc28c253ac3f1/phonenumbers-9.0.33-py2.py3-none-any.whl", hash = "sha256:ba1d0da52711d5fdda6b2b673b2621fe80774fc5d1b2e5a6ef783396b0343186", size = 2595422, upload-time = "2026-06-22T10:23:29.925Z" },
]
[[package]]
@@ -1541,7 +1539,7 @@ wheels = [
[[package]]
name = "posthog"
version = "7.29.0"
version = "7.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
@@ -1549,9 +1547,9 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/09/43ae0e27bafe031d307921cdeadcd3af8a7370c887177df7c43cfd903adf/posthog-7.29.0.tar.gz", hash = "sha256:673f201e2d204f0664bb0cec86f8adfca97ba3a94488fb7857bb11c4a35fb017", size = 360483, upload-time = "2026-07-23T15:26:28.225Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/4f/a954175c862a3565d02c3f627874d85f18313472a0c4b08f45d84aaf3315/posthog-7.16.1.tar.gz", hash = "sha256:3619d3c619ad01f36c6d465e084950882417c63021eb3cfacacb23f900ec52d4", size = 226343, upload-time = "2026-05-27T18:46:20.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/4a/01c3e9f44a2f167977b5fafc9ba5b4ed5a027881da4b2ee1f4987a621758/posthog-7.29.0-py3-none-any.whl", hash = "sha256:8269afec8439e0177fd9d660b88d0a580497b617ee6aa213850ae9244ad46111", size = 429592, upload-time = "2026-07-23T15:26:26.343Z" },
{ url = "https://files.pythonhosted.org/packages/3e/28/0f840699a1d0db3c1e5483c6208f0804a51f21ccfa34e6aa356161606adc/posthog-7.16.1-py3-none-any.whl", hash = "sha256:fd5aa4510033f3b039fda2fbfce45f493d140d4782f681e69639793dda317d67", size = 264231, upload-time = "2026-05-27T18:46:17.933Z" },
]
[[package]]
@@ -1886,15 +1884,14 @@ wheels = [
[[package]]
name = "pylint-django"
version = "2.8.0"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pylint" },
{ name = "pylint-plugin-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/a1/b92e5d5cf320b603c9bcc5174da7e9ba4c6ce71087354322a2b83536df13/pylint_django-2.8.0.tar.gz", hash = "sha256:42accea9098e4a3298b4bfbae0e4da81f909f8bff0deda9485efbd6035a86d6a", size = 32038, upload-time = "2026-07-11T10:19:14.844Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/4a/3dae8a09e12a28ccf3d7204cc4fb69f28dfb30d2b19567eb5ff094fe1265/pylint_django-2.8.0-py3-none-any.whl", hash = "sha256:706eb2cc8d7692236be9fd033a341042afe3bbbf99df9234a659db931016ef5d", size = 44672, upload-time = "2026-07-11T10:05:29.281Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0d/d775fec0dde8ca5d20e9170a2ca332dfa21b77f7e7e47fc3ab9b2261773c/pylint_django-2.7.0-py3-none-any.whl", hash = "sha256:76ef7e7bbbcf7ee86adbb2beac0ffaa7232509a17bf4a488d81467a1bbaa215b", size = 42892, upload-time = "2026-01-01T11:17:04.292Z" },
]
[[package]]
@@ -2098,16 +2095,16 @@ wheels = [
[[package]]
name = "responses"
version = "0.26.2"
version = "0.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" },
]
[[package]]
@@ -2193,27 +2190,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.0"
version = "0.15.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
{ url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" },
{ url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" },
{ url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" },
{ url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" },
{ url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" },
{ url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" },
{ url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" },
{ url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" },
{ url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" },
{ url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" },
{ url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" },
{ url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" },
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
]
[[package]]
@@ -2230,15 +2227,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.66.1"
version = "2.63.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/c8/b3c970a5b186722d276cd40a05b3254e03bccc0208560aff20f612e018e8/sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216", size = 912449, upload-time = "2026-06-16T12:45:57.553Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" },
]
[[package]]
@@ -2302,14 +2299,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.33.0.20260712"
version = "2.33.0.20260518"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
]
[[package]]
-3
View File
@@ -36,9 +36,6 @@ WORKDIR /home/frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build
# ---- Front-end image ----
-5
View File
@@ -4,11 +4,6 @@ server {
server_tokens off;
root /usr/share/nginx/html;
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+20 -4
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.26.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.26.0",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
@@ -15,13 +15,14 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
@@ -1048,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -4709,6 +4716,15 @@
"node": ">= 0.8"
}
},
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.26.0",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -22,13 +22,14 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
-18
View File
@@ -1,18 +0,0 @@
{
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
-7
View File
@@ -2,7 +2,6 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import type { Track } from 'livekit-client'
type Source = Track.Source
@@ -45,17 +44,11 @@ export interface ApiConfig {
subtitle: {
enabled: boolean
}
diagnostics: {
connection_test_enabled?: boolean
}
telephony: {
enabled: boolean
international_phone_number?: string
default_country?: string
}
resource?: {
default_access_level?: ApiAccessLevel
}
manifest_link?: string
livekit: {
url: string
+7 -60
View File
@@ -1,5 +1,5 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import React, { useLayoutEffect, useMemo } from 'react'
import React from 'react'
const avatar = cva({
base: {
@@ -28,34 +28,13 @@ const avatar = cva({
},
})
// Instantiating a segmenter is expensive; create it once and reuse it.
const graphemeSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: undefined
/**
* Returns the first user-perceived character. Some Unicode characters span
* multiple UTF-16 code units, so a naive index into the string can split them
* and yield a broken glyph.
*/
const getFirstGrapheme = (value: string): string => {
if (!value) return ''
if (graphemeSegmenter) {
const [first] = graphemeSegmenter.segment(value)
return first?.segment ?? ''
}
// Fallback: keeps single code points intact (including surrogate pairs).
return Array.from(value)[0] ?? ''
}
const getInitials = (name?: string): string => {
if (!name) return ''
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const first = getFirstGrapheme(words[0])
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
return (first + second).toLocaleUpperCase()
const first = words[0].charAt(0)
const second = words.length > 1 ? words[1].charAt(0) : ''
return (first + second).toUpperCase()
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -65,37 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initials = useMemo(() => getInitials(name), [name])
const textRef = React.useRef<SVGTextElement>(null)
const [offsetY, setOffsetY] = React.useState(0)
// Optically center the initials: measure the ink bounding box of the
// rendered glyphs and shift them so the box's center sits at the middle
// of the viewBox. Works for any font, weight or glyph shape, unlike a
// hand-tuned dy offset. getBBox() is in local (pre-transform)
// coordinates, so applying the translation never changes the measure.
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const center = () => {
const box = text.getBBox()
// A hidden element measures as an empty box; keep the default then.
if (box.height === 0) return
setOffsetY(50 - (box.y + box.height / 2))
}
center()
// Glyph metrics can change once webfonts finish loading.
let cancelled = false
document.fonts?.ready.then(() => {
if (!cancelled) center()
})
return () => {
cancelled = true
}
}, [initials])
const initials = getInitials(name)
return (
<div
style={{ backgroundColor: bgColor, ...style }}
@@ -108,17 +57,15 @@ export const Avatar = React.memo(
className={css({ width: '100%', height: '100%', display: 'block' })}
>
<text
ref={textRef}
x="50"
y="50"
transform={`translate(0 ${offsetY})`}
textAnchor="middle"
dominantBaseline="central"
fontSize="52"
fontSize={initials.length > 1 ? 48 : 52}
fontWeight="500"
fill="currentColor"
>
{initials}
{initials.toUpperCase()}
</text>
</svg>
</div>
+1 -5
View File
@@ -2,7 +2,6 @@ import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
export const SoundTester = () => {
const { t } = useTranslation('settings')
@@ -16,10 +15,7 @@ export const SoundTester = () => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
console.error(`Error setting sinkId: ${error}`)
}
}
updateActiveId(activeDeviceId)
@@ -1,7 +1,15 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import { type PostHog } from 'posthog-js'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils'
let posthog: PostHog | null = null
const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => {
@@ -30,6 +38,7 @@ export const useAnalytics = ({
flags_api_host,
isDisabled,
}: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser()
useEffect(() => {
@@ -40,13 +49,6 @@ export const useAnalytics = ({
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
})
})
}, [id, host, flags_api_host, isDisabled])
@@ -56,5 +58,12 @@ export const useAnalytics = ({
startAnalyticsSession(user)
}, [user])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
getPosthog().then((ph) => {
ph.capture('$pageview')
})
}, [location])
return null
}
@@ -1,148 +0,0 @@
import { getPosthog } from './utils'
export const captureEvent = (
event: string,
props?: Record<string, unknown>
) => {
void getPosthog()
.then((ph) => {
ph.capture(event, props)
})
.catch(() => {
/* telemetry must never break the app */
})
if (import.meta.env.DEV) {
console.warn(`[telemetry] ${event}`, props)
}
}
export type LogCode =
// media
| 'join_preview_failure'
| 'room_media_failure'
| 'livekit_room_error'
| 'device_switch_failure'
| 'permission_poll_failure'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
| 'effects_processor_failure'
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'generic_failure'
export const reportError = (
logCode: LogCode,
error: unknown,
extraInfo: Record<string, unknown> = {}
): void => {
const e = error instanceof Error ? error : new Error(String(error))
void getPosthog()
.then((ph) => {
ph.captureException(e, {
log_code: logCode,
error_name: e.name,
error_message: e.message,
...extraInfo,
})
})
.catch(() => {})
if (import.meta.env.DEV) {
console.warn(`[${logCode}]`, e, extraInfo)
}
}
export interface DeviceSnapshot {
cam_count: number
mic_count: number
out_count: number
labels_visible: boolean
saved_cam_present: boolean | null
saved_mic_present: boolean | null
saved_video_device_id_set: boolean
saved_audio_device_id_set: boolean
audio_enabled: boolean | null
video_enabled: boolean | null
cam_permission: PermissionState | 'unknown'
mic_permission: PermissionState | 'unknown'
}
/** Reads the persisted LiveKit user choices without importing the store. */
const readPersistedChoices = (): {
videoDeviceId?: string
audioDeviceId?: string
videoEnabled?: boolean
audioEnabled?: boolean
} => {
try {
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
} catch {
return {}
}
}
const queryPermission = async (
name: 'camera' | 'microphone'
): Promise<PermissionState | 'unknown'> => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return 'unknown'
}
}
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
const choices = readPersistedChoices()
let devices: MediaDeviceInfo[] = []
try {
devices = await navigator.mediaDevices.enumerateDevices()
} catch {
/* snapshot stays partial */
}
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
const present = (k: MediaDeviceKind, id?: string) =>
id ? ofKind(k).some((d) => d.deviceId === id) : null
const [cam_permission, mic_permission] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
return {
cam_count: ofKind('videoinput').length,
mic_count: ofKind('audioinput').length,
out_count: ofKind('audiooutput').length,
labels_visible: devices.some((d) => !!d.label),
saved_cam_present: present('videoinput', choices.videoDeviceId),
saved_mic_present: present('audioinput', choices.audioDeviceId),
saved_video_device_id_set: !!choices.videoDeviceId,
saved_audio_device_id_set: !!choices.audioDeviceId,
audio_enabled: choices.audioEnabled ?? null,
video_enabled: choices.videoEnabled ?? null,
cam_permission,
mic_permission,
}
}
export const captureMediaEvent = async (
event:
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success'
| 'device-not-found'
| 'permissions-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
| 'visit-room'
| 'connection-event',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
}
@@ -1,8 +0,0 @@
import type { PostHog } from 'posthog-js'
let posthog: PostHog | null = null
export const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
@@ -1,8 +1,4 @@
import { BackendLanguage } from '@/utils/languages'
import type {
ApiAccessLevel,
RoomConfiguration,
} from '@/features/rooms/api/ApiRoom'
export type ApiUser = {
id: string
@@ -11,6 +7,4 @@ export type ApiUser = {
last_name: string
language: BackendLanguage
timezone: string
default_room_access_level?: ApiAccessLevel | null
default_room_configuration?: RoomConfiguration | null
}
@@ -1,36 +0,0 @@
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import type { ApiError } from '@/api/ApiError'
import { type ApiUser } from './ApiUser'
export type PatchUserParams = {
userId: string
user: Partial<
Pick<
ApiUser,
| 'timezone'
| 'language'
| 'default_room_access_level'
| 'default_room_configuration'
>
>
}
export const patchUser = ({ userId, user }: PatchUserParams) => {
return fetchApi<ApiUser>(`/users/${userId}/`, {
method: 'PATCH',
body: JSON.stringify(user),
})
}
export const patchUserMutationKey = ['patchUser']
export function usePatchUser(
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
) {
return useMutation<ApiUser, ApiError, PatchUserParams>({
mutationKey: patchUserMutationKey,
mutationFn: patchUser,
...options,
})
}
@@ -1,17 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
export type LiveKitConnectionDetails = {
url: string
room: string
token: string
expires_in: number
}
export type ConnectionTestResponse = {
livekit: LiveKitConnectionDetails
}
export const fetchConnectionTestDetails = () =>
fetchApi<ConnectionTestResponse>('/diagnostics/connection/', {
method: 'POST',
})
@@ -1,257 +0,0 @@
import { Checker, Track, type CheckInfo } from 'livekit-client'
/**
* Addresses are useful to a network administrator (they identify the egress IP
* and the SFU endpoint actually reached) but they also land in a downloadable
* report. Flip this to false to keep only the candidate types and protocols.
*/
const INCLUDE_CANDIDATE_ADDRESSES = true
/** Beyond this, the log becomes noise rather than evidence. */
const MAX_LOGGED_PAIRS = 8
export type IceCandidateInfo = {
/** host, srflx, prflx or relay. */
type?: string
/** Transport to the first hop: udp or tcp. */
protocol?: string
/** Transport used by the relay itself (udp, tcp, tls). Local relay only. */
relayProtocol?: string
/** Chrome reports an mDNS `.local` name here for host candidates. */
address?: string
port?: number
/** Local candidates only, and not reported by every browser. */
networkType?: string
}
export type IceCandidatePair = {
/** The pair the browser is actually sending media on. */
selected: boolean
nominated?: boolean
local: IceCandidateInfo
remote: IceCandidateInfo
/** Round trip time in milliseconds. */
rttMs?: number
availableOutgoingBitrate?: number
bytesSent?: number
}
export type IceCandidateReport = {
selected: IceCandidatePair | null
/** Every pair that completed its connectivity checks, selected one first. */
working: IceCandidatePair[]
}
const PROBE_WIDTH = 320
const PROBE_HEIGHT = 180
const PROBE_FPS = 15
const SETTLE_DELAY_MS = 3000
type Stats = Record<string, unknown> & { type?: string }
const readCandidate = (stats?: Stats): IceCandidateInfo => {
if (!stats) return {}
return {
type: stats.candidateType as string | undefined,
protocol: stats.protocol as string | undefined,
relayProtocol: stats.relayProtocol as string | undefined,
networkType: stats.networkType as string | undefined,
...(INCLUDE_CANDIDATE_ADDRESSES
? {
address: stats.address as string | undefined,
port: stats.port as number | undefined,
}
: {}),
}
}
const describeCandidate = (candidate: IceCandidateInfo) => {
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
const endpoint =
candidate.address === undefined
? ''
: ` ${candidate.address}:${candidate.port ?? '?'}`
return `${candidate.type ?? 'unknown'} ${transport}${endpoint}`
}
const parseCandidates = (report: RTCStatsReport): IceCandidateReport => {
let selectedId: string | undefined
report.forEach((stats: Stats) => {
if (stats.type === 'transport' && stats.selectedCandidatePairId) {
selectedId = stats.selectedCandidatePairId as string
}
})
const working: IceCandidatePair[] = []
report.forEach((stats: Stats) => {
// `succeeded` means the pair completed its connectivity checks; failed,
// waiting and in-progress pairs are not evidence of anything working.
if (stats.type !== 'candidate-pair' || stats.state !== 'succeeded') return
const rtt = stats.currentRoundTripTime as number | undefined
working.push({
selected: selectedId !== undefined && stats.id === selectedId,
nominated: stats.nominated as boolean | undefined,
local: readCandidate(report.get(stats.localCandidateId as string)),
remote: readCandidate(report.get(stats.remoteCandidateId as string)),
rttMs: rtt === undefined ? undefined : Math.round(rtt * 1000),
availableOutgoingBitrate: stats.availableOutgoingBitrate as
| number
| undefined,
bytesSent: stats.bytesSent as number | undefined,
})
})
// Firefox does not report transport.selectedCandidatePairId: fall back to the
// nominated pair, then to the one that actually carried bytes.
let selected = working.find((pair) => pair.selected) ?? null
if (!selected) {
selected =
working.find((pair) => pair.nominated) ??
working
.slice()
.sort((a, b) => (b.bytesSent ?? 0) - (a.bytesSent ?? 0))[0] ??
null
if (selected) selected.selected = true
}
working.sort((a, b) => Number(b.selected) - Number(a.selected))
return { selected, working }
}
/**
* A synthetic track avoids asking for camera or microphone permission: this
* check must work for someone who denied both.
*/
const createProbeTrack = () => {
const canvas = document.createElement('canvas')
canvas.width = PROBE_WIDTH
canvas.height = PROBE_HEIGHT
const context = canvas.getContext('2d')
if (!context) throw new Error('Could not get canvas context')
let frame = 0
let rafId = 0
const draw = () => {
frame = (frame + 4) % 360
context.fillStyle = `hsl(${frame}, 100%, 50%)`
context.fillRect(0, 0, canvas.width, canvas.height)
rafId = requestAnimationFrame(draw)
}
draw()
const track = canvas.captureStream(PROBE_FPS).getVideoTracks()[0]
return {
track,
stop: () => {
cancelAnimationFrame(rafId)
track.stop()
},
}
}
export class SelectedCandidateCheck extends Checker {
private result: IceCandidateReport | null = null
get description() {
const selected = this.result?.selected
if (!selected) return 'Selected ICE candidate pair'
const transport =
selected.local.relayProtocol ?? selected.local.protocol ?? 'unknown'
const rtt =
selected.rttMs === undefined ? '' : ` · RTT ${selected.rttMs} ms`
return `${selected.local.type ?? 'unknown'} over ${transport}${rtt}`
}
protected async perform() {
await this.connect()
const probe = createProbeTrack()
try {
let publication
try {
publication = await this.room.localParticipant.publishTrack(
probe.track,
{
// The token restricts `can_publish_sources`, so a raw
// MediaStreamTrack published as `unknown` is rejected server side.
source: Track.Source.Camera,
simulcast: false,
videoEncoding: { maxBitrate: 300_000, maxFramerate: PROBE_FPS },
}
)
} catch (error) {
// A server-side grant problem is not a diagnosis of the user's network.
this.appendWarning(
`Could not publish the probe track: ${
error instanceof Error ? error.message : 'unknown error'
}`
)
this.skip()
return
}
// ICE keeps promoting pairs for a moment after the track goes up.
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))
// Stats come from the publisher peer connection: in an empty test room
// there is no subscriber transport to inspect.
const report = await publication.track?.getRTCStatsReport()
this.result = report ? parseCandidates(report) : null
} finally {
probe.stop()
}
const selected = this.result?.selected
const working = this.result?.working ?? []
if (!selected) {
this.appendWarning('No working candidate pair reported by the browser')
return
}
this.appendMessage(`selected: ${describeCandidate(selected.local)}`)
this.appendMessage(`server: ${describeCandidate(selected.remote)}`)
if (selected.rttMs !== undefined) {
this.appendMessage(`round trip time: ${selected.rttMs} ms`)
}
this.appendMessage(`working candidate pairs: ${working.length}`)
for (const pair of working.slice(0, MAX_LOGGED_PAIRS)) {
const rtt = pair.rttMs === undefined ? '' : ` · ${pair.rttMs} ms`
this.appendMessage(
`${pair.selected ? '→' : ' '} ${describeCandidate(pair.local)}${describeCandidate(pair.remote)}${rtt}`
)
}
if (working.length > MAX_LOGGED_PAIRS) {
this.appendMessage(
`… and ${working.length - MAX_LOGGED_PAIRS} more, see the report`
)
}
if (selected.local.type === 'relay') {
this.appendWarning(
'Media is relayed through TURN. Direct connections are likely blocked by a firewall.'
)
}
if ((selected.local.relayProtocol ?? selected.local.protocol) !== 'udp') {
this.appendWarning(
'Media is not using UDP, which usually means degraded quality under load.'
)
}
}
getInfo(): CheckInfo {
const info = super.getInfo()
info.data = this.result ?? undefined
return info
}
}
@@ -1,186 +0,0 @@
import { useTranslation } from 'react-i18next'
import {
Disclosure,
DisclosurePanel,
Heading,
Button as RACButton,
} from 'react-aria-components'
import { RiArrowDownSFill } from '@remixicon/react'
import { css, cx } from '@/styled-system/css'
import type { ConnectionTestStepResult } from '../types'
import { StepStatusIndicator } from './StepStatusIndicator'
/** Each step is its own bounded card, collapsed or not. */
const cardClass = css({
border: '1px solid {colors.greyscale.900}',
borderRadius: '5px',
backgroundColor: 'white',
overflow: 'hidden',
})
/**
* Fixed columns so the status labels line up across every row, whether or not
* the row is expandable.
*/
const rowClass = css({
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 7rem 1.5rem',
alignItems: 'center',
gap: '1rem',
width: '100%',
paddingX: '1rem',
paddingY: '0.75rem',
textAlign: 'left',
})
const identityClass = css({
display: 'flex',
flexDirection: 'column',
gap: '0.125rem',
minWidth: 0,
})
const triggerClass = css({
cursor: 'pointer',
transition: 'background-color 120ms',
_hover: { backgroundColor: 'greyscale.50' },
'&[data-focus-visible]': {
outline: '2px solid {colors.focusRing}',
outlineOffset: '-2px',
},
})
/** Expanded headers stay tinted so the open card reads as one block. */
const triggerExpandedClass = css({
backgroundColor: 'greyscale.100',
_hover: { backgroundColor: 'greyscale.100' },
})
const labelClass = css({
textStyle: 'body',
color: 'greyscale.1000',
fontWeight: 'medium',
})
const valueClass = css({
fontFamily: 'mono',
textStyle: 'xs',
color: 'greyscale.500',
overflowWrap: 'anywhere',
})
const chevronClass = css({
color: 'primary.800',
justifySelf: 'end',
transition: 'transform 150ms',
})
const chevronExpandedClass = css({ transform: 'rotate(180deg)' })
const headingResetClass = css({
margin: 0,
fontSize: 'inherit',
fontWeight: 'inherit',
})
const panelClass = css({
backgroundColor: 'white',
})
const logListClass = css({
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})
const logItemClass = css({
fontFamily: 'mono',
textStyle: 'xs',
color: 'greyscale.700',
overflowWrap: 'anywhere',
})
const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
const { t } = useTranslation('connectionTest')
return (
<>
<span className={identityClass}>
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
{step.summary && <span className={valueClass}>{step.summary}</span>}
</span>
<StepStatusIndicator
status={step.status}
label={t(`status.${step.status}`)}
/>
</>
)
}
export const ConnectionTestStepRow = ({
step,
}: {
step: ConnectionTestStepResult
}) => {
const { t } = useTranslation('connectionTest')
const isSettled = step.status !== 'pending' && step.status !== 'running'
const hasLogs = isSettled && Boolean(step.logs?.length)
if (!hasLogs) {
return (
<div className={cx(cardClass, rowClass)}>
<StepRowContent step={step} />
{/* Empty chevron column keeps non-expandable rows aligned. */}
<span />
</div>
)
}
return (
<Disclosure className={cardClass}>
{({ isExpanded }) => (
<>
<Heading level={3} className={headingResetClass}>
<RACButton
slot="trigger"
className={cx(
rowClass,
triggerClass,
isExpanded ? triggerExpandedClass : undefined
)}
>
<StepRowContent step={step} />
<RiArrowDownSFill
aria-hidden="true"
className={cx(
chevronClass,
isExpanded ? chevronExpandedClass : undefined
)}
/>
</RACButton>
</Heading>
<DisclosurePanel
className={panelClass}
aria-label={t('detailsFor', { step: t(`steps.${step.id}`) })}
style={{ padding: isExpanded ? '0.75rem 1rem' : '0 1rem' }}
>
{/* Collapsed panels stay in the DOM for aria-controls, but the log
lines themselves are only mounted when actually visible. */}
{isExpanded && (
<ul className={logListClass}>
{step.logs?.map((log, index) => (
<li key={`${log.level}-${index}`} className={logItemClass}>
{log.message}
</li>
))}
</ul>
)}
</DisclosurePanel>
</>
)}
</Disclosure>
)
}
@@ -1,257 +0,0 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ProgressBar } from 'react-aria-components'
import { css, cx } from '@/styled-system/css'
import type { ConnectionTestStats } from '../types'
import { statusSquareClass } from './stepAppearance'
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
/** Only a failure earns a colour: everything else stays near-black. */
const stateColorClass: Record<SummaryState, string> = {
idle: css({ color: 'greyscale.1000' }),
running: css({ color: 'greyscale.1000' }),
passed: css({ color: 'greyscale.1000' }),
partial: css({ color: 'greyscale.1000' }),
failed: css({ color: 'danger.600' }),
}
const cardClass = css({
width: '100%',
borderRadius: '5px',
border: '1px solid {colors.greyscale.900}',
backgroundColor: 'white',
padding: { base: '1.25rem', xsm: '1.75rem' },
display: 'flex',
flexDirection: 'column',
// Blocks are spaced here; everything inside a block stays tight.
gap: '1.5rem',
})
const headerClass = css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})
const eyebrowClass = css({
textStyle: 'sm',
fontWeight: 'medium',
color: 'greyscale.600',
margin: 0,
})
const headlineClass = css({
// Sized for the longest state string ("N vérifications en échec"), not for
// the shortest one.
fontSize: { base: '28', xsm: '40' },
lineHeight: '1.1',
fontWeight: 'bold',
letterSpacing: '-0.02em',
textWrap: 'balance',
margin: 0,
})
const hintClass = css({
textStyle: 'sm',
color: 'greyscale.600',
margin: 0,
maxWidth: '34rem',
})
const dividerClass = css({
// Lighter than the card border: an inner rule should never compete with it.
borderTop: '1px solid {colors.greyscale.100}',
paddingTop: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.875rem',
})
const progressRowClass = css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
})
const trackClass = css({
height: '0.375rem',
width: '100%',
borderRadius: 'full',
backgroundColor: 'greyscale.200',
overflow: 'hidden',
})
const fillClass = css({
height: '100%',
borderRadius: 'full',
backgroundColor: 'primary.800',
transition: 'width 200ms ease-out',
})
const progressValueClass = css({
textStyle: 'sm',
fontVariantNumeric: 'tabular-nums',
color: 'greyscale.700',
whiteSpace: 'nowrap',
// Reserved width so the bar does not resize when the digits change.
minWidth: '3rem',
textAlign: 'right',
})
const countersClass = css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem 1.5rem',
})
const counterClass = css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
textStyle: 'sm',
color: 'greyscale.600',
})
const counterSquareClass = css({
width: '0.5rem',
height: '0.5rem',
borderRadius: '2px',
flexShrink: 0,
})
const counterValueClass = css({
fontWeight: 'medium',
fontVariantNumeric: 'tabular-nums',
color: 'greyscale.1000',
})
/** A zero count is context, not a result: it recedes instead of shouting. */
const emptyCounterClass = css({ color: 'greyscale.400' })
const emptySquareClass = css({
backgroundColor: 'transparent!',
border: '1px solid {colors.greyscale.250}',
})
const actionsClass = css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
})
const Counter = ({
squareClass,
value,
label,
}: {
squareClass: string
value: number
label: string
}) => {
const isEmpty = value === 0
return (
<span className={cx(counterClass, isEmpty ? emptyCounterClass : undefined)}>
<span
aria-hidden="true"
className={cx(
counterSquareClass,
squareClass,
isEmpty ? emptySquareClass : undefined
)}
/>
<span
className={cx(
counterValueClass,
isEmpty ? emptyCounterClass : undefined
)}
>
{value}
</span>
{label}
</span>
)
}
export const ConnectionTestSummary = ({
stats,
isRunning,
children,
}: {
stats: ConnectionTestStats
isRunning: boolean
children?: ReactNode
}) => {
const { t } = useTranslation('connectionTest')
const state: SummaryState = isRunning
? 'running'
: !stats.hasStarted
? 'idle'
: stats.failed > 0
? 'failed'
: stats.skipped > 0
? 'partial'
: 'passed'
return (
<section className={cardClass}>
<div className={headerClass}>
<h1 className={eyebrowClass}>{t('title')}</h1>
{/* Announced once per state change rather than on every step update. */}
<p className={cx(headlineClass, stateColorClass[state])} role="status">
{state === 'failed'
? t('summary.failed', { count: stats.failed })
: t(`summary.${state}`)}
</p>
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
</div>
{stats.hasStarted && (
<div className={dividerClass}>
<div className={progressRowClass}>
<ProgressBar
aria-label={t('progressLabel')}
value={stats.progress}
className={css({ flex: 1 })}
>
{({ percentage }) => (
<div className={trackClass}>
<div
className={fillClass}
style={{ width: `${percentage ?? 0}%` }}
/>
</div>
)}
</ProgressBar>
<span className={progressValueClass}>
{t('progress', { done: stats.settled, total: stats.total })}
</span>
</div>
<div className={countersClass}>
<Counter
squareClass={statusSquareClass.success}
value={stats.passed}
label={t('counts.passed')}
/>
<Counter
squareClass={statusSquareClass.skipped}
value={stats.skipped}
label={t('counts.skipped')}
/>
<Counter
squareClass={statusSquareClass.failed}
value={stats.failed}
label={t('counts.failed')}
/>
</div>
</div>
)}
{children && <div className={actionsClass}>{children}</div>}
</section>
)
}
@@ -1,40 +0,0 @@
import { css, cx } from '@/styled-system/css'
import type { ConnectionTestStepStatus } from '../types'
import { statusSquareClass, statusTextClass } from './stepAppearance'
const wrapperClass = css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
textStyle: 'sm',
whiteSpace: 'nowrap',
})
const squareClass = css({
width: '0.625rem',
height: '0.625rem',
borderRadius: '2px',
flexShrink: 0,
})
/**
* Status is carried by the label; the square is decorative so the meaning does
* not depend on colour alone.
*/
export const StepStatusIndicator = ({
status,
label,
className,
}: {
status: ConnectionTestStepStatus
label: string
className?: string
}) => (
<span className={cx(wrapperClass, className)}>
<span
aria-hidden="true"
className={cx(squareClass, statusSquareClass[status])}
/>
<span className={statusTextClass[status]}>{label}</span>
</span>
)
@@ -1,29 +0,0 @@
import { css } from '@/styled-system/css'
import type { ConnectionTestStepStatus } from '../types'
/**
* Panda extracts styles statically, so every status needs its own literal
* `css()` call: `css({ backgroundColor: someVariable })` would emit nothing.
*/
export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
pending: css({
backgroundColor: 'transparent',
border: '1px solid {colors.greyscale.300}',
}),
running: css({
backgroundColor: 'primary.800',
animation: 'pulse_background 1.2s ease-in-out infinite',
}),
success: css({ backgroundColor: 'success.600' }),
failed: css({ backgroundColor: 'danger.600' }),
skipped: css({ backgroundColor: 'greyscale.300' }),
}
/** Colour is carried by the square; the label stays near-black except on failure. */
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
pending: css({ color: 'greyscale.500' }),
running: css({ color: 'greyscale.700' }),
success: css({ color: 'greyscale.1000' }),
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
skipped: css({ color: 'greyscale.500' }),
}
@@ -1,298 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
CheckStatus,
ConnectionCheck,
createLocalAudioTrack,
createLocalVideoTrack,
getBrowser,
type CheckInfo,
} from 'livekit-client'
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
import {
createInitialSteps,
type ConnectionTestLog,
type ConnectionTestStepId,
type ConnectionTestStepResult,
type ConnectionTestStepStatus,
} from '../types'
import { openPermissionsDialog } from '@/stores/permissions'
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
'websocket',
'webrtc',
'turn',
'reconnect',
'selectedCandidate',
'publishAudio',
'publishVideo',
]
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
[CheckStatus.IDLE]: 'pending',
[CheckStatus.RUNNING]: 'running',
[CheckStatus.SUCCESS]: 'success',
[CheckStatus.FAILED]: 'failed',
[CheckStatus.SKIPPED]: 'skipped',
}
/** getUserMedia rejections that mean "the user said no", not "the device is broken". */
const PERMISSION_ERROR_NAMES = new Set([
'NotAllowedError',
'PermissionDeniedError',
'SecurityError',
])
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
error instanceof Error ? error.message : fallback
const isPermissionError = (error: unknown) =>
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
logs: info.logs,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
const grouped: Record<string, string[]> = {
audioinput: [],
audiooutput: [],
videoinput: [],
}
for (const device of devices) {
// Browsers are free to report kinds we don't know about yet.
const bucket = (grouped[device.kind] ??= [])
bucket.push(device.label || device.deviceId)
}
return grouped
}
/**
* Outcome of a single step. `aborted` is deliberately distinct from `failed`:
* a cancelled run must not be reported to the user as a broken device.
*/
type StepOutcome =
| { state: 'success' }
| { state: 'failed'; error: unknown }
| { state: 'aborted' }
const ABORTED: StepOutcome = { state: 'aborted' }
export const useConnectionTestRunner = () => {
const [steps, setSteps] = useState(createInitialSteps)
const [isRunning, setIsRunning] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const updateStep = useCallback(
(id: ConnectionTestStepId, patch: Partial<ConnectionTestStepResult>) => {
setSteps((current) =>
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
)
},
[]
)
const skipSteps = useCallback(
(
ids: ConnectionTestStepId[],
summary: string,
logs?: ConnectionTestLog[]
) => {
// One state update for the whole batch instead of one per step.
const targets = new Set(ids)
setSteps((current) =>
current.map((step) =>
targets.has(step.id)
? { ...step, status: 'skipped', summary, logs }
: step
)
)
},
[]
)
const runStep = useCallback(
async (
id: ConnectionTestStepId,
signal: AbortSignal,
fn: () => Promise<Partial<ConnectionTestStepResult>>
): Promise<StepOutcome> => {
if (signal.aborted) return ABORTED
updateStep(id, {
status: 'running',
summary: undefined,
logs: undefined,
data: undefined,
})
try {
const result = await fn()
if (signal.aborted) return ABORTED
// `result.status` overrides when set (LiveKit checks map their own status)
updateStep(id, { status: 'success', ...result })
return { state: 'success' }
} catch (error) {
if (signal.aborted) return ABORTED
updateStep(id, {
status: 'failed',
summary: getErrorMessage(error),
})
return { state: 'failed', error }
}
},
[updateStep]
)
const runTest = useCallback(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const { signal } = controller
setIsRunning(true)
setSteps(createInitialSteps())
try {
await runStep('browser', signal, async () => {
const browser = getBrowser()
if (!browser) throw new Error('Browser not detected')
return {
summary: `${browser.name} ${browser.version}`,
data: {
name: browser.name,
version: browser.version,
os: browser.os,
osVersion: browser.osVersion,
},
}
})
if (signal.aborted) return
const microphone = await runStep('microphone', signal, async () => {
const track = await createLocalAudioTrack()
const label =
track.mediaStreamTrack.label ||
track.mediaStreamTrack.getSettings().deviceId ||
''
track.stop()
return { summary: label, data: { label } }
})
if (signal.aborted) return
if (
microphone.state === 'failed' &&
isPermissionError(microphone.error)
) {
openPermissionsDialog('audioinput')
}
const camera = await runStep('camera', signal, async () => {
const track = await createLocalVideoTrack()
const settings = track.mediaStreamTrack.getSettings()
const label = track.mediaStreamTrack.label || ''
// Released immediately, like the microphone probe: the capture
// indicator must not stay on between this check and publishVideo.
track.stop()
return {
summary: label,
data: {
label,
width: settings.width,
height: settings.height,
},
}
})
if (signal.aborted) return
if (camera.state === 'failed' && isPermissionError(camera.error)) {
openPermissionsDialog('videoinput')
}
await runStep('devices', signal, async () => {
const devices = await navigator.mediaDevices.enumerateDevices()
return {
summary: String(devices.length),
data: groupDevicesByKind(devices),
}
})
if (signal.aborted) return
let checker: ConnectionCheck
try {
const { livekit } = await fetchConnectionTestDetails()
if (signal.aborted) return
checker = new ConnectionCheck(livekit.url, livekit.token)
} catch (error) {
if (signal.aborted) return
skipSteps(
LIVEKIT_STEP_IDS,
getErrorMessage(error, 'Failed to fetch test token')
)
return
}
// LiveKit's ConnectionCheck exposes no cancellation: each check owns its
// own room and disconnects it when it settles. The best we can do is
// never start the next one once the run has been aborted (runStep
// short-circuits on `signal.aborted`).
await runStep('websocket', signal, async () =>
fromCheckInfo(await checker.checkWebsocket())
)
await runStep('webrtc', signal, async () =>
fromCheckInfo(await checker.checkWebRTC())
)
await runStep('turn', signal, async () =>
fromCheckInfo(await checker.checkTURN())
)
await runStep('reconnect', signal, async () =>
fromCheckInfo(await checker.checkReconnect())
)
await runStep('selectedCandidate', signal, async () =>
fromCheckInfo(await checker.createAndRunCheck(SelectedCandidateCheck))
)
if (microphone.state !== 'success') {
skipSteps(['publishAudio'], 'Microphone permission required')
} else {
await runStep('publishAudio', signal, async () =>
fromCheckInfo(await checker.checkPublishAudio())
)
}
if (camera.state !== 'success') {
skipSteps(['publishVideo'], 'Camera permission required')
} else {
await runStep('publishVideo', signal, async () =>
fromCheckInfo(await checker.checkPublishVideo())
)
}
} finally {
if (!signal.aborted) {
setIsRunning(false)
}
}
}, [runStep, skipSteps])
const reset = useCallback(() => {
abortRef.current?.abort()
setSteps(createInitialSteps())
setIsRunning(false)
}, [])
// Leaving the page mid-run must stop the pending checks rather than let them
// keep a LiveKit session open behind an unmounted component.
useEffect(
() => () => {
abortRef.current?.abort()
},
[]
)
return {
steps,
isRunning,
runTest,
reset,
}
}
@@ -1,167 +0,0 @@
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiDownload2Line,
RiErrorWarningLine,
RiPlayLine,
} from '@remixicon/react'
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { Center, VStack } from '@/styled-system/jsx'
import { Permissions } from '@/features/rooms/components/Permissions'
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
import { useConfig } from '@/api/useConfig'
import { navigateTo } from '@/navigation/navigateTo'
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
const sectionClass = css({
width: '100%',
borderTop: '2px solid {colors.greyscale.900}',
paddingTop: '1rem',
})
const sectionTitleClass = css({
textStyle: 'h2',
color: 'greyscale.1000',
margin: 0,
})
const rowsClass = css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginTop: '0.75rem',
})
const helpClass = css({
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
width: '100%',
borderRadius: 8,
border: '1px solid {colors.greyscale.200}',
backgroundColor: 'white',
padding: '0.75rem 1rem',
textStyle: 'sm',
color: 'greyscale.800',
})
const helpIconClass = css({
color: 'danger.600',
flexShrink: 0,
marginTop: '2px',
})
const ConnectionTest = () => {
const { data, isLoading } = useConfig()
const { t } = useTranslation('connectionTest')
const { steps, isRunning, runTest, reset } = useConnectionTestRunner()
const stats = useMemo(() => summarizeSteps(steps), [steps])
const stepsById = useMemo(
() => new Map(steps.map((step) => [step.id, step] as const)),
[steps]
)
const isPublishVideoRunning =
stepsById.get('publishVideo')?.status === 'running'
// LiveKit appends a bare <video> to document.body during publishVideo.
// Keep it in the DOM (so the frame check still works) but hide it visually.
useEffect(() => {
document.body.classList.toggle(
HIDE_LIVEKIT_VIDEO_CLASS,
isPublishVideoRunning
)
return () => {
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
}
}, [isPublishVideoRunning])
useEffect(() => {
// Wait for config to load, otherwise we'd redirect off a page
// that's actually enabled.
if (!isLoading && !data?.diagnostics?.connection_test_enabled) {
navigateTo('home', undefined, { replace: true })
}
}, [isLoading, data])
return (
<Screen layout="centered">
<Permissions />
<CenteredContent withBackButton>
<Center>
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
<ConnectionTestSummary stats={stats} isRunning={isRunning}>
{isRunning ? (
// A disabled "run" button while the test runs is dead weight:
// cancelling is the only thing left to do.
<Button
variant="secondary"
onPress={reset}
icon={<RiCloseLine size={18} aria-hidden="true" />}
>
{t('cancel')}
</Button>
) : (
<Button
variant="primary"
onPress={runTest}
icon={<RiPlayLine size={18} aria-hidden="true" />}
>
{stats.hasStarted ? t('runAgain') : t('runTest')}
</Button>
)}
{stats.hasStarted && !isRunning && (
<Button
variant="secondary"
onPress={() => downloadConnectionTestReport(steps)}
icon={<RiDownload2Line size={18} aria-hidden="true" />}
>
{t('downloadReport')}
</Button>
)}
</ConnectionTestSummary>
{stats.hasStarted &&
CONNECTION_TEST_GROUPS.map((group) => (
<section key={group.id} className={sectionClass}>
<h2 className={sectionTitleClass}>
{t(`groups.${group.id}`)}
</h2>
<div className={rowsClass}>
{group.steps.map((id) => {
const step = stepsById.get(id)
return step ? (
<ConnectionTestStepRow key={id} step={step} />
) : null
})}
</div>
</section>
))}
{stats.failed > 0 && !isRunning && (
<p className={helpClass}>
<RiErrorWarningLine
size={18}
aria-hidden="true"
className={helpIconClass}
/>
{t('help.firewall')}
</p>
)}
</VStack>
</Center>
</CenteredContent>
</Screen>
)
}
export default ConnectionTest
@@ -1,103 +0,0 @@
export type ConnectionTestStepId =
| 'browser'
| 'microphone'
| 'camera'
| 'devices'
| 'websocket'
| 'webrtc'
| 'turn'
| 'reconnect'
| 'selectedCandidate'
| 'publishAudio'
| 'publishVideo'
export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'skipped'
export type ConnectionTestLog = {
level: 'info' | 'warning' | 'error'
message: string
}
export type ConnectionTestStepResult = {
id: ConnectionTestStepId
status: ConnectionTestStepStatus
summary?: string
logs?: ConnectionTestLog[]
data?: Record<string, unknown>
}
export type ConnectionTestGroupId = 'local' | 'network'
/** Display order: everything local first, then everything that leaves the machine. */
export const CONNECTION_TEST_GROUPS: ReadonlyArray<{
id: ConnectionTestGroupId
steps: ReadonlyArray<ConnectionTestStepId>
}> = [
{ id: 'local', steps: ['browser', 'microphone', 'camera', 'devices'] },
{
id: 'network',
steps: [
'websocket',
'webrtc',
'turn',
'reconnect',
'selectedCandidate',
'publishAudio',
'publishVideo',
],
},
]
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] =
CONNECTION_TEST_GROUPS.flatMap((group) => [...group.steps])
export const createInitialSteps = (): ConnectionTestStepResult[] =>
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
export type ConnectionTestStats = {
total: number
settled: number
passed: number
failed: number
skipped: number
hasStarted: boolean
progress: number
}
/**
* Single pass over the steps: the page needs half a dozen derived booleans and
* counters, and scanning the array once per render beats one `.some()` per flag.
*/
export const summarizeSteps = (
steps: ConnectionTestStepResult[]
): ConnectionTestStats => {
let passed = 0
let failed = 0
let skipped = 0
let pending = 0
for (const step of steps) {
if (step.status === 'success') passed += 1
else if (step.status === 'failed') failed += 1
else if (step.status === 'skipped') skipped += 1
else if (step.status === 'pending') pending += 1
}
const total = steps.length
const settled = passed + failed + skipped
return {
total,
settled,
passed,
failed,
skipped,
hasStarted: pending < total,
progress: total === 0 ? 0 : Math.round((settled / total) * 100),
}
}
@@ -1,53 +0,0 @@
import type { ConnectionTestStepResult } from '../types'
export type ConnectionTestReport = {
generatedAt: string
userAgent: string
steps: Record<
string,
{
status: ConnectionTestStepResult['status']
summary?: string
logs?: ConnectionTestStepResult['logs']
data?: ConnectionTestStepResult['data']
}
>
}
export const buildConnectionTestReport = (
steps: ConnectionTestStepResult[]
): ConnectionTestReport => ({
generatedAt: new Date().toISOString(),
userAgent: navigator.userAgent,
steps: Object.fromEntries(
steps.map(({ id, status, summary, logs, data }) => [
id,
{
status,
...(summary !== undefined ? { summary } : {}),
...(logs?.length ? { logs } : {}),
...(data !== undefined ? { data } : {}),
},
])
),
})
export const downloadConnectionTestReport = (
steps: ConnectionTestStepResult[]
) => {
const report = buildConnectionTestReport(steps)
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
const blob = new Blob([JSON.stringify(report, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `connection-test-${timestamp}.json`
// Firefox only follows the click when the anchor is in the document, and
// revoking the URL in the same tick cancels the download in some browsers.
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
@@ -15,7 +15,6 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -161,9 +160,7 @@ const Home = () => {
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
reportError('generic_failure', error, {
context: 'Site is not reachable:',
})
console.error('Site is not reachable:', error)
}
}
}
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useCallback, useEffect, useLayoutEffect } from 'react'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
type CarouselOrientation = 'vertical' | 'horizontal'
interface CarouselLayoutState {
orientation: CarouselOrientation
maxVisibleTiles: number
}
interface CarouselLayoutObserverProps {
asideEl: React.RefObject<HTMLDivElement>
orientation?: CarouselOrientation
onLayoutChange: (layout: CarouselLayoutState) => void
}
const CarouselLayoutObserver = ({
orientation,
asideEl,
onLayoutChange,
}: CarouselLayoutObserverProps) => {
const { width, height } = useSize(asideEl)
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
// container size hovers around a breakpoint. A ref (not state) because
// updating it must not trigger a re-render.
const prevTilesRef = React.useRef(0)
const carouselOrientation: CarouselOrientation =
orientation ?? (height >= width ? 'vertical' : 'horizontal')
const tileSpan =
carouselOrientation === 'vertical'
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
const scrollBarWidth = getScrollBarWidth()
const availableSpan =
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
let maxVisibleTiles: number
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
// Within the dead zone: keep the previous count.
maxVisibleTiles = Math.round(prevTilesRef.current)
} else {
maxVisibleTiles = Math.round(tilesThatFit)
prevTilesRef.current = tilesThatFit
}
// Apply cosmetic layout output straight to the DOM.
useLayoutEffect(() => {
const el = asideEl.current
if (!el) return
el.dataset.lkOrientation = carouselOrientation
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
}, [asideEl, carouselOrientation, maxVisibleTiles])
// Report upward only what the parent actually needs for
// `useVisualStableUpdate` (and only when it changes — see parent handler).
useEffect(() => {
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
return null
}
/** @public */
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
@@ -40,52 +107,42 @@ export function CarouselLayout({
...props
}: CarouselLayoutProps) {
const asideEl = React.useRef<HTMLDivElement>(null)
const [prevTiles, setPrevTiles] = React.useState(0)
const { width, height } = useSize(asideEl)
const carouselOrientation = orientation
? orientation
: height >= width
? 'vertical'
: 'horizontal'
const tileSpan =
carouselOrientation === 'vertical'
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
const scrollBarWidth = getScrollBarWidth()
const [layout, setLayout] = React.useState<CarouselLayoutState>({
orientation: orientation ?? 'vertical',
maxVisibleTiles: MIN_VISIBLE_TILES,
})
const tilesThatFit =
carouselOrientation === 'vertical'
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
// Stable callback + identity check: the parent only re-renders when the
// derived layout genuinely changed, not on every resize tick.
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
setLayout((prev) =>
prev.orientation === next.orientation &&
prev.maxVisibleTiles === next.maxVisibleTiles
? prev
: next
)
}, [])
let maxVisibleTiles = Math.round(tilesThatFit)
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
maxVisibleTiles = Math.round(prevTiles)
} else if (prevTiles !== tilesThatFit) {
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
React.useLayoutEffect(() => {
if (asideEl.current) {
asideEl.current.dataset.lkOrientation = carouselOrientation
asideEl.current.style.setProperty(
'--lk-max-visible-tiles',
maxVisibleTiles.toString()
)
}
}, [maxVisibleTiles, carouselOrientation])
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
return (
<aside
key={carouselOrientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
<>
<CarouselLayoutObserver
asideEl={asideEl}
orientation={orientation}
onLayoutChange={handleLayoutChange}
/>
{/* `key` intentionally remounts the container when orientation flips, */}
{/* which resets scroll position and re-runs the observer measurement. */}
<aside
key={layout.orientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
</>
)
}
@@ -10,8 +10,37 @@ import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
import { useEffect, useRef, useState } from 'react'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
interface GridLayoutObserverProps {
gridEl: React.RefObject<HTMLDivElement>
trackCount: number
onMaxTilesChange: (maxTiles: number) => void
}
/**
* Headless component that runs the layout calculation in isolation and
* reports the resulting tile capacity upward.
*
* `useGridLayout` re-renders its host on every layout recalculation
* (e.g. container resizes). Rendering it in a null child means only this
* component churns; the parent `GridLayout` re-renders solely when
* `maxTiles` actually changes, since `setState` bails out on equal values.
*/
const GridLayoutObserver = ({
gridEl,
trackCount,
onMaxTilesChange,
}: GridLayoutObserverProps) => {
const { layout } = useGridLayout(gridEl, trackCount)
useEffect(() => {
onMaxTilesChange(layout.maxTiles)
}, [onMaxTilesChange, layout.maxTiles])
return null
}
/** @public */
export interface GridLayoutProps
extends
@@ -38,15 +67,14 @@ export interface GridLayoutProps
* @public
*/
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = React.createRef<HTMLDivElement>()
const gridEl = useRef<HTMLDivElement>(null)
const [maxTiles, setMaxTiles] = useState(1)
const elementProps = React.useMemo(
() => mergeProps(props, { className: 'lk-grid-layout' }),
[props]
)
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
const pagination = usePagination(maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSwipe(gridEl, {
@@ -60,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
data-lk-pagination={pagination.totalPageCount > 1}
{...elementProps}
>
<GridLayoutObserver
gridEl={gridEl}
trackCount={tracks.length}
onMaxTilesChange={setMaxTiles}
/>
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && (
{tracks.length > maxTiles && (
<>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
@@ -4,7 +4,6 @@ import { NotificationDuration } from './NotificationDuration'
import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording'
import { reportError } from '@/features/analytics/telemetry'
export const notifyAutoMutedOnJoin = () => {
toastQueue.add(
@@ -56,9 +55,7 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload
} catch (error) {
// Handle errors appropriately for your application
reportError('generic_failure', error, {
context: 'Failed to decode notification payload:',
})
console.error('Failed to decode notification payload:', error)
return
}
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('generic_failure', error, {
context: 'An error occurred while lowering hands :',
})
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.', {
cause: error,
})
@@ -1,7 +1,6 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
import { reportError } from '@/features/analytics/telemetry'
export const useParticipantRole = () => {
const data = useRoomData()
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
}),
})
} catch (error) {
reportError(
'generic_failure',
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -10,7 +10,6 @@ import {
} from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
@@ -88,7 +87,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting()
} catch (e) {
reportError('generic_failure', e)
console.error(e)
setListEnabled(true)
}
}
@@ -7,9 +7,7 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
sync: true,
})
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
}
}, [])
if (!container || !container.isConnected) return null
if (!container) return null
return createPortal(
/**
@@ -1,9 +1,7 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -61,29 +59,21 @@ export const usePictureInPicture = () => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
let pipWindow: Window
try {
pipWindow =
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
} catch {
// Avoid unhandled rejections if the user blocks or closes the request.
return null
}
try {
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
flushSync(() => {
documentPictureInPictureStore.window = null
})
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -92,10 +82,8 @@ export const usePictureInPicture = () => {
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
reportError('generic_failure', error, {
context: 'pip_init_failure',
})
pipWindow.close()
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
@@ -16,6 +16,7 @@ import {
notifyRecordingSaveInProgress,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
@@ -28,7 +29,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
import { LimitDescription } from './LimitDescription'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
captureEvent('screen-recording-requested', {})
posthog.capture('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
@@ -100,15 +100,13 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
captureEvent('screen-recording-started', {
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle recording:',
})
console.error('Failed to handle recording:', error)
}
}
@@ -17,6 +17,7 @@ import {
useNotifyParticipants,
notifyRecordingSaveInProgress,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
@@ -34,7 +35,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitDescription } from './LimitDescription'
import { openSettingsDialog } from '@/stores/settings'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
captureEvent('transcript-requested', {})
posthog.capture('transcript-requested', {})
}
const handleTranscript = async () => {
@@ -121,15 +121,13 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
captureEvent('transcript-started', {
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle transcript:',
})
console.error('Failed to handle transcript:', error)
}
}
@@ -1,6 +1,5 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
@@ -9,9 +8,7 @@ export const useRoomMetadata = () => {
try {
return JSON.parse(metadata)
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to parse room metadata:',
})
console.error('Failed to parse room metadata:', error)
return undefined
}
} else {
+3 -16
View File
@@ -18,14 +18,6 @@ export type RoomConfiguration = {
everyone_can_mute?: boolean | null
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
export type ApiResourceAccess = {
id: string
role: ParticipantRole
}
export type ApiRoom = {
id: string
name: string
@@ -35,12 +27,7 @@ export type ApiRoom = {
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
/**
* Only present in the API response when the requesting user is an
* administrator or owner of the room (see RoomSerializer.to_representation
* in the backend). Its presence can therefore be used to detect
* administrability outside of a LiveKit session, where the room_role
* participant attribute is not available.
*/
accesses?: ApiResourceAccess[]
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -3,12 +3,12 @@ import { fetchApi } from '@/api/fetchApi'
export const fetchRoom = ({
roomId,
username,
username = '',
}: {
roomId: string
username?: string
}) => {
const query = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
return fetchApi<ApiRoom>(
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
)
}
@@ -9,7 +9,6 @@ import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
@@ -32,10 +31,7 @@ export const useMuteParticipant = () => {
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
reportError(
'participant_mute_api_failure',
new Error('Cannot mute participant: missing auth token')
)
console.error('Cannot mute participant: missing auth token')
return
}
@@ -57,11 +53,8 @@ export const useMuteParticipant = () => {
}
)
} catch (error) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
@@ -72,11 +65,8 @@ export const useMuteParticipant = () => {
destinationIdentities: [participant.identity],
})
} catch (e) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
@@ -12,9 +11,7 @@ export const useMuteParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('participant_mute_api_failure', error, {
context: 'An error occurred while muting participants :',
})
console.error('An error occurred while muting participants :', error)
throw new Error('An error occurred while muting participants.', {
cause: error,
})
@@ -2,8 +2,6 @@ import { type ApiRoom } from './ApiRoom'
import { fetchApi } from '@/api/fetchApi'
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
import type { ApiError } from '@/api/ApiError'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export type PatchRoomParams = {
roomId: string
@@ -17,25 +15,11 @@ export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
})
}
export const patchRoomMutationKey = ['patchRoom']
export function usePatchRoom(
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
) {
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
mutationKey: patchRoomMutationKey,
mutationFn: patchRoom,
onMutate: async ({ roomId, room: partialRoom }) => {
await queryClient.cancelQueries({ queryKey: [keys.room, roomId] })
queryClient.setQueryData<ApiRoom>([keys.room, roomId], (previous) =>
previous ? { ...previous, ...partialRoom } : previous
)
},
onSettled: (_data, _error, { roomId }) => {
if (queryClient.isMutating({ mutationKey: patchRoomMutationKey }) === 1) {
queryClient.invalidateQueries({ queryKey: [keys.room, roomId] })
}
},
...options,
onSuccess: options?.onSuccess,
})
}
@@ -1,7 +1,6 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useParticipantPermissions = () => {
@@ -33,11 +32,8 @@ export const useParticipantPermissions = () => {
}),
})
} catch (error) {
reportError(
'permissions_api_failure',
new Error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -1,6 +1,5 @@
import type { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
@@ -16,9 +15,7 @@ export const useUpdateParticipantsPermissions = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('permissions_api_failure', error, {
context: 'An error occurred while updating permissions :',
})
console.error('An error occurred while updating permissions :', error)
throw new Error('An error occurred while updating permissions.', {
cause: error,
})
@@ -25,7 +25,8 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -36,7 +37,6 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
export const Conference = ({
roomId,
@@ -47,6 +47,7 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
@@ -56,8 +57,8 @@ export const Conference = ({
const { username } = useSnapshot(userStore)
useEffect(() => {
void captureMediaEvent('visit-room', { slug: roomId })
}, [roomId])
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
@@ -169,6 +170,14 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
@@ -226,10 +235,7 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
reportError('livekit_room_error', e, {
path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
})
posthog.captureException(e)
}}
onConnected={async () => {
if (!apiConfig) return
@@ -289,10 +295,18 @@ export const Conference = ({
return
}
}}
onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
>
<WatchMediaDeviceErrors />
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const roomUrl = getRouteUrl('room', roomData?.slug)
const telephony = useTelephony()
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More