Compare commits

...

20 Commits

Author SHA1 Message Date
leo 21894d27e3 👷(ci) update trivyignore
The trivy scan fails on HIGH vulnerability CVE-2026-119373
found in the Debian 13 base images. As it has no fixed version,
add an exception to trivyignore. Also add expiry date for all
exceptions.
2026-11-03 17:15:26 +01:00
briquet 2b9eba72a6 ⬆️(dev) upgrade LiveKit server to v1.13.11 and egress to v1.15.0
Upgrade the local and helm dev stacks
2026-11-03 08:43:25 +01:00
briquet 327f405559 🐛(frontend) show the transcript mic hint when every mic is muted
Since LiveKit Egress v1.13, transcripts no longer go through Chrome:
audio-only egresses always use the SDK source, which only starts on the
first audio packet. A muted microphone sends none, so a transcript
started while every microphone is muted stays "starting" until someone
unmutes.

The hint asking participants to turn on their microphone only showed
when no microphone was published at all, as it counted muted ones. Only
count unmuted microphones for transcripts. Screen recordings still run
in Chrome and keep counting every track.
2026-11-03 08:43:25 +01:00
briquet 35c3491ab2 ✨(frontend) tell participants when a recording is interrupted
The backend now notifies participants with transcriptionInterrupted or
screenRecordingInterrupted when LiveKit Egress cuts a recording short but
keeps what it recorded so far.
2026-11-03 08:43:25 +01:00
briquet 41f3d1def4 ✨(backend) keep recordings cut short by LiveKit egress
Since LiveKit Egress v1.14, an egress cut short still uploads what it
recorded, but Meet either throws it away or saves it silently:
- a transcript that loses its room connection, once the SDK gave up
  reconnecting, ends FAILED with "recording ended early: connection to
  room failed": it was flagged as failed and was never transcribed
- an egress drained because its host ran out of CPU ends COMPLETE with
  the end reason "CPU exhausted": the truncated file was saved without
  telling anyone. If the drain outlasts its grace period, the egress gets
  killed and ends FAILED instead, with the same end reason, and with or
  without its file uploaded.

A new INTERRUPTED worker event is created :
- the recording is saved and sent to external services as usual, and the
participants of an active recording are told that only part of it was
kept.
- A FAILED egress only counts as interrupted when one of its files
has a location, which egress sets once the upload succeeded. Other
failures stay failures, even with an uploaded file.
2026-11-03 08:43:25 +01:00
briquet 22a93947d5 ✨(frontend) add an exponential backoff reconnection policy
When a LiveKit node crashes or Redis stalls, every client loses its signal
connection at the same time. LiveKit's default policy has been jittered
since 2.23.0, but it retries within 500 ms, then again within 450 ms, and
stops after about 45 s of delays, which can be too short for an outage.

Add a reconnection policy with a random exponential backoff. Before its
first attempt, a client waits a random delay up to 1 s by default. That
ceiling doubles after every failed attempt, capped at 10 s, and later
delays fall between half the ceiling and the ceiling. Without that floor,
retries happen at the same time when the service comes back

A client gives up after 3 minutes by default, unless its media still
flows. A client that drops again soon after reconnecting resumes the
backoff one step above where it stopped. The PostHog telemetry now reports how
long a reconnection lasted and when the policy gave up.
2026-11-03 08:43:25 +01:00
briquet b9748983ac ✨(backend) expose livekit reconnect settings in the config
When a LiveKit node crashes or Redis stalls, every client loses its signal
connection at the same time and tries to reconnect at once. The frontend
is about to spread these reconnections with a random exponential backoff.

Expose its parameters under livekit.reconnect in the config endpoint to
let each deployment tune them without rebuilding the frontend.

LIVEKIT_RECONNECT_MAX_DELAY_MS defaults to 10 s: with delays drawn between
half of it and all of it, a client retries every 7.5 s on average, close
to the 7 s of LiveKit's default policy in a long outage.

LIVEKIT_RECONNECT_BACKOFF_ENABLED switches back to LiveKit's default
policy.
2026-11-03 08:43:25 +01:00
briquet f7c3f392ef 🐛(frontend) show a page when the connection ends unexpectedly
When a room ended for a reason Conference did not handle, the user was
left on a dead call screen. This happens when the SDK gives up
reconnecting and when the join itself fails. Since livekit-client 2.22.1
it also happens when the WebSocket upgrade times out during the join,
which 2.21.0 reported as a cancellation and sent to the "you left the
meeting" page.

Send these users to the feedback page with a heading of its own: the
connection was lost if the room had connected, the meeting could not be
joined otherwise. Both keep the buttons to rejoin or go home. A join that
never succeeded leaves no call to rate, so the rating is hidden.

A full reconnection removes every remote participant until it succeeds,
which the idle-disconnect timer took for being alone in the room. Only
run that timer while the room is connected, so a long reconnection does
not end with the user sent away.
2026-11-03 08:43:25 +01:00
briquet 0c357c8b46 ⬆️(frontend) upgrade livekit-client from 2.21.0 to 2.23.0 2026-11-03 08:43:25 +01:00
briquet cae6c19da8 🔖(minor) bump release to 1.37.0 2026-11-01 14:07:10 +01:00
briquet 455daf1e8f ⚡️(docker) build frontend assets natively for arm64 images
Release images were building the JS bundles under QEMU for arm64, which
took most of the 10 to 17 minutes spent on each frontend image on CI.

Since the output is mostly static files, we can use the build platform
for the multi stage build steps of frontend images and share them
between architectures.
2026-10-31 11:41:06 +01:00
briquet 73b182b34a ⬆️(backend) upgrade django-lasuite to v1.0.0
Upgrade django-lasuite from 0.0.29 to 1.0.0.

An inactive token on the external API is now refused with a 401 error
instead of a 400.
2026-10-31 11:22:28 +01:00
briquet 6a1622cd50 🔒️(frontend) keep rooms out of search engine
Room URLs were appearing in search engine results.
The indexing is now restricted  to allow only the landing page.

- Emit a separate `home.html` during the Vite build without a `noindex` tag
- Configure Nginx to serve `home.html` exclusively at `/`
- Serve all other routes with a `noindex` meta tag and an
  `X-Robots-Tag: noindex` header
2026-10-31 11:22:28 +01:00
briquet 56b5a34ec5 ✨(frontend) replace the Crisp chat with the La Suite support form
LaSuite no longer uses Crisp for support.

Users now reach the team through the La Suite support form. It opens
from the in-room "Contact support" menu item and from the main page
as well

The widget is configured with the widget_script_url, widget_api_url and
widget_channel keys that uses shared values with other LaSuite projects
It won't show if the script URL are not configured.
2026-10-29 17:01:06 +01:00
Florent Chehab 815baa73e8 ⬆️(summary) update ffmpeg to 8.1.3-r0
8.1.2 is not available anymore and the image build fails.
2026-10-26 13:28:56 +01:00
briquet 85670a02d5 🔖(patch) bump release to 1.36.3 2026-10-23 21:48:52 +02:00
briquet 21121e8bc1 🔒️(backend) upgrade djangorestframework to 3.18.2
Fix three vulnerabilities in djangorestframework

- CVE-2026-114136: a compressing codec in the Content-Type charset
  lets an unauthenticated body bypass Django's size limit,
  causing memory-amplification denial of service
- CVE-2026-114137: TokenProxy.delete() reports success without deleting
  the token
- CVE-2026-114138: crafted Accept headers make content negotiation burn
  seconds of CPU per request, exhausting workers
2026-10-23 21:46:44 +02:00
briquet 1717c65f2b 🔒️(frontend) upgrade nginx to 1.30.6 to fix CVE-2026-15176
Bump the nginx-unprivileged base image to 1.30.6-alpine3.24, which
fixes a heap overflow when using alias with rewrite, along with
CVE-2026-93479, CVE-2026-95101 and CVE-2026-97629.

https://nginx.org/en/security_advisories.html
2026-10-23 21:46:44 +02:00
briquet fa20d52248 🔒️(all) upgrade brotli to fix CVE-2026-111497
CVE-2026-111497 is an out-of-bounds write in the brotli decoder,
reachable by decompressing crafted brotli data.

- backend: upgrade Brotli to 1.2.1, used by urllib3 and aiohttp to
  decode "Content-Encoding: br" responses
- docker: require brotli-libs >= 1.2.1-r0 in the backend, summary and
  frontend images, the nginx base image ships 1.2.0-r1
- frontend: build the Outlook add-in on Node 22.23.4, Node 20 is EOL
  and will not get the fix

https://www.strix.ai/cve/CVE-2026-111497
2026-10-23 21:46:44 +02:00
briquet 75df71733a 🗃️(backend) add missing editable=False on client_secret_sha256
Migration 0025 declared client_secret_sha256 without editable=False,
while the model field is setting it.
2026-10-21 23:19:05 +02:00
92 changed files with 1797 additions and 311 deletions
+11 -8
View File
@@ -2,19 +2,22 @@
# Review regularly and remove entries once Debian ships a fix.
# util-linux
CVE-2026-76642
CVE-2026-78408
CVE-2026-78409
CVE-2026-78410
CVE-2026-76642 exp:2026-12-05
CVE-2026-78408 exp:2026-12-05
CVE-2026-78409 exp:2026-12-05
CVE-2026-78410 exp:2026-12-05
# acl
CVE-2026-54369
CVE-2026-54369 exp:2026-12-05
# ncurses
CVE-2025-69720
CVE-2025-69720 exp:2026-12-05
# systemd
CVE-2026-16742
CVE-2026-16742 exp:2026-12-05
# perl-base (fix deferred by Debian)
CVE-2026-9538
CVE-2026-9538 exp:2026-12-05
# libsqlite3-0 (fixed in sid 3.54.1-1; trixie marked no-dsa / minor)
CVE-2026-119373 exp:2026-12-05
+39 -1
View File
@@ -8,6 +8,44 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) add jittered exponential backoff to LiveKit reconnections
- ✨(frontend) tell participants when a recording is interrupted
### Changed
- ⬆️(frontend) upgrade livekit-client to 2.23.0
- ✨(backend) expose LiveKit reconnection settings in the config
- ✨(backend) keep recordings cut short by LiveKit egress
- ⬆️(dev) upgrade LiveKit server to v1.13.11 and egress to v1.15.0
### Fixed
- 🐛(frontend) show a page when the connection ends unexpectedly
- 🐛(frontend) show the transcript mic hint when every mic is muted
## [1.37.0] - 2026-11-01
### Changed
- ⬆️(summary) update ffmpeg to 8.1.3-r0
- ✨(frontend) replace the Crisp chat with the La Suite support form
- 🔒️(frontend) keep rooms out of search engine and AI indexes
- ⬆️(backend) upgrade django-lasuite to v1.0.0
- ⚡️(docker) build frontend assets natively for arm64 images
## [1.36.3] - 2026-10-23
### Fixed
- 🔒️(backend) upgrade brotli to 1.2.1 to fix CVE-2026-111497
- 🔒️(frontend) upgrade nginx to 1.30.6 to fix CVE-2026-15176
- 🔒️(docker) upgrade brotli-libs to 1.2.1 to fix CVE-2026-111497
- 🔒️(frontend) build the Outlook add-in on Node 22.23.4
- 🔒️(backend) upgrade djangorestframework to 3.18.2 to fix CVE-2026-114136,
CVE-2026-114137 and CVE-2026-114138
## [1.36.2] - 2026-10-21
### Fixed
@@ -167,7 +205,7 @@ and this project adheres to
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) probe liveness on `__lbheartbeat__` and readiness on `__heartbeat__`
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
+2 -1
View File
@@ -87,7 +87,8 @@ RUN apk --no-cache add \
libffi-dev \
pango \
libmagic \
shared-mime-info
shared-mime-info \
'brotli-libs>=1.2.1-r0'
# Copy entrypoint
+24
View File
@@ -16,6 +16,30 @@ the following command inside your docker container:
## [Unreleased]
## v1.37.0
### Crisp support chat replaced by the La Suite support form
The Crisp chat is gone, the support button, now open the La Suite support form. They are hidden unless these `FRONTEND_SUPPORT` keys are all set.
The former help links for recording/transcription are now redirecting to the adhoc page in the official documentation
### Pages hidden from search engines
Every page except `/` now carries a `noindex` tag and, with the provided nginx configurations, an `X-Robots-Tag` header to stay out of search indexes.
A new `robots.txt` also blocks the main AI crawlers. If you maintain your own nginx configuration, copy the `location = /` and `location = /home.html` blocks from `src/frontend/default.conf`, and the `X-Robots-Tag` header from its `location /`.
### LiveKit server v1.13.11 and egress v1.15.0
The development stack now runs LiveKit server v1.13.11 and LiveKit Egress v1.15.0. When upgrading your own deployment:
- Deploy this release of Meet first, then upgrade egress, then the server. Older Meet releases discard the partial recordings described below.
- Transcripts no longer go through Chrome. Since egress v1.13, audio-only recordings always use the egress SDK pipeline.
- A transcript now waits for actual audio: it stays "starting", and participants are asked to turn on their microphone, while every microphone is muted.
- Recordings cut short are now kept. Since egress v1.14, a transcript that lost the room, or any recording drained because its egress host ran out of CPU, still uploads what it recorded, even though egress may report it as failed. Meet saves and processes it as usual, and tells participants the recording was interrupted. A screen recording that loses the room is still reported as complete, and saved without a warning.
- Telephony: since server v1.13.8, a call that matches no dispatch rule, for example while no room is active, is rejected with a SIP 404 instead of being silently dropped.
## [v1.35.0]
### Purging deleted rooms
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
+2 -2
View File
@@ -218,7 +218,7 @@ services:
- kc_postgresql
livekit:
image: livekit/livekit-server:v1.13.6
image: livekit/livekit-server:v1.13.11
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
@@ -233,7 +233,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress:v1.11.0
image: livekit/egress:v1.15.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
+12
View File
@@ -38,6 +38,17 @@ server {
add_header Cache-Control "public, max-age=2592000";
}
location = / {
try_files /home.html /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
location = /home.html {
internal;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
@@ -45,6 +56,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# Optionally, handle 404 errors by redirecting to index.html
+7 -5
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ----
FROM node:22.23.4-alpine AS frontend-deps
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS frontend-deps
RUN apk upgrade --no-cache ca-certificates
@@ -41,7 +41,7 @@ COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Addons builder image ----
FROM node:20-alpine AS addons-builder
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS addons-builder
RUN apk upgrade --no-cache ca-certificates
@@ -58,13 +58,15 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.5-alpine3.24 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' && \
apk del curl
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
'brotli-libs>=1.2.1-r0' && \
apk del curl && \
rm -f /usr/share/nginx/html/50x.html
USER nginx
USER nginx
+13
View File
@@ -43,6 +43,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
add_header X-Robots-Tag "noindex, nofollow" always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
@@ -76,6 +77,17 @@ server {
add_header Cache-Control "public, max-age=2592000";
}
location = / {
try_files /home.html /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
location = /home.html {
internal;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
@@ -83,6 +95,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# Optionally, handle 404 errors by redirecting to index.html
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.13.6
FROM livekit/livekit-server:v1.13.11
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+2 -1
View File
@@ -1,4 +1,5 @@
log_level: debug
logging:
level: debug
api_key: devkey
api_secret: secret
ws_url: "ws://livekit:7880"
+1 -1
View File
@@ -37,7 +37,7 @@ $ make help
---
## Need Help?
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or by email at [support-visio@numerique.gouv.fr](mailto:support-visio@numerique.gouv.fr).
---
+13 -4
View File
@@ -11,14 +11,23 @@ We do not cover internal signaling behavior. For full reference, see the [LiveKi
> [!IMPORTANT]
> The WebSocket is the backbone of LiveKit signaling. All signaling messages rely on it, and without it, ICE candidate exchange and peer connection setup cannot occur. If the WebSocket connection is lost, the client automatically attempts to resume the RTC session once connectivity is restored.
## Reconnection
When the WebSocket drops, for instance after a LiveKit node crash or during a Redis outage, every client in every room loses it at the same time. To keep them from all reconnecting at once, each client waits a random delay before every attempt. The first delay is at most `LIVEKIT_RECONNECT_BASE_DELAY_MS`. That ceiling then doubles after each failed attempt, capped at `LIVEKIT_RECONNECT_MAX_DELAY_MS`, and each later delay falls between half the ceiling and the ceiling. The first attempt always starts within 5 seconds of the drop.
A client gives up once its reconnection has lasted longer than `LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS`, and then shows a "connection lost" page. While only signaling is down and its media still flows, it keeps trying instead. A client that loses the connection again less than `LIVEKIT_RECONNECT_BACKOFF_RESET_MS` after reconnecting resumes the backoff one step above where it stopped.
## Environment Variables
| Variable | Type | Default | Purpose |
| ----------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `True` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `True` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
| Variable | Type | Default | Purpose |
| ----------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `False` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `False` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
| `LIVEKIT_RECONNECT_BACKOFF_ENABLED` | Boolean | `True` | Enables the reconnection backoff described above. When `False`, clients use LiveKit's default reconnection policy. |
| `LIVEKIT_RECONNECT_BASE_DELAY_MS` | Integer | `1000` | Longest wait before the first reconnection attempt, in milliseconds. It doubles after each failed attempt. Must be greater than 0. |
| `LIVEKIT_RECONNECT_MAX_DELAY_MS` | Integer | `10000` | Longest wait between two reconnection attempts, in milliseconds. Must be greater than 0. |
| `LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS` | Integer | `180000` | How long a client keeps trying to reconnect, in milliseconds. Not applied while its media still flows. Must be greater than 0. |
| `LIVEKIT_RECONNECT_BACKOFF_RESET_MS` | Integer | `120000` | How long a connection must stay up, in milliseconds, for the next reconnection to start from the shortest delay again. With `0`, every reconnection does. |
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
+6 -1
View File
@@ -348,7 +348,7 @@ These are the environmental options available on meet backend.
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_SUPPORT | La Suite support form (`widget_script_url`, `widget_api_url`, `widget_channel`) and help articles (`help_article_*`) | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
@@ -413,6 +413,11 @@ These are the environmental options available on meet backend.
| LIVEKIT_API_TIMEOUT_SECONDS | Seconds before a call to the LiveKit API gives up | 10 |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| LIVEKIT_RECONNECT_BACKOFF_ENABLED | Spread client reconnections with a random exponential backoff. When false, clients use LiveKit's default reconnection policy | true |
| LIVEKIT_RECONNECT_BASE_DELAY_MS | Longest wait before the first reconnection attempt, in milliseconds. It doubles after each failed attempt. Must be greater than 0 | 1000 |
| LIVEKIT_RECONNECT_MAX_DELAY_MS | Longest wait between two reconnection attempts, in milliseconds. Must be greater than 0 | 10000 |
| LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS | How long a client keeps trying to reconnect, in milliseconds. Not applied while its media still flows. Must be greater than 0 | 180000 |
| LIVEKIT_RECONNECT_BACKOFF_RESET_MS | How long a connection must stay up, in milliseconds, for the next reconnection to start from the shortest delay again. With 0, every reconnection does | 120000 |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
@@ -2,6 +2,7 @@
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="robots" content="noindex, nofollow" />
<title data-app-name></title>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow" />
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow" />
<title data-app-name></title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow" />
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.36.2"
version = "1.37.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.36.2"
version = "1.37.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
+7
View File
@@ -86,6 +86,13 @@ def get_frontend_configuration(request):
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
"reconnect": {
"backoff_enabled": settings.LIVEKIT_RECONNECT_BACKOFF_ENABLED,
"base_delay_ms": settings.LIVEKIT_RECONNECT_BASE_DELAY_MS,
"max_delay_ms": settings.LIVEKIT_RECONNECT_MAX_DELAY_MS,
"give_up_after_ms": settings.LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS,
"backoff_reset_ms": settings.LIVEKIT_RECONNECT_BACKOFF_RESET_MS,
},
},
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
"authenticated_users_can_edit_display_name": (
@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-21 20:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0028_room_deleted_at'),
]
operations = [
migrations.AlterField(
model_name='application',
name='client_secret_sha256',
field=models.CharField(blank=True, editable=False, max_length=255, null=True),
),
]
+4
View File
@@ -27,6 +27,9 @@ class RecordingWorkerEvent(Enum):
COMPLETED = "completed"
# The recording ended on its configured limit, its media file is available.
LIMIT_REACHED = "limit reached"
# The recording ended early without being stopped, its partial media file
# is available.
INTERRUPTED = "interrupted"
# The worker stopped before it ever started recording, there is no media file.
ABORTED = "aborted"
# The worker hit a runtime error once recording had started; its media file
@@ -44,6 +47,7 @@ SUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
RecordingWorkerEvent.INTERRUPTED,
}
)
@@ -33,6 +33,7 @@ NOTIFICATION_PREFIXES = {
}
NOTIFICATION_SUFFIXES = {
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
RecordingWorkerEvent.INTERRUPTED: "Interrupted",
RecordingWorkerEvent.FAILED: "Failed",
RecordingWorkerEvent.ABORTED: "Aborted",
}
@@ -61,24 +62,29 @@ class RecordingEventsService:
"""
@staticmethod
def log_worker_error(recording, event, error=None, error_code=None):
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
def log_worker_error(recording, event, error=None, error_code=None, details=None):
"""Log FAILED at error level, INTERRUPTED at warning level and expected
ABORTED outcomes at info level."""
if event == RecordingWorkerEvent.FAILED:
log = logger.error
elif event == RecordingWorkerEvent.INTERRUPTED:
log = logger.warning
elif event == RecordingWorkerEvent.ABORTED:
log = logger.info
else:
return
log(
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s "
"(error_code=%s, details=%s)",
event.value,
recording.id,
recording.room.id,
recording.mode,
error or "no error reported",
error_code or "no error_code reported",
details or "no details reported",
)
@staticmethod
@@ -196,6 +202,13 @@ class RecordingEventsService:
):
self._apply_outcome(recording, event, self._handle_limit_reached)
# Interrupted
if (
event == RecordingWorkerEvent.INTERRUPTED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._apply_outcome(recording, event, self._handle_interrupted)
try:
self._handle_successful(recording)
except RecordingNotSavableError:
@@ -224,6 +237,20 @@ class RecordingEventsService:
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
@classmethod
def _handle_interrupted(cls, recording: Recording):
"""Stop recording and notify participants that only part of it is saved.
INTERRUPTED: used when the worker ended the recording early on its own
because it lost the room or ran out of resources but kept what it had
recorded so far
"""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.INTERRUPTED)
@classmethod
def _handle_failed(cls, recording: Recording):
"""Set recording status to failed, matching the worker event, and notify participants.
+33 -1
View File
@@ -96,6 +96,9 @@ EGRESS_STATUS_TO_RECORDING_EVENT = {
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
}
EGRESS_ROOM_CONNECTION_FAILED_ERROR = "recording ended early: connection to room failed"
EGRESS_CPU_EXHAUSTED_DETAILS = "End reason: CPU exhausted"
def to_recording_event(egress_status):
"""Translate a LiveKit egress status into a recording worker event."""
@@ -109,6 +112,34 @@ def to_recording_event(egress_status):
return event
def to_ended_recording_event(egress_info):
"""Translate an ended LiveKit egress into a recording worker event.
This handles partial media file upload where an egress was interrupted
which can happen when:
- an transcription egress that lost its room connection uploads an audio file, then ends
FAILED with the EGRESS_ROOM_CONNECTION_FAILED_ERROR
- an egress drained because its host ran out of CPU which can transition to COMPLETE state
with EGRESS_CPU_EXHAUSTED_DETAILS or FAILED state if the drain outlasted its grace period
and the egress got killed, possibly before the upload.
"""
status = egress_info.status
is_cut_short = (
egress_info.error == EGRESS_ROOM_CONNECTION_FAILED_ERROR
or egress_info.details == EGRESS_CPU_EXHAUSTED_DETAILS
)
has_uploaded_file = any(file.location for file in egress_info.file_results)
if is_cut_short and (
status == api.EgressStatus.EGRESS_COMPLETE
or (status == api.EgressStatus.EGRESS_FAILED and has_uploaded_file)
):
return RecordingWorkerEvent.INTERRUPTED
return to_recording_event(status)
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -221,7 +252,7 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
event = to_recording_event(data.egress_info.status)
event = to_ended_recording_event(data.egress_info)
self._audit_recording_end(recording, event, data.egress_info.error_code)
# Log if/why the recording failed
@@ -230,6 +261,7 @@ class LiveKitEventsService:
event,
error=data.egress_info.error,
error_code=data.egress_info.error_code,
details=data.egress_info.details,
)
# Update room
@@ -50,6 +50,26 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingInterrupted"),
("transcript", "transcriptionInterrupted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_interrupted_success(mock_notify, mode, notification_type, service):
"""Test _handle_interrupted stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_interrupted(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
@@ -222,6 +242,7 @@ def test_handle_update_syncs_room_metadata(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
RecordingWorkerEvent.INTERRUPTED,
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
),
@@ -244,6 +265,10 @@ def test_handle_update_ignores_events_without_a_metadata_status(
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
(RecordingWorkerEvent.INTERRUPTED, "active", "saved", "Interrupted"),
(RecordingWorkerEvent.INTERRUPTED, "stopped", "saved", None),
(RecordingWorkerEvent.INTERRUPTED, "saved", "saved", None),
(RecordingWorkerEvent.INTERRUPTED, "failed_to_stop", "failed_to_stop", None),
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
@@ -321,6 +346,7 @@ def test_handle_terminal_event_ignores_non_terminal_events(
("event", "expected_status"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
(RecordingWorkerEvent.INTERRUPTED, "saved"),
(RecordingWorkerEvent.ABORTED, "aborted"),
(RecordingWorkerEvent.FAILED, "failed"),
),
@@ -407,6 +433,7 @@ def test_handle_update_survives_a_metadata_failure(
("event", "expected_level"),
(
(RecordingWorkerEvent.ABORTED, logging.INFO),
(RecordingWorkerEvent.INTERRUPTED, logging.WARNING),
(RecordingWorkerEvent.FAILED, logging.ERROR),
),
)
@@ -419,7 +446,11 @@ def test_log_worker_error_reports_an_unsuccessful_event(
with caplog.at_level(logging.INFO):
service.log_worker_error(
recording, event, error="could not connect to the room", error_code=500
recording,
event,
error="could not connect to the room",
error_code=500,
details="End reason: Failure",
)
assert (
@@ -428,6 +459,7 @@ def test_log_worker_error_reports_an_unsuccessful_event(
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
assert "details=End reason: Failure" in caplog.text
worker_logs = [
record
for record in caplog.records
@@ -448,7 +480,7 @@ def test_log_worker_error_reports_an_unsuccessful_event(
),
)
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
"""Test log_worker_error ignores events other than FAILED, INTERRUPTED and ABORTED."""
recording = RecordingFactory(status="active")
@@ -8,9 +8,12 @@ import hashlib
import json
from unittest import mock
from django.utils import timezone
import pytest
from livekit import api
from ...factories import RecordingFactory
from ...services.livekit_events import ActionFailedError, LiveKitEventsService
@@ -184,3 +187,75 @@ def test_action_error(client, mock_livekit_config):
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
@pytest.mark.django_db
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services",
return_value=False,
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_egress_ended_keeps_a_partial_transcript(
mock_update_metadata,
mock_notify,
mock_notify_external_services,
client,
mock_livekit_config,
):
"""Should save a transcript whose egress lost the room but uploaded its file.
LiveKit Egress v1.14 reports it as failed. The payload mirrors the one it
sends, including a field unknown to the pinned livekit-protocol, which
must be ignored rather than rejected.
"""
recording = RecordingFactory(
worker_id="EG_hycBMAjmt6Ub", mode="transcript", status="active"
)
event_data = json.dumps(
{
"event": "egress_ended",
"egressInfo": {
"egressId": "EG_hycBMAjmt6Ub",
"roomId": "RM_hycBMAjmt6Ub",
"roomName": str(recording.room.id),
"status": "EGRESS_FAILED",
"error": "recording ended early: connection to room failed",
"errorCode": 503,
"details": "End reason: Source closed",
"fileResults": [
{
"filename": f"recordings/{recording.id}.ogg",
"location": f"https://s3.example.com/recordings/{recording.id}.ogg",
"size": "123456",
}
],
},
"id": "EV_eugWmGhovZmm",
"createdAt": str(int(timezone.now().timestamp())),
}
)
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=token.to_jwt(),
)
assert response.status_code == 200
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "transcriptionInterrupted"},
)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == "saved"
@@ -1,7 +1,7 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611, C0302
# pylint: disable=W0621,W0613, W0212, E0611, E1101, C0302
import logging
import uuid
@@ -11,6 +11,7 @@ from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from livekit.api import EgressStatus
from core.audit.testing import find_events
@@ -19,12 +20,15 @@ from core.models import Room
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
EGRESS_CPU_EXHAUSTED_DETAILS,
EGRESS_ROOM_CONNECTION_FAILED_ERROR,
EGRESS_STATUS_TO_RECORDING_EVENT,
ActionFailedError,
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
api,
to_ended_recording_event,
to_recording_event,
)
from core.services.lobby import LobbyService
@@ -39,6 +43,7 @@ from core.services.sip_management import (
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
fake = Faker()
@pytest.fixture
@@ -494,6 +499,228 @@ def test_to_recording_event_returns_none_on_unmapped_status(caplog):
assert "Unmapped LiveKit egress status" in caplog.text
recording_filename = f"recordings/{fake.file_name(extension='ogg')}"
UPLOADED_FILE = api.FileInfo(
filename=recording_filename,
location=f"https://storage.example.com/{recording_filename}",
size=1048576,
)
NOT_UPLOADED_FILE = api.FileInfo(filename=recording_filename)
ROOM_CONNECTION_FAILED = {
"status": EgressStatus.EGRESS_FAILED,
"error": EGRESS_ROOM_CONNECTION_FAILED_ERROR,
"error_code": 503,
"details": "End reason: Source closed",
}
CPU_EXHAUSTED_DRAINED = {
"status": EgressStatus.EGRESS_COMPLETE,
"details": EGRESS_CPU_EXHAUSTED_DETAILS,
}
CPU_EXHAUSTED_KILLED = {
"status": EgressStatus.EGRESS_FAILED,
"error": "CPU exhausted: 8.5 cores used",
"error_code": 403,
"details": EGRESS_CPU_EXHAUSTED_DETAILS,
}
@pytest.mark.parametrize(
("egress_info", "expected_event"),
(
(
api.EgressInfo(**ROOM_CONNECTION_FAILED, file_results=[UPLOADED_FILE]),
RecordingWorkerEvent.INTERRUPTED,
),
(
api.EgressInfo(**ROOM_CONNECTION_FAILED, file_results=[NOT_UPLOADED_FILE]),
RecordingWorkerEvent.FAILED,
),
(
api.EgressInfo(**CPU_EXHAUSTED_DRAINED, file_results=[UPLOADED_FILE]),
RecordingWorkerEvent.INTERRUPTED,
),
(
api.EgressInfo(**CPU_EXHAUSTED_KILLED, file_results=[UPLOADED_FILE]),
RecordingWorkerEvent.INTERRUPTED,
),
(
api.EgressInfo(**CPU_EXHAUSTED_KILLED, file_results=[NOT_UPLOADED_FILE]),
RecordingWorkerEvent.FAILED,
),
# Other failures stay failures, even with an uploaded file.
(
api.EgressInfo(
status=EgressStatus.EGRESS_FAILED,
error="pipeline frozen",
error_code=500,
details="End reason: Source closed",
file_results=[UPLOADED_FILE],
),
RecordingWorkerEvent.FAILED,
),
(
api.EgressInfo(
status=EgressStatus.EGRESS_COMPLETE,
details="End reason: StopEgress API",
file_results=[UPLOADED_FILE],
),
RecordingWorkerEvent.COMPLETED,
),
(
api.EgressInfo(
status=EgressStatus.EGRESS_ABORTED,
error="Stop called before pipeline could start",
error_code=412,
details="End reason: StopEgress API",
),
RecordingWorkerEvent.ABORTED,
),
(
api.EgressInfo(
status=EgressStatus.EGRESS_LIMIT_REACHED,
error="Session limit reached",
error_code=413,
details="End reason: Session limit reached",
file_results=[UPLOADED_FILE],
),
RecordingWorkerEvent.LIMIT_REACHED,
),
),
)
def test_to_ended_recording_event_translates_egress_info(egress_info, expected_event):
"""Should tell an egress cut short with a partial file apart from the others."""
assert to_ended_recording_event(egress_info) == expected_event
@pytest.mark.parametrize(
("mode", "egress_info_kwargs", "notification_type"),
(
("transcript", ROOM_CONNECTION_FAILED, "transcriptionInterrupted"),
("transcript", CPU_EXHAUSTED_DRAINED, "transcriptionInterrupted"),
("screen_recording", CPU_EXHAUSTED_DRAINED, "screenRecordingInterrupted"),
("screen_recording", CPU_EXHAUSTED_KILLED, "screenRecordingInterrupted"),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_interrupted_saves_partial_recording( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
mock_notify_external_services,
mode,
egress_info_kwargs,
notification_type,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should save an interrupted recording and tell participants it is partial."""
mock_notify_external_services.return_value = True
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
data = api.WebhookEvent(
event="egress_ended",
egress_info=api.EgressInfo(
egress_id=recording.worker_id,
file_results=[UPLOADED_FILE],
**egress_info_kwargs,
),
)
with caplog.at_level(logging.WARNING):
service._handle_egress_ended(data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_notify_external_services.assert_called_once_with(recording)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
assert (
f"Recording worker reported interrupted for recording {recording.id}"
in caplog.text
)
recording.refresh_from_db()
assert recording.status == "notification_succeeded"
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_interrupted_after_stop_does_not_notify_participants(
mock_update_metadata, mock_notify, mock_notify_external_services, service
):
"""Shouldn't notify participants about a recording they already stopped."""
mock_notify_external_services.return_value = False
recording = RecordingFactory(worker_id="worker-1", status="stopped")
data = api.WebhookEvent(
event="egress_ended",
egress_info=api.EgressInfo(
egress_id=recording.worker_id,
file_results=[UPLOADED_FILE],
**CPU_EXHAUSTED_DRAINED,
),
)
service._handle_egress_ended(data)
mock_notify.assert_not_called()
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_failed_on_another_error_discards_recording(
mock_update_metadata, mock_notify, service
):
"""Should still flag as failed an egress that failed for another reason."""
recording = RecordingFactory(
worker_id="worker-1", mode="transcript", status="active"
)
data = api.WebhookEvent(
event="egress_ended",
egress_info=api.EgressInfo(
egress_id=recording.worker_id,
status=EgressStatus.EGRESS_FAILED,
error="pipeline frozen",
error_code=500,
details="End reason: Source closed",
file_results=[UPLOADED_FILE],
),
)
service._handle_egress_ended(data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "transcriptionFailed"},
)
recording.refresh_from_db()
assert recording.status == "failed"
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
"""Shouldn't touch the room's metadata when the egress status is unknown."""
@@ -0,0 +1,40 @@
"""Test the LiveKit reconnection settings exposed by the config endpoint."""
import pytest
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
def test_api_config_retrieve_livekit_reconnect_defaults():
"""Without any override, the frontend gets the default backoff."""
response = APIClient().get("/api/v1.0/config/")
assert response.status_code == 200
assert response.json()["livekit"]["reconnect"] == {
"backoff_enabled": True,
"base_delay_ms": 1000,
"max_delay_ms": 10000,
"give_up_after_ms": 180000,
"backoff_reset_ms": 120000,
}
def test_api_config_retrieve_livekit_reconnect_settings(settings):
"""Each reconnection setting reaches the frontend unchanged."""
settings.LIVEKIT_RECONNECT_BACKOFF_ENABLED = False
settings.LIVEKIT_RECONNECT_BASE_DELAY_MS = 500
settings.LIVEKIT_RECONNECT_MAX_DELAY_MS = 30000
settings.LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS = 600000
settings.LIVEKIT_RECONNECT_BACKOFF_RESET_MS = 60000
response = APIClient().get("/api/v1.0/config/")
assert response.status_code == 200
assert response.json()["livekit"]["reconnect"] == {
"backoff_enabled": False,
"base_delay_ms": 500,
"max_delay_ms": 30000,
"give_up_after_ms": 600000,
"backoff_reset_ms": 60000,
}
@@ -184,7 +184,7 @@ def test_api_rooms_list_with_application_disabled(mock_rs_authenticate, settings
@responses.activate
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
"""Listing rooms with invalid resource server token should return 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -202,9 +202,8 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
assert response.status_code == 401
assert "introspected user is not active" in str(response.data).lower()
def test_api_rooms_list_missing_scope():
@@ -422,7 +421,7 @@ def test_api_rooms_retrieve_with_expired_token(settings):
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server token should return 400."""
"""Retrieving rooms with invalid resource server token should return 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -443,9 +442,8 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
assert response.status_code == 401
assert "introspected user is not active" in str(response.data).lower()
def test_api_rooms_retrieve_requires_scope():
@@ -647,7 +645,7 @@ def test_api_rooms_create_with_expired_token(settings):
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server token should return 400."""
"""Creating rooms with invalid resource server token should return 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -665,9 +663,8 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.post("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
assert response.status_code == 401
assert "introspected user is not active" in str(response.data).lower()
def test_api_rooms_create_inactive_user():
@@ -2971,6 +2968,36 @@ def test_api_rooms_missing_token_is_audited_as_denial(audit_events):
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
@responses.activate
def test_api_rooms_invalid_rs_token_is_audited_as_denial(audit_events, settings):
"""A token refused by the identity provider is recorded as a denial."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={"iss": "https://oidc.example.com", "active": False},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert response.headers["WWW-Authenticate"] == "Bearer"
responses.assert_call_count("https://oidc.example.com/introspect", 1)
[event] = find_events(audit_events, "room.list")
assert event["event"]["category"] == ["api", "authentication"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["http"]["response"] == {"status_code": 401}
assert event["error"]["message"] == response.json()["detail"]
def test_api_rooms_missing_scope_is_audited_as_denial(audit_events):
"""A token without the required scope is a permission denial by the application."""
user = UserFactory()
@@ -0,0 +1,24 @@
"""Unit tests for the value of the LiveKit reconnection delay settings."""
import pytest
from meet.settings import StrictlyPositiveIntegerValue
@pytest.mark.parametrize("raw,expected", [("1", 1), (" 15000 ", 15000)])
def test_strictly_positive_integer_value_parses(raw, expected):
"""A delay above 0 is read as an integer."""
assert StrictlyPositiveIntegerValue(environ=False).to_python(raw) == expected
def test_strictly_positive_integer_value_rejects_zero():
"""0 stops the settings module loading, unlike with PositiveIntegerValue."""
with pytest.raises(ValueError, match="greater than 0"):
StrictlyPositiveIntegerValue(environ=False).to_python("0")
@pytest.mark.parametrize("raw", ["-1", "1e3", "1000ms", ""])
def test_strictly_positive_integer_value_rejects_invalid(raw):
"""A negative or non-integer value stops the settings module loading."""
with pytest.raises(ValueError):
StrictlyPositiveIntegerValue(environ=False).to_python(raw)
+33
View File
@@ -68,6 +68,17 @@ class VideoCodecValue(values.Value):
return codec
class StrictlyPositiveIntegerValue(values.PositiveIntegerValue):
"""An integer greater than 0"""
def to_python(self, value):
"""Ensure the integer is greater than 0."""
int_value = super().to_python(value)
if int_value == 0:
raise ValueError(f"Expected an integer greater than 0, got {value!r}.")
return int_value
class ResolutionSpec(BaseModel):
"""An value of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
@@ -787,6 +798,28 @@ class Base(Configuration):
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
)
# Livekit websocket reconnection exponential backoff config sent to the frontend.
# Before its first reconnection attempt, a client waits a random delay up to the base
# delay. That ceiling doubles after every failed attempt, capped at the max delay, and
# later delays fall between half the ceiling and the ceiling.
# It gives up after the give-up delay unless the media still flows.
# After a successful reconnection, a new disconnection starting sooner than the reset
# delay after the last one resumes the backoff one step above where that one stopped.
LIVEKIT_RECONNECT_BACKOFF_ENABLED = values.BooleanValue(
True, environ_name="LIVEKIT_RECONNECT_BACKOFF_ENABLED", environ_prefix=None
)
LIVEKIT_RECONNECT_BASE_DELAY_MS = StrictlyPositiveIntegerValue(
1000, environ_name="LIVEKIT_RECONNECT_BASE_DELAY_MS", environ_prefix=None
)
LIVEKIT_RECONNECT_MAX_DELAY_MS = StrictlyPositiveIntegerValue(
10000, environ_name="LIVEKIT_RECONNECT_MAX_DELAY_MS", environ_prefix=None
)
LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS = StrictlyPositiveIntegerValue(
180000, environ_name="LIVEKIT_RECONNECT_GIVE_UP_AFTER_MS", environ_prefix=None
)
LIVEKIT_RECONNECT_BACKOFF_RESET_MS = values.PositiveIntegerValue(
120000, environ_name="LIVEKIT_RECONNECT_BACKOFF_RESET_MS", environ_prefix=None
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
+4 -4
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.36.2"
version = "1.37.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.80",
"Brotli==1.2.0",
"Brotli==1.2.1",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
"dj-database-url==3.1.2",
@@ -33,7 +33,7 @@ dependencies = [
"django-cors-headers==4.9.0",
"django-countries==9.0.0",
"django-filter==26.1",
"django-lasuite[all]==0.0.29",
"django-lasuite[all]==1.0.0",
"django-parler==2.4",
"redis==5.2.1",
"django-redis==7.0.0",
@@ -41,7 +41,7 @@ dependencies = [
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.17",
"djangorestframework==3.18.0",
"djangorestframework==3.18.2",
"drf_spectacular==0.30.0",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
+68 -38
View File
@@ -263,30 +263,49 @@ sdist = { url = "https://files.pythonhosted.org/packages/a8/13/9c5fcc8be41af6827
[[package]]
name = "brotli"
version = "1.2.0"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" },
{ url = "https://files.pythonhosted.org/packages/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" },
{ url = "https://files.pythonhosted.org/packages/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" },
{ url = "https://files.pythonhosted.org/packages/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" },
{ url = "https://files.pythonhosted.org/packages/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" },
{ url = "https://files.pythonhosted.org/packages/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" },
{ url = "https://files.pythonhosted.org/packages/8c/b4/379b2e5584be70110b154021ffa61b84dc3b476d2c031317f5c3fbcfcde5/brotli-1.2.1-cp313-cp313-win32.whl", hash = "sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671", size = 333014, upload-time = "2026-10-22T13:58:23.112Z" },
{ url = "https://files.pythonhosted.org/packages/96/c2/0c7abb25013043d4bcf7dd768d343bf7dee6ce99ac8c172f7ee4aef1c772/brotli-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f", size = 371072, upload-time = "2026-10-22T13:58:24.161Z" },
{ url = "https://files.pythonhosted.org/packages/81/68/5f9d96203fdce3ff9ba1186ce10220e26483ffa2384cde5430dd980e9057/brotli-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c", size = 380893, upload-time = "2026-10-22T13:58:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" },
{ url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" },
{ url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" },
{ url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" },
{ url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" },
{ url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" },
{ url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" },
{ url = "https://files.pythonhosted.org/packages/04/da/1ec2e9b4f500693e56e6391f128dd0d304d6d2b7f0d5090e80a802b722f1/brotli-1.2.1-cp314-cp314-win32.whl", hash = "sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81", size = 338208, upload-time = "2026-10-22T13:58:40.927Z" },
{ url = "https://files.pythonhosted.org/packages/24/5e/80cd38322263da5e92ea4935423f1d3cfa2509201673dce77b772c47b997/brotli-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd", size = 376707, upload-time = "2026-10-22T13:58:42.025Z" },
{ url = "https://files.pythonhosted.org/packages/c5/35/cb35c489fcd0a813bbb7500465af5a7bac3bee7d996f6a211bac9096ef2c/brotli-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b", size = 390280, upload-time = "2026-10-22T13:58:43.256Z" },
{ url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" },
{ url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" },
{ url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" },
{ url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" },
{ url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" },
{ url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" },
{ url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" },
{ url = "https://files.pythonhosted.org/packages/a9/53/35042a907c309af8defb423e58692bdc5e3e1fd3d03388892b7614270626/brotli-1.2.1-cp315-cp315-win32.whl", hash = "sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c", size = 338212, upload-time = "2026-10-22T13:58:57.189Z" },
{ url = "https://files.pythonhosted.org/packages/38/6c/883a32c23603e278ae93d8df31f0573b67382de827e6e05a6e9ee0b51896/brotli-1.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399", size = 376707, upload-time = "2026-10-22T13:58:58.292Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d5/2c574da9faf130a59c4c685ba1378f2f470b57fb9fa5d3b0f8b4e90579a0/brotli-1.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae", size = 390278, upload-time = "2026-10-22T13:58:59.512Z" },
]
[[package]]
@@ -818,26 +837,28 @@ wheels = [
[[package]]
name = "django-lasuite"
version = "0.0.29"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "joserfc" },
{ name = "mozilla-django-oidc" },
{ name = "pyjwt" },
{ name = "requests" },
{ name = "requests-toolbelt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/ab/72a79b7133fb2d17835bef40f45f7e6d2b4343469c37fd00386122e9068d/django_lasuite-0.0.29.tar.gz", hash = "sha256:831cb859e45e33963c9d89168c2de14f9844e805000b0f0d1512ffd0b9731a39", size = 35248, upload-time = "2026-08-25T13:03:43.597Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/35335b826b039c43da68a0e624e154f60c3ac6c83f01f9b4ad6ce91faf03/django_lasuite-1.0.0.tar.gz", hash = "sha256:8921aaba5a78f1f9728cee2cc4596755478108f17146d4a2fccb393c043a05a3", size = 45815, upload-time = "2026-10-30T14:57:31.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/cf/86648643221816f18ebd5a3e59b6d2a85ee85b38b19c7abe939f798a2845/django_lasuite-0.0.29-py3-none-any.whl", hash = "sha256:33bcff90ced2709190ef01241badb17ceed08a32725b9cdd3105650b36da303b", size = 54899, upload-time = "2026-08-25T13:03:42.212Z" },
{ url = "https://files.pythonhosted.org/packages/a4/6f/d1df3f2881cda711e569b8e685ad3a846ca9c71226340597cee1c09beae0/django_lasuite-1.0.0-py3-none-any.whl", hash = "sha256:23d589be9496b1f484772b49f3c4c4a81e70affd537e3c50abf15303c84d7cb0", size = 68529, upload-time = "2026-10-30T14:57:30.416Z" },
]
[package.optional-dependencies]
all = [
{ name = "celery" },
{ name = "cryptography" },
{ name = "django-configurations" },
{ name = "djangorestframework" },
{ name = "joserfc" },
{ name = "mozilla-django-oidc" },
{ name = "pyjwt" },
{ name = "python-icap" },
{ name = "requests" },
{ name = "requests-toolbelt" },
]
[[package]]
@@ -910,14 +931,14 @@ wheels = [
[[package]]
name = "djangorestframework"
version = "3.18.0"
version = "3.18.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/bc/de04e3d4dc65e8b926700956ee70d4f084f2005603d21122d4d0683006fd/djangorestframework-3.18.0.tar.gz", hash = "sha256:2323a5111837e0b784dcb8323abc78ecc54fa2a5af7aff2677cf50cdd849477f", size = 913667, upload-time = "2026-08-07T14:53:33.571Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/2f048189610d58b29ae947f46d9c7d19c20ba427568eac906d6a2277d391/djangorestframework-3.18.2.tar.gz", hash = "sha256:8249bad7715eb37274ce5820fc5d9fd786b7579806589565f52a71c02b35f54b", size = 923551, upload-time = "2026-10-06T20:42:15.116Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/d7/7461738e819b853275b35fd35712a85806c09a327e1fd5f8ff766bdd317e/djangorestframework-3.18.0-py3-none-any.whl", hash = "sha256:381fc44d3249c9565c5f723850855b734e99030eb30957a49f506d3fe11d7dcb", size = 900032, upload-time = "2026-08-07T14:53:31.881Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9e/81e5bfbba54f7396233df979befde49e6e13c94c2df21b94555684dddd3e/djangorestframework-3.18.2-py3-none-any.whl", hash = "sha256:a92d6a4e128a2550bd2fe2e02008a9e47fe52c180e130bb438f5ba70ee933a3f", size = 903990, upload-time = "2026-10-06T20:42:13.411Z" },
]
[[package]]
@@ -1339,7 +1360,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.36.2"
version = "1.37.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1412,7 +1433,7 @@ requires-dist = [
{ name = "aiohttp", specifier = "==3.14.5" },
{ name = "boto3", specifier = "==1.43.80" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.1" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
{ name = "cryptography", specifier = "==50.0.1" },
{ name = "dj-database-url", specifier = "==3.1.2" },
@@ -1421,13 +1442,13 @@ requires-dist = [
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==9.0.0" },
{ name = "django-filter", specifier = "==26.1" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.29" },
{ name = "django-lasuite", extras = ["all"], specifier = "==1.0.0" },
{ name = "django-parler", specifier = "==2.4" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
{ name = "django-redis", specifier = "==7.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-timezone-field", specifier = ">=5.1" },
{ name = "djangorestframework", specifier = "==3.18.0" },
{ name = "djangorestframework", specifier = "==3.18.2" },
{ name = "dockerflow", specifier = "==2026.3.4" },
{ name = "drf-spectacular", specifier = "==0.30.0" },
{ name = "easy-thumbnails", specifier = "==2.10.1" },
@@ -2239,6 +2260,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" },
]
[[package]]
name = "python-icap"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/0a/123c30f4041eabb94c587d35fb64aa9498435d028a8577340a4e5adc5224/python_icap-0.2.0.tar.gz", hash = "sha256:627869ac29119074542de6ae95370914854fdb35ee0a13c8ae4c55847cfcb80e", size = 684516, upload-time = "2026-07-08T22:21:58.842Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/f2/b437a5e08b94dfc4cd8505cfc3e63922247e9ce3957707550ab08c514560/python_icap-0.2.0-py3-none-any.whl", hash = "sha256:b648d55b68abe387849a124d1b4a443801342a566afd2141f4515953ed4bd738", size = 70680, upload-time = "2026-07-08T22:21:57.307Z" },
]
[[package]]
name = "python-magic"
version = "0.4.27"
+6 -4
View File
@@ -1,4 +1,4 @@
FROM node:22.23.4-alpine AS frontend-deps
FROM --platform=$BUILDPLATFORM node:22.23.4-alpine AS frontend-deps
RUN apk upgrade --no-cache ca-certificates
@@ -47,13 +47,15 @@ ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.5-alpine3.24 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' && \
apk del curl
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
'brotli-libs>=1.2.1-r0' && \
apk del curl && \
rm -f /usr/share/nginx/html/50x.html
USER nginx
# Un-privileged user running the application
+12
View File
@@ -16,6 +16,17 @@ server {
add_header Cache-Control "public, max-age=2592000";
}
location = / {
try_files /home.html /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
location = /home.html {
internal;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
@@ -23,6 +34,7 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# Optionally, handle 404 errors by redirecting to index.html
+1
View File
@@ -7,6 +7,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>%VITE_APP_TITLE%</title>
</head>
<body>
+20 -17
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.36.2",
"version": "1.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.36.2",
"version": "1.37.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
@@ -21,14 +21,13 @@
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.34.1",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"livekit-client": "2.23.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.51.0",
@@ -1028,9 +1027,9 @@
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.50.4",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
"version": "1.52.1",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.52.1.tgz",
"integrity": "sha512-X8DxKlNbzk6Lh+v630+mjdFHSotdAFwIyzxSRSUql2DbTxCnlpa7JsvFGc6Na3mXiex2OFh0iiQtWwOBP0rIYA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@@ -4563,12 +4562,6 @@
}
}
},
"node_modules/crisp-sdk-web": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.1.2.tgz",
"integrity": "sha512-qxSpiT4EGHnVEO6J/t1UURY6fg1ojgquLU0IIr7RNjZxXZs1R2e6AhAiFqxiGZ13Vb4o5VoUpATFU2TzX/ppOQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -8235,16 +8228,17 @@
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.23.0.tgz",
"integrity": "sha512-j9Kg86nJjBjNFUD+ukp2jwMCGa0k+YD5gU//RzAM+ZY3pbG631QCte7fQjwqWMmtBzhDJL9nKYuOLMv/9IbAxg==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.50.4",
"@livekit/protocol": "1.52.1",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"machina": "^7.0.0",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
@@ -8353,6 +8347,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/machina": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz",
"integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==",
"license": "MIT",
"engines": {
"node": ">=22.22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.36.2",
"version": "1.37.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -29,14 +29,13 @@
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.34.1",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"livekit-client": "2.23.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.51.0",
+18
View File
@@ -0,0 +1,18 @@
User-agent: GPTBot
User-agent: OAI-SearchBot
User-agent: ChatGPT-User
User-agent: ClaudeBot
User-agent: Claude-SearchBot
User-agent: Claude-User
User-agent: PerplexityBot
User-agent: Perplexity-User
User-agent: Google-Extended
User-agent: Applebot-Extended
User-agent: CCBot
User-agent: Meta-ExternalAgent
User-agent: Amazonbot
User-agent: Bytespider
Disallow: /
User-agent: *
Allow: /
+10 -1
View File
@@ -13,7 +13,9 @@ export interface ApiConfig {
flags_api_host?: string
}
support?: {
id: string
widget_script_url?: string
widget_api_url?: string
widget_channel?: string
help_article_transcript: string
help_article_recording: string
help_article_more_tools: string
@@ -73,6 +75,13 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_video_codec: VideoCodec
reconnect?: {
backoff_enabled: boolean
base_delay_ms: number
max_delay_ms: number
give_up_after_ms: number
backoff_reset_ms: number
}
}
transcription_destination?: string
max_participants_for_sound: number
@@ -1,6 +1,5 @@
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
import { useSyncUserPreferencesWithBackend } from '@/features/auth/api/useSyncUserPreferencesWithBackend'
import { useEffect } from 'react'
@@ -8,10 +7,9 @@ export const AppInitialization = () => {
const { data } = useConfig()
useSyncUserPreferencesWithBackend()
const { analytics = {}, support = {}, custom_css_url = '' } = data ?? {}
const { analytics = {}, custom_css_url = '' } = data ?? {}
useAnalytics(analytics)
useSupport(support)
useEffect(() => {
if (custom_css_url) {
@@ -2,12 +2,6 @@ import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
// livekit-client leaks the raw WebSocket error Event as an unhandled
// rejection when the signal ws errors after connect (Firefox-heavy,
// coincides with signal reconnects). Carries zero diagnostic content —
// the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/,
]
const shouldIgnoreException = (value: unknown): boolean =>
@@ -32,6 +32,7 @@ export type LogCode =
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'support_widget_failure'
| 'generic_failure'
export const reportError = (
@@ -1,5 +1,4 @@
import { apiUrl } from '@/api/apiUrl'
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
const logoutUrl = () => {
@@ -8,6 +7,5 @@ const logoutUrl = () => {
export const logout = async () => {
await terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl()
}
@@ -96,6 +96,8 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionInterrupted:
case NotificationType.ScreenRecordingInterrupted:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
@@ -10,6 +10,7 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionInterrupted = 'transcriptionInterrupted',
TranscriptionFailed = 'transcriptionFailed',
TranscriptionAborted = 'transcriptionAborted',
TranscriptionRequested = 'transcriptionRequested',
@@ -17,6 +18,7 @@ export enum NotificationType {
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
ScreenRecordingInterrupted = 'screenRecordingInterrupted',
ScreenRecordingFailed = 'screenRecordingFailed',
ScreenRecordingAborted = 'screenRecordingAborted',
RecordingSaving = 'recordingSaving',
@@ -22,6 +22,8 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.TranscriptionInterrupted:
return 'transcript.interrupted'
case NotificationType.TranscriptionFailed:
return 'transcript.failed'
case NotificationType.TranscriptionAborted:
@@ -32,6 +34,8 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
case NotificationType.ScreenRecordingInterrupted:
return 'screenRecording.interrupted'
case NotificationType.ScreenRecordingFailed:
return 'screenRecording.failed'
case NotificationType.ScreenRecordingAborted:
@@ -58,6 +58,8 @@ const renderToast = (
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
case NotificationType.TranscriptionInterrupted:
case NotificationType.ScreenRecordingInterrupted:
case NotificationType.TranscriptionFailed:
case NotificationType.ScreenRecordingFailed:
case NotificationType.TranscriptionAborted:
@@ -12,6 +12,10 @@ export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
{ onlySubscribed: false }
)
const hasTracks =
mode === RecordingMode.Transcript
? tracks.some((track) => !track.publication.isMuted)
: tracks.length > 0
const [delayElapsed, setDelayElapsed] = useState(false)
useEffect(() => {
@@ -25,5 +29,5 @@ export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
return () => window.clearTimeout(timeout)
}, [isStarting, mode])
return isStarting && delayElapsed && tracks.length === 0
return isStarting && delayElapsed && !hasTracks
}
@@ -10,7 +10,6 @@ import {
ConnectionErrorReason,
DisconnectReason,
MediaDeviceFailure,
Room,
type RoomOptions,
VideoPresets,
} from 'livekit-client'
@@ -46,6 +45,26 @@ import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { MeetDevtools } from '@/features/devtools'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
import { ConnectionEndReason } from '@/features/rooms/utils/connectionEndReason'
import { createLiveKitRoom } from '@/features/rooms/livekit/utils/reconnectPolicy'
const handleError = (e: Error) => {
const failure = getMediaDeviceFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, {
path: 'connect_publish',
})
}
export const Conference = ({
roomId,
@@ -136,7 +155,11 @@ export const Conference = ({
apiConfig?.livekit.default_video_codec,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
const reconnect = apiConfig?.livekit.reconnect
const room = useMemo(
() => createLiveKitRoom(roomOptions, reconnect),
[roomOptions, reconnect]
)
useEffect(() => {
/**
@@ -186,6 +209,7 @@ export const Conference = ({
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
const hasConnectedRef = useRef(false)
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
@@ -248,24 +272,9 @@ export const Conference = ({
className={css({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
const failure = getMediaDeviceFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, {
path: 'connect_publish',
})
}}
onError={handleError}
onConnected={async () => {
hasConnectedRef.current = true
if (!apiConfig) return
if (
userPreferencesSnap.is_auto_mute_large_room_enabled &&
@@ -307,6 +316,20 @@ export const Conference = ({
}
)
return
default:
// Reconnecting gave up, or the join itself failed
navigateTo(
'feedback',
{},
{
state: {
reason: hasConnectedRef.current
? ConnectionEndReason.Lost
: ConnectionEndReason.Failed,
...metadata,
},
}
)
}
}}
>
@@ -1,17 +1,29 @@
import { useConfig } from '@/api/useConfig'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import {
useConnectionState,
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import {
ConnectionState,
DisconnectReason,
type Room,
RoomEvent,
} from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { BackoffReconnectPolicy } from '@/features/rooms/livekit/utils/reconnectPolicy'
const getBackoffPolicy = (room: Room) => {
const policy = room.options.reconnectPolicy
return policy instanceof BackoffReconnectPolicy ? policy : undefined
}
export const ConnectionObserver = () => {
const room = useRoomContext()
@@ -31,6 +43,7 @@ export const ConnectionObserver = () => {
RoomEvent.ParticipantDisconnected,
],
})
const connectionState = useConnectionState(room)
useEffect(() => {
// Always clear existing timer on dependency change
@@ -48,6 +61,10 @@ export const ConnectionObserver = () => {
return
}
// A full reconnection removes every remote participant until it
// succeeds: being alone then doesn't mean being idle
if (connectionState !== ConnectionState.Connected) return
if (remoteParticipants.length === 0) {
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
connectionObserverStore.isIdleDisconnectModalOpen = true
@@ -64,6 +81,7 @@ export const ConnectionObserver = () => {
}
}, [
remoteParticipants.length,
connectionState,
data?.idle_disconnect_warning_delay,
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
@@ -89,11 +107,13 @@ export const ConnectionObserver = () => {
}
const handleReconnected = async () => {
const durationMs = getBackoffPolicy(room)?.lastReconnectionDurationMs
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
durationMs,
})
}
@@ -116,9 +136,12 @@ export const ConnectionObserver = () => {
sessionDuration: connectionStartTimeRef.current
? connectionEndTime - connectionStartTimeRef.current
: -1,
reason: disconnectReason
? DisconnectReason[disconnectReason]
: 'UNKNOWN',
// LiveKit reports no reason when the reconnect policy gives up
reason: getBackoffPolicy(room)?.gaveUp
? 'RECONNECT_GAVE_UP'
: disconnectReason
? DisconnectReason[disconnectReason]
: 'UNKNOWN',
})
}
@@ -2,23 +2,18 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import {
useIsSupportEnabled,
openSupportChat,
} from '@/features/support/hooks/useSupport'
import { useSupportWidget } from '@/features/support/hooks/useSupportWidget'
export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled()
const { isEnabled, open } = useSupportWidget()
if (!isSupportEnabled) {
return
}
if (!isEnabled) return
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={openSupportChat}
onAction={open}
>
<RiQuestionLine size={20} />
{t('support')}
@@ -0,0 +1,273 @@
import { ConnectionState, RoomEvent, type Room } from 'livekit-client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
BackoffReconnectPolicy,
createLiveKitRoom,
MAX_FIRST_DELAY_MS,
} from './reconnectPolicy'
const CONFIG = {
backoff_enabled: true,
base_delay_ms: 1_000,
max_delay_ms: 15_000,
give_up_after_ms: 300_000,
backoff_reset_ms: 120_000,
}
const createRoom = () => {
const listeners = new Map<RoomEvent, Array<(...args: unknown[]) => void>>()
const room = {
on(event: RoomEvent, listener: (...args: unknown[]) => void) {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
return room
},
emit(event: RoomEvent, ...args: unknown[]) {
listeners.get(event)?.forEach((listener) => listener(...args))
},
setState(state: ConnectionState, event?: RoomEvent) {
room.emit(RoomEvent.ConnectionStateChanged, state)
if (event) room.emit(event)
},
}
return room
}
const setup = ({ config = CONFIG, random = 0.5 } = {}) => {
const clock = { now: 0 }
vi.spyOn(Math, 'random').mockReturnValue(random)
vi.spyOn(performance, 'now').mockImplementation(() => clock.now)
const room = createRoom()
const policy = new BackoffReconnectPolicy(config)
policy.attach(room as unknown as Room)
const delay = (retryCount: number, elapsedMs = 0) =>
policy.nextRetryDelayInMs({ retryCount, elapsedMs })
return { clock, room, policy, delay }
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('BackoffReconnectPolicy', () => {
it('doubles the delay ceiling after each failed attempt, up to the cap', () => {
const { delay } = setup()
expect([0, 1, 2, 3, 4, 10].map((retryCount) => delay(retryCount))).toEqual([
500, 1_500, 3_000, 6_000, 11_250, 11_250,
])
})
it('draws the first delay anywhere below its ceiling (check two extremes)', () => {
expect(setup({ random: 0 }).delay(0)).toBe(0)
expect(setup({ random: 0.9999 }).delay(0)).toBe(999)
})
it('draws the next delays between half their ceiling and their ceiling (check two extremes)', () => {
expect(setup({ random: 0 }).delay(3)).toBe(4_000)
expect(setup({ random: 0.9999 }).delay(3)).toBe(7_999)
})
it('keeps the first delay of a reconnection under 5 s', () => {
const config = { ...CONFIG, base_delay_ms: 8_000 }
const { delay } = setup({ config, random: 0.9999 })
expect(delay(0)).toBeLessThan(MAX_FIRST_DELAY_MS)
expect(delay(1)).toBe(14_999)
})
it('starts the first attempt within 5 s of the drop', () => {
const config = { ...CONFIG, base_delay_ms: 8_000 }
const { clock, delay } = setup({ config })
expect(delay(0)).toBe(2_500)
clock.now = 4_000
expect(delay(0)).toBe(500)
clock.now = 6_000
expect(delay(0)).toBe(0)
})
it('never steps the backoff down within a reconnection', () => {
const { delay } = setup()
expect(delay(3)).toBe(6_000)
expect(delay(0)).toBe(6_000)
})
it('ignores the elapsed time reported by LiveKit', () => {
const { delay } = setup()
expect(delay(1, Date.now())).toBe(1_500)
})
it('picks up the backoff of a reconnection that ended shortly before', () => {
const { clock, room, delay } = setup()
delay(0)
delay(1)
delay(2)
room.emit(RoomEvent.Reconnected)
clock.now += 60_000
// One step above where the last reconnection ended (capped)
expect(delay(0)).toBe(2_500)
expect(delay(1)).toBe(11_250)
})
it('starts the backoff over once the connection held for the reset delay', () => {
const { clock, room, delay } = setup()
delay(0)
delay(1)
delay(2)
room.emit(RoomEvent.Reconnected)
clock.now += 120_000
expect(delay(0)).toBe(500)
})
it('gives up once the reconnection lasts longer than the give-up delay', () => {
const { clock, room, policy, delay } = setup()
delay(0)
room.setState(ConnectionState.Reconnecting, RoomEvent.Reconnecting)
clock.now = 299_999
expect(delay(20)).toBe(11_250)
expect(policy.gaveUp).toBe(false)
clock.now = 300_000
expect(delay(21)).toBeNull()
expect(policy.gaveUp).toBe(true)
})
it('keeps trying while only signaling is down', () => {
const { clock, room, policy, delay } = setup()
delay(0)
room.setState(
ConnectionState.SignalReconnecting,
RoomEvent.SignalReconnecting
)
clock.now = 600_000
expect(delay(40)).toBe(11_250)
expect(policy.gaveUp).toBe(false)
// The media connection failed as well
room.setState(ConnectionState.Reconnecting, RoomEvent.Reconnecting)
expect(delay(41)).toBeNull()
expect(policy.gaveUp).toBe(true)
})
it('gives up once the media connection failed, even back to resuming', () => {
const { clock, room, policy, delay } = setup()
delay(0)
room.setState(
ConnectionState.SignalReconnecting,
RoomEvent.SignalReconnecting
)
delay(1)
// The peer connections fail, then LiveKit tries to resume again
clock.now = 30_000
room.setState(ConnectionState.Reconnecting, RoomEvent.Reconnecting)
room.setState(
ConnectionState.SignalReconnecting,
RoomEvent.SignalReconnecting
)
clock.now = 299_999
expect(delay(20)).toBe(11_250)
expect(policy.gaveUp).toBe(false)
clock.now = 300_000
expect(delay(21)).toBeNull()
expect(policy.gaveUp).toBe(true)
})
it('starts a new reconnection once the room connects again', () => {
const { clock, room, policy, delay } = setup()
delay(0)
room.setState(ConnectionState.Reconnecting, RoomEvent.Reconnecting)
clock.now = 300_000
expect(delay(21)).toBeNull()
room.setState(ConnectionState.Disconnected, RoomEvent.Disconnected)
expect(policy.gaveUp).toBe(true)
room.setState(ConnectionState.Connecting)
room.setState(ConnectionState.Connected, RoomEvent.Connected)
expect(policy.gaveUp).toBe(false)
clock.now += 600_000
expect(delay(0)).toBe(500)
// The media connection of the previous reconnection is not lost anymore
room.setState(
ConnectionState.SignalReconnecting,
RoomEvent.SignalReconnecting
)
clock.now += 300_000
expect(delay(20)).toBe(11_250)
})
it('starts over when a new join replaces the reconnection', () => {
const { clock, room, policy, delay } = setup()
delay(0)
delay(3)
room.setState(ConnectionState.Reconnecting, RoomEvent.Reconnecting)
// LiveKitRoom calls room.connect() again in the middle of the reconnection
clock.now = 400_000
room.setState(ConnectionState.Connecting)
expect(delay(0)).toBe(500)
expect(policy.gaveUp).toBe(false)
})
it('reports how long a reconnection lasted, once', () => {
const { clock, room, policy, delay } = setup()
clock.now = 1_000
delay(0)
clock.now = 4_000
room.emit(RoomEvent.Reconnected)
expect(policy.lastReconnectionDurationMs).toBe(3_000)
// LiveKit can emit Reconnected several times for one recovery
room.emit(RoomEvent.Reconnected)
expect(policy.lastReconnectionDurationMs).toBeUndefined()
})
})
describe('createLiveKitRoom', () => {
it('builds the policy from the config endpoint values', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5)
const { reconnectPolicy } = createLiveKitRoom({}, CONFIG).options
expect(reconnectPolicy).toBeInstanceOf(BackoffReconnectPolicy)
expect(
reconnectPolicy.nextRetryDelayInMs({ retryCount: 2, elapsedMs: 0 })
).toBe(3_000)
})
it('keeps the default policy when the backoff is missing or disabled', () => {
for (const config of [undefined, { ...CONFIG, backoff_enabled: false }]) {
expect(
createLiveKitRoom({}, config).options.reconnectPolicy
).not.toBeInstanceOf(BackoffReconnectPolicy)
}
})
})
@@ -0,0 +1,146 @@
import {
ConnectionState,
Room,
RoomEvent,
type ReconnectContext,
type ReconnectPolicy,
type RoomOptions,
} from 'livekit-client'
import type { ApiConfig } from '@/api/useConfig'
/**
* Until the first attempt of a reconnection starts, the room still reports
* Connected and LiveKit keeps checking its transport for a while.
* Around 10s after the initial drop, it will try a full websocket reconnection.
* Hence, the first attempt should start well before that.
*/
export const MAX_FIRST_DELAY_MS = 5_000
type ReconnectConfig = NonNullable<ApiConfig['livekit']['reconnect']>
type Reconnection = {
startedAt: number
// Higher than 0 when the previous reconnection ended shortly before
firstStep: number
failedAttempts: number
isMediaLost: boolean
gaveUp: boolean
}
/**
* When a LiveKit node crashes or Redis stalls, every client loses its signal
* connection at the same time. This policy implements an exponential backoff
* with some randomness to spread the reconnections out.
*
* The policy keeps its own clock since LiveKit calls the policy again on every
* new disconnection trigger
*/
export class BackoffReconnectPolicy implements ReconnectPolicy {
lastReconnectionDurationMs: number | undefined
private readonly config: ReconnectConfig
private reconnection: Reconnection | undefined
private lastRecovery: { at: number; step: number } | undefined
constructor(config: ReconnectConfig) {
this.config = config
}
attach(room: Room) {
room.on(RoomEvent.Connected, this.handleRecovery)
room.on(RoomEvent.Reconnected, this.handleRecovery)
room.on(RoomEvent.Reconnecting, this.handleMediaLost)
room.on(RoomEvent.ConnectionStateChanged, this.handleStateChange)
}
get gaveUp() {
return this.reconnection?.gaveUp ?? false
}
nextRetryDelayInMs({ retryCount }: ReconnectContext): number | null {
const now = performance.now()
this.reconnection ??= this.startReconnection(now)
const reconnection = this.reconnection
reconnection.failedAttempts = Math.max(
reconnection.failedAttempts,
retryCount
)
if (
now - reconnection.startedAt >= this.config.give_up_after_ms &&
reconnection.isMediaLost
) {
reconnection.gaveUp = true
return null
}
const ceiling = Math.min(
this.config.max_delay_ms,
this.config.base_delay_ms *
2 ** (reconnection.firstStep + reconnection.failedAttempts)
)
if (reconnection.failedAttempts === 0) {
// LiveKit re-arms its timer on each trigger: count from the first drop
const timeLeft = reconnection.startedAt + MAX_FIRST_DELAY_MS - now
return Math.floor(
Math.random() * Math.min(ceiling, Math.max(timeLeft, 0))
)
}
return Math.floor((ceiling / 2) * (1 + Math.random()))
}
private startReconnection(now: number): Reconnection {
const recovery = this.lastRecovery
const isLastDisconnectionRecent =
recovery !== undefined && now - recovery.at < this.config.backoff_reset_ms
return {
startedAt: now,
firstStep: isLastDisconnectionRecent ? recovery.step + 1 : 0,
failedAttempts: 0,
isMediaLost: false,
gaveUp: false,
}
}
private handleMediaLost = () => {
this.reconnection ??= this.startReconnection(performance.now())
this.reconnection.isMediaLost = true
}
private handleStateChange = (state: ConnectionState) => {
if (state === ConnectionState.Connecting) this.reconnection = undefined
}
private handleRecovery = () => {
const now = performance.now()
if (this.reconnection) {
const { startedAt, firstStep, failedAttempts } = this.reconnection
this.lastRecovery = { at: now, step: firstStep + failedAttempts }
this.lastReconnectionDurationMs = now - startedAt
this.reconnection = undefined
} else {
// LiveKit can emit Reconnected several times for a full reconnection
// We want to avoid duplicating the reconnection duration in PostHog
this.lastReconnectionDurationMs = undefined
}
}
}
/**
* Build a room with the reconnection policy from the config endpoint.
* Without it, the room keeps the LiveKit default policy
*/
export const createLiveKitRoom = (
options: RoomOptions,
config?: ReconnectConfig
): Room => {
if (!config?.backoff_enabled) return new Room(options)
const reconnectPolicy = new BackoffReconnectPolicy(config)
const room = new Room({ ...options, reconnectPolicy })
reconnectPolicy.attach(room)
return room
}
@@ -7,6 +7,7 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import { ConnectionEndReason } from '@/features/rooms/utils/connectionEndReason'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -32,6 +33,8 @@ enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
RoomDeleted = 'roomDeleted',
ConnectionLost = 'connectionLost',
ConnectionFailed = 'connectionFailed',
}
const FeedbackRoute = () => {
@@ -49,6 +52,10 @@ const FeedbackRoute = () => {
return DisconnectReasonKey.ParticipantRemoved
case DisconnectReason.ROOM_DELETED:
return DisconnectReasonKey.RoomDeleted
case ConnectionEndReason.Lost:
return DisconnectReasonKey.ConnectionLost
case ConnectionEndReason.Failed:
return DisconnectReasonKey.ConnectionFailed
}
}, [])
@@ -91,7 +98,9 @@ const FeedbackRoute = () => {
{t('feedback.home')}
</Button>
</Stack>
<Rating metadata={metadata} />
{reasonKey !== DisconnectReasonKey.ConnectionFailed && (
<Rating metadata={metadata} />
)}
</VStack>
</Center>
</Screen>
@@ -0,0 +1,7 @@
/**
* Why a room ended when no more specific reason applies
*/
export enum ConnectionEndReason {
Failed = 'connectionFailed',
Lost = 'connectionLost',
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { RiQuestionLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { useSupportWidget } from '../hooks/useSupportWidget'
export const SupportButton = () => {
const { t } = useTranslation('global', { keyPrefix: 'support' })
const { isEnabled, isPending, open } = useSupportWidget()
if (!isEnabled) return null
return (
<Button
square
variant="secondaryText"
aria-label={t('buttonLabel')}
tooltip={t('buttonLabel')}
isPending={isPending}
onPress={open}
>
{isPending ? <Spinner size={24} /> : <RiQuestionLine />}
</Button>
)
}
@@ -0,0 +1,73 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { useLocation } from 'wouter'
import { RiCheckboxCircleFill, RiCloseLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { announceToScreenReader } from '@/stores/screenReaderAnnouncer'
import { supportStore } from '@/stores/support'
import { subscribeToSupportWidget } from '../utils/supportWidget'
import { ToastDuration } from '@/features/notifications/NotificationDuration'
import { StyledToast } from '@/features/notifications/components/StyledToast'
import { StyledToastContainer } from '@/features/notifications/components/StyledToastContainer'
const hide = () => {
supportStore.showSentConfirmation = false
}
export const SupportWidget = () => {
const { t } = useTranslation('global', { keyPrefix: 'support.sent' })
const { showSentConfirmation } = useSnapshot(supportStore)
const [location] = useLocation()
useEffect(() => subscribeToSupportWidget(), [])
useEffect(hide, [location])
useEffect(() => {
if (!showSentConfirmation) return
announceToScreenReader(`${t('title')}. ${t('body')}`)
const timeout = window.setTimeout(hide, ToastDuration.EXTRA_LONG)
return () => window.clearTimeout(timeout)
}, [showSentConfirmation, t])
if (!showSentConfirmation) return null
return (
<div
className={css({
position: 'fixed',
top: '5rem',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
})}
>
<StyledToastContainer>
<StyledToast>
<RiCheckboxCircleFill
size={24}
aria-hidden="true"
className={css({ color: 'success.300', flexShrink: 0 })}
/>
<div>
<Text margin={false} bold>
{t('title')}
</Text>
<Text margin={false}>{t('body')}</Text>
</div>
<Button
square
size="sm"
invisible
aria-label={t('close')}
onPress={hide}
>
<RiCloseLine color="white" />
</Button>
</StyledToast>
</StyledToastContainer>
</div>
)
}
@@ -1,125 +0,0 @@
import { useEffect, useState } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
let crisp: CrispSdk | undefined
let crispPromise: Promise<CrispSdk> | undefined
const loadCrisp = (): Promise<CrispSdk> => {
crispPromise ??= import('crisp-sdk-web')
.then((module) => {
crisp = module.Crisp
return module.Crisp
})
.catch((error) => {
crispPromise = undefined
throw error
})
return crispPromise
}
export const openSupportChat = () => {
if (!crisp?.isCrispInjected()) return
crisp.chat.open()
}
export const initializeSupportSession = (user: ApiUser) => {
if (!crisp?.isCrispInjected()) return
const { id, email } = user
crisp.setTokenId(`meet-${id}`)
if (email) crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!crisp?.isCrispInjected()) return
crisp.setTokenId()
crisp.session.reset()
}
export type useSupportProps = {
id?: string
isDisabled?: boolean
}
const IDLE_TIMEOUT_MS = 10_000
const scheduleWhenIdle = (callback: () => void): (() => void) => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, {
timeout: IDLE_TIMEOUT_MS,
})
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(callback, 1)
return () => window.clearTimeout(handle)
}
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => {
if (!id || isDisabled) return
if (crisp?.isCrispInjected()) {
setIsInjected(true)
return
}
let cancelled = false
const cancelIdle = scheduleWhenIdle(() => {
void loadCrisp()
.then((sdk) => {
if (cancelled) return
if (!sdk.isCrispInjected()) {
sdk.configure(id)
sdk.setHideOnMobile(true)
}
setIsInjected(true)
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to initialize support chat', error)
}
})
})
return () => {
cancelled = true
cancelIdle()
}
}, [id, isDisabled])
useEffect(() => {
if (!user || !isInjected || isDisabled) return
initializeSupportSession(user)
}, [user, isInjected, isDisabled])
return null
}
// Some users block the chat widget, so check its availability safely.
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
} catch {
return false
}
}
export const useIsSupportEnabled = () => {
const { data } = useConfig()
return !!data?.support?.id && isCrispAvailable()
}
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { useConfig } from '@/api/useConfig'
import { useUser } from '@/features/auth/api/useUser'
import { reportError } from '@/features/analytics/telemetry'
import { announceToScreenReader } from '@/stores/screenReaderAnnouncer'
import { supportStore } from '@/stores/support'
import { openSupportWidget } from '../utils/supportWidget'
export const useSupportWidget = () => {
const { t, i18n } = useTranslation('global', { keyPrefix: 'support' })
const { data } = useConfig()
const { user } = useUser()
const { status } = useSnapshot(supportStore)
const {
widget_script_url: scriptUrl,
widget_api_url: api,
widget_channel: channel,
} = data?.support ?? {}
const isEnabled = !!(scriptUrl && api && channel)
const open = () => {
if (!isEnabled) return
openSupportWidget(scriptUrl, {
api,
channel,
locale: i18n.language,
email: user?.email,
}).catch((error) => {
reportError('support_widget_failure', error)
announceToScreenReader(t('openError'), 'assertive')
})
}
return {
isEnabled,
isPending: status === 'pending',
open,
}
}
@@ -0,0 +1,147 @@
import { supportStore } from '@/stores/support'
declare global {
interface Window {
_lasuite_widget?: unknown[]
}
}
type WidgetOptions = {
api: string
channel: string
locale: string
email?: string
}
const ROOT_ID = 'lasuite-widget-support-root'
const OPENED_EVENT = 'lasuite-widget-support-opened'
const SUCCESS_EVENT = 'lasuite-widget-support-success'
const CLOSED_EVENT = 'lasuite-widget-support-closed'
const TIMEOUT_MS = 10_000
const WIDGET_CSS = `@media (min-width: 48em) {
[role='dialog'] {
top: 50%;
right: auto;
bottom: auto;
left: 50%;
transform: translate(-50%, -50%);
}
}
[role='status'] {
display: none;
}`
const styleWidget = () => {
const shadowRoot = document.getElementById(ROOT_ID)?.shadowRoot
if (!shadowRoot) return
const style = document.createElement('style')
style.textContent = WIDGET_CSS
shadowRoot.appendChild(style)
}
let returnFocusTo: HTMLElement | null = null
// The widget removes the form without moving focus, which drops it on <body>.
const restoreFocus = () => {
const activeEl = document.activeElement
const isFocusLost = activeEl === document.body || activeEl?.id === ROOT_ID
if (isFocusLost && returnFocusTo?.isConnected) {
returnFocusTo.focus({ preventScroll: true })
}
}
export const subscribeToSupportWidget = () => {
const onOpened = () => {
styleWidget()
supportStore.status = 'open'
}
const onSuccess = () => {
supportStore.showSentConfirmation = true
restoreFocus()
}
const onClosed = () => {
supportStore.status = 'closed'
restoreFocus()
returnFocusTo = null
}
document.addEventListener(OPENED_EVENT, onOpened)
document.addEventListener(SUCCESS_EVENT, onSuccess)
document.addEventListener(CLOSED_EVENT, onClosed)
return () => {
document.removeEventListener(OPENED_EVENT, onOpened)
document.removeEventListener(SUCCESS_EVENT, onSuccess)
document.removeEventListener(CLOSED_EVENT, onClosed)
}
}
const resolveTrigger = () => {
const activeEl = document.activeElement as HTMLElement | null
if (activeEl?.getAttribute('role') === 'menuitem') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
return activeEl
}
let scriptPromise: Promise<void> | undefined
const loadScript = (src: string): Promise<void> => {
scriptPromise ??= new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
const timeout = window.setTimeout(
() => fail(new Error(`Timed out loading ${src}`)),
TIMEOUT_MS
)
const fail = (error: Error) => {
window.clearTimeout(timeout)
script.remove()
scriptPromise = undefined
reject(error)
}
script.src = src
script.async = true
script.onload = () => {
window.clearTimeout(timeout)
resolve()
}
script.onerror = () => fail(new Error(`Failed to load ${src}`))
document.body.appendChild(script)
})
return scriptPromise
}
const initWidget = (options: WidgetOptions): Promise<void> =>
new Promise<void>((resolve, reject) => {
const onOpened = () => {
window.clearTimeout(timeout)
resolve()
}
const timeout = window.setTimeout(() => {
document.removeEventListener(OPENED_EVENT, onOpened)
reject(new Error('Support widget did not open'))
}, TIMEOUT_MS)
document.addEventListener(OPENED_EVENT, onOpened, { once: true })
window._lasuite_widget?.push(['support', 'init', options])
})
export const openSupportWidget = async (
scriptUrl: string,
options: WidgetOptions
) => {
if (supportStore.status !== 'closed') return
supportStore.status = 'pending'
returnFocusTo = resolveTrigger()
try {
await loadScript(scriptUrl)
await initWidget(options)
} catch (error) {
supportStore.status = 'closed'
returnFocusTo = null
throw error
}
}
+2
View File
@@ -4,6 +4,7 @@ import { HStack, Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Button, Text } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { SupportButton } from '@/features/support/components/SupportButton'
import { useUser } from '@/features/auth/api/useUser'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { FeedbackBanner } from '@/components/FeedbackBanner'
@@ -153,6 +154,7 @@ export const Header = () => {
</header>
<nav>
<Stack gap={1} direction="row" align="center">
<SupportButton />
{isLoggedIn === false &&
!isHome &&
!isLegalTerms &&
+2
View File
@@ -6,6 +6,7 @@ import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
import { SupportWidget } from '@/features/support/components/SupportWidget'
export type Layout = 'fullpage' | 'centered'
@@ -46,6 +47,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
})}
>
<ScreenReaderAnnouncer />
<SupportWidget />
{children}
</main>
</div>
+9
View File
@@ -27,6 +27,15 @@
},
"selected": "ausgewählt",
"submit": "OK",
"support": {
"buttonLabel": "Support kontaktieren",
"openError": "Das Kontaktformular konnte nicht geöffnet werden. Bitte versuchen Sie es später erneut.",
"sent": {
"title": "Nachricht gesendet",
"body": "Das Support-Team hat Ihre Nachricht erhalten.",
"close": "Schließen"
}
},
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
@@ -37,6 +37,7 @@
"started": "{{name}} hat die Meeting-Transkription gestartet.",
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"interrupted": "Die Transkription wurde unerwartet beendet, der bereits aufgezeichnete Teil wird gespeichert.",
"failed": "Die Transkription wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Transkription konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Transkription starten."
@@ -46,6 +47,7 @@
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"interrupted": "Die Aufzeichnung wurde unerwartet beendet, der bereits aufgezeichnete Teil wird gespeichert.",
"failed": "Die Aufzeichnung wurde unerwartet beendet und konnte nicht gespeichert werden.",
"aborted": "Die Aufzeichnung konnte nicht gestartet werden.",
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
+3 -1
View File
@@ -4,7 +4,9 @@
"normal": "Du hast das Meeting verlassen",
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt",
"roomDeleted": "Dieses Meeting wurde gelöscht"
"roomDeleted": "Dieses Meeting wurde gelöscht",
"connectionLost": "Die Verbindung zum Meeting wurde unterbrochen",
"connectionFailed": "Beitritt zum Meeting nicht möglich"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
+9
View File
@@ -27,6 +27,15 @@
},
"selected": "selected",
"submit": "OK",
"support": {
"buttonLabel": "Contact support",
"openError": "The contact form could not be opened. Please try again later.",
"sent": {
"title": "Message sent",
"body": "The support team has received your message.",
"close": "Close"
}
},
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
@@ -37,6 +37,7 @@
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"interrupted": "The transcription stopped unexpectedly, the part already recorded will be saved.",
"failed": "The transcription stopped unexpectedly and could not be saved.",
"aborted": "The transcription could not be started.",
"requested": "{{name}} wants to start the meeting transcription."
@@ -46,6 +47,7 @@
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
"interrupted": "The recording stopped unexpectedly, the part already recorded will be saved.",
"failed": "The recording stopped unexpectedly and could not be saved.",
"aborted": "The recording could not be started.",
"requested": "{{name}} wants to start the meeting recording."
+3 -1
View File
@@ -4,7 +4,9 @@
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by a host",
"roomDeleted": "This meeting has been deleted"
"roomDeleted": "This meeting has been deleted",
"connectionLost": "The connection to the meeting was lost",
"connectionFailed": "Unable to join the meeting"
},
"home": "Return to home",
"back": "Rejoin the meeting"
+9
View File
@@ -27,6 +27,15 @@
},
"selected": "seleccionado",
"submit": "Aceptar",
"support": {
"buttonLabel": "Contactar con el soporte",
"openError": "No se ha podido abrir el formulario de contacto. Inténtelo de nuevo más tarde.",
"sent": {
"title": "Mensaje enviado",
"body": "El equipo de soporte ha recibido su mensaje.",
"close": "Cerrar"
}
},
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
@@ -37,6 +37,7 @@
"started": "{{name}} ha iniciado la transcripción de la reunión.",
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
"interrupted": "La transcripción se ha interrumpido de forma inesperada, se va a guardar la parte ya grabada.",
"failed": "La transcripción se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la transcripción.",
"requested": "{{name}} ha solicitado iniciar la transcripción."
@@ -46,6 +47,7 @@
"started": "{{name}} ha iniciado la grabación de la reunión.",
"stopped": "{{name}} ha detenido la grabación de la reunión.",
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
"interrupted": "La grabación se ha interrumpido de forma inesperada, se va a guardar la parte ya grabada.",
"failed": "La grabación se ha interrumpido de forma inesperada y no se ha podido guardar.",
"aborted": "No se ha podido iniciar la grabación.",
"requested": "{{name}} ha solicitado iniciar la grabación."
+3 -1
View File
@@ -4,7 +4,9 @@
"normal": "Has salido de la reunión",
"duplicateIdentity": "Te has unido a la reunión desde otro dispositivo",
"participantRemoved": "Un administrador te ha expulsado de la llamada",
"roomDeleted": "Esta reunión ha sido eliminada"
"roomDeleted": "Esta reunión ha sido eliminada",
"connectionLost": "Se ha perdido la conexión con la reunión",
"connectionFailed": "No se ha podido acceder a la reunión"
},
"home": "Volver al inicio",
"back": "Volver a la reunión"
+9
View File
@@ -27,6 +27,15 @@
},
"selected": "sélectionné",
"submit": "OK",
"support": {
"buttonLabel": "Contacter l'aide",
"openError": "Impossible d'ouvrir le formulaire de contact. Réessayez plus tard.",
"sent": {
"title": "Message envoyé",
"body": "L'équipe support a bien reçu votre message.",
"close": "Fermer"
}
},
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
@@ -37,6 +37,7 @@
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"interrupted": "La transcription s'est interrompue de façon inattendue, la partie déjà enregistrée va être sauvegardée.",
"failed": "La transcription s'est interrompue de façon inattendue et n'a pas pu être sauvegardée.",
"aborted": "La transcription n'a pas pu être démarrée.",
"requested": "{{name}} a demandé à démarrer la transcription."
@@ -46,6 +47,7 @@
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
"interrupted": "L'enregistrement s'est interrompu de façon inattendue, la partie déjà enregistrée va être sauvegardée.",
"failed": "L'enregistrement s'est interrompu de façon inattendue et n'a pas pu être sauvegardé.",
"aborted": "L'enregistrement n'a pas pu être démarré.",
"requested": "{{name}} a demandé à démarrer l'enregistrement."
+3 -1
View File
@@ -4,7 +4,9 @@
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur",
"roomDeleted": "Cette réunion a été supprimée"
"roomDeleted": "Cette réunion a été supprimée",
"connectionLost": "La connexion à la réunion a été perdue",
"connectionFailed": "Impossible de rejoindre la réunion"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
+9
View File
@@ -26,6 +26,15 @@
},
"selected": "geselecteerd",
"submit": "OK",
"support": {
"buttonLabel": "Contact ondersteuning",
"openError": "Het contactformulier kon niet worden geopend. Probeer het later opnieuw.",
"sent": {
"title": "Bericht verzonden",
"body": "Het supportteam heeft je bericht ontvangen.",
"close": "Sluiten"
}
},
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
@@ -37,6 +37,7 @@
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"interrupted": "De transcriptie is onverwacht gestopt, het al opgenomen deel wordt opgeslagen.",
"failed": "De transcriptie is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De transcriptie kon niet worden gestart.",
"requested": "{{name}} wil graag de transcriptie starten."
@@ -46,6 +47,7 @@
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"interrupted": "De opname is onverwacht gestopt, het al opgenomen deel wordt opgeslagen.",
"failed": "De opname is onverwacht gestopt en kon niet worden opgeslagen.",
"aborted": "De opname kon niet worden gestart.",
"requested": "{{name}} wil graag de opname starten."
+3 -1
View File
@@ -4,7 +4,9 @@
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd",
"roomDeleted": "Deze vergadering is verwijderd"
"roomDeleted": "Deze vergadering is verwijderd",
"connectionLost": "De verbinding met de vergadering is verbroken",
"connectionFailed": "Deelnemen aan de vergadering is niet gelukt"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
+13
View File
@@ -0,0 +1,13 @@
import { proxy } from 'valtio'
export type SupportWidgetStatus = 'closed' | 'pending' | 'open'
type State = {
status: SupportWidgetStatus
showSentConfirmation: boolean
}
export const supportStore = proxy<State>({
status: 'closed',
showSentConfirmation: false,
})
-8
View File
@@ -72,14 +72,6 @@ main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
}
}
body:has(.lk-video-conference) #crisp-chatbox-button {
display: none !important;
}
body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
display: none !important;
}
html:has(.lk-video-conference) {
scrollbar-gutter: auto !important;
}
+21 -2
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { defineConfig, loadEnv } from 'vite'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { defineConfig, loadEnv, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr'
@@ -25,6 +26,23 @@ if (mediapipeVersion !== livekitMediapipeVersion) {
)
}
const NOINDEX_TAG = '<meta name="robots" content="noindex, nofollow" />'
const homePage = (): Plugin => ({
name: 'home-page',
apply: 'build',
writeBundle({ dir = 'dist' }) {
const html = readFileSync(join(dir, 'index.html'), 'utf-8')
if (!html.includes(NOINDEX_TAG)) {
throw new Error(
`${dir}/index.html does not contain ${NOINDEX_TAG}, so home.html ` +
`cannot be generated from it. The two must stay in sync.`
)
}
writeFileSync(join(dir, 'home.html'), html.replace(NOINDEX_TAG, ''))
},
})
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
@@ -66,6 +84,7 @@ export default defineConfig(({ mode }) => {
},
],
}),
homePage(),
env.VITE_ANALYZE === 'true' &&
visualizer({
open: true,
+1 -1
View File
@@ -163,7 +163,7 @@ backend:
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
FRONTEND_SUPPORT: "{'help_article_transcript': 'https://docs.numerique.gouv.fr/docs/ba213749-2379-421c-b2ea-e4bd0840ea9b', 'help_article_recording': 'https://docs.numerique.gouv.fr/docs/a4e12e41-2974-472c-84a9-c00d12c10769', 'help_article_more_tools': 'https://docs.numerique.gouv.fr/docs/75fa98c7-5af1-433e-a611-e38143db26d0'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
FRONTEND_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/7c5bd65d-3c21-486f-bce1-26e0a921d642/"
FRONTEND_TECHNICAL_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
@@ -1,8 +1,12 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
image:
tag: v1.15.0
egress:
log_level: debug
logging:
level: debug
ws_url: ws://livekit-livekit-server:80
insecure: true
enable_chrome_sandbox: true
@@ -2,10 +2,11 @@ replicaCount: 1
terminationGracePeriodSeconds: 18000
image:
tag: v1.11.0
tag: v1.15.0
egress:
log_level: debug
logging:
level: debug
ws_url: ws://livekit-livekit-server:80
insecure: true
enable_chrome_sandbox: true
@@ -51,6 +51,7 @@ data:
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
add_header X-Robots-Tag "noindex, nofollow" always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
@@ -84,6 +85,17 @@ data:
add_header Cache-Control "public, max-age=2592000";
}
location = / {
try_files /home.html /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
location = /home.html {
internal;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
@@ -91,6 +103,7 @@ data:
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
add_header X-Robots-Tag "noindex, nofollow" always;
}
# Optionally, handle 404 errors by redirecting to index.html
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.36.2",
"version": "1.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.36.2",
"version": "1.37.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.36.2",
"version": "1.37.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.36.2",
"version": "1.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.36.2",
"version": "1.37.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.36.2",
"version": "1.37.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apk add --no-cache --upgrade 'ca-certificates>=20261020-r0'
# Install ffmpeg for audio/video processing (format conversion, extraction, compression)
# See summary/core/file_service.py for usage.
RUN apk add --no-cache "ffmpeg=8.1.2-r0"
RUN apk add --no-cache "ffmpeg=8.1.3-r0" 'brotli-libs>=1.2.1-r0'
FROM base AS builder
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.36.2"
version = "1.37.0"
requires-python = ">=3.13"
dependencies = [
"certifi>=2026.10.19",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.36.2"
version = "1.37.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },