Compare commits

..

43 Commits

Author SHA1 Message Date
Florent Chehab 4b3b4b9c28 🔨(docker) improve docker ignore
Tweak syntax to ignore folders recursively (important).
And add few missing ones.
2026-03-05 17:23:56 +01:00
lebaudantoine b564044e70 🔖(minor) bump release to 1.10.0 2026-03-05 14:19:25 +01:00
renovate[bot] 4717143251 ⬆️(dependencies) update django to v5.2.12 [SECURITY] 2026-03-05 12:19:47 +01:00
dependabot[bot] 805e983749 Bump @hono/node-server from 1.19.9 to 1.19.10 in /src/frontend
Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.9 to 1.19.10.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.9...v1.19.10)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-05 12:13:29 +01:00
Cyril 6dfafb7f67 💄(fix) truncate long names with ellipsis in reaction overlay
Long participant names under emoji reactions overflow without truncation.
2026-03-05 11:38:47 +01:00
lebaudantoine e56c0f997e 🩹(frontend) fix overflow in participant metadata layout
Recent styling changes introduced an overflow, causing the network
indicator to be pushed outside of the participant tile.

Remove width: 100% and add a minimal gap to prevent metadata
elements from being too close to each other.
2026-03-04 20:39:09 +01:00
lebaudantoine 61afd94e3a 🩹(frontend) enhance shortcut hint styling using PandaCSS utilities
Refactor styles to leverage PandaCSS inline capabilities for
better clarity and consistency.

Remove an unnecessary div wrapper that was causing a layout shift.
2026-03-04 20:39:09 +01:00
Cyril 3d7aec2b4a ️(frontend) announce selected state to SR in select and menu list
Add visually hidden "selected" text for screen readers.
2026-03-04 19:07:34 +01:00
dependabot[bot] 9c009839f0 Bump minimatch from 3.1.2 to 3.1.5 in /src/frontend
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 17:22:29 +01:00
lebaudantoine ec63ddcd47 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit
Create a separate Ingress resource to isolate traffic targeting the
webhook-livekit endpoint and allow applying specific NGINX
annotations to this route.

Use an exact path match to take precedence over the default /api
regex rule defined in the base Ingress.

No similar change is made for the S3 webhook endpoint, as this
dependency will be removed from the project.
2026-03-04 16:30:01 +01:00
lebaudantoine fcde8757e6 🩹(frontend) remove incorrect reference to ProConnect on the prejoin
Remove incorrect reference to ProConnect (DINUM SSO) from content
literals, where it should not be mentioned by default in the
white labeled version.

It closes #1075
2026-03-04 14:04:37 +01:00
Cyril 9610e606eb ️(frontend) prevent focus ring clipping
Change overflow from hidden to visible on invite dialog
2026-03-04 13:39:02 +01:00
Cyril 8362ac0e24 ️(frontend) shortcuts table: semantic structure and kbd badge
caption, th scope, <kbd> for keys, no Tab stops on rows
2026-03-04 12:09:38 +01:00
Cyril f1ddd7fa2f ♻️(frontend) show shortcut hint only on first grid tile via CSS
Use :first-child and :focus-within to restrict hint visibility to the first tile
2026-03-04 12:08:22 +01:00
Cyril 487340efca ♻️(frontend) move fullscreen and recording shortcuts to their components
Register Ctrl+Shift+F in DesktopControlBar, Ctrl+Shift+L in ToolsToggle
2026-03-04 12:07:39 +01:00
Cyril 7ebc928dd3 (frontend) add Ctrl+Shift+/ to open shortcuts settings
Update toolbar hint and register shortcut to open settings on shortcuts tab
2026-03-04 12:07:39 +01:00
Cyril 85de214ca7 💄(frontend) truncate pinned participant name with ellipsis on overflow
Long participant names are now truncated with an ellipsis.
2026-03-04 11:21:10 +01:00
lebaudantoine e3e34dbf31 ️(frontend) optimize countdown check in IsIdleDisconnectModal.tsx
Using Array.includes runs in O(n) on every second of the countdown.

Replace the array with a Set to achieve O(1) lookups for better
performance.
2026-03-04 10:22:29 +01:00
lebaudantoine 555afe4abd ️(frontend) fix roomId RegExp recompilation
The regex was being recreated on every function call, causing
unnecessary performance overhead.

Hoist the RegExp to a module-level constant to reuse the compiled
pattern.
2026-03-04 10:22:29 +01:00
lebaudantoine 78ddb121e3 ️(frontend) avoid recreating inline array props in VideoTab.tsx
The items array was defined inline, creating a new reference on
every render.

Hoist the array to a module-level constant or memoize it with
useMemo to prevent unnecessary re-renders.
2026-03-04 10:22:29 +01:00
lebaudantoine ca9c7fc152 ️(frontend) avoid non-primitive default props recreation on each render
The empty object literal created a new reference every render,
potentially triggering unnecessary re-renders.

Hoist an EMPTY_PROPS constant to the module level and reuse it
instead of allocating a new object.
2026-03-04 10:22:29 +01:00
lebaudantoine 6e3845d0c1 ️(frontend) fix missing import type in Rating.tsx
Replace runtime import of PostHog with a type-only import to
avoid loading the module at runtime.
2026-03-04 10:22:29 +01:00
lebaudantoine 41b171da68 🩹(frontend) fix double await in Join.tsx
Remove redundant await in videoTrack.setDeviceId call
to avoid unnecessary promise chaining.
2026-03-04 10:22:29 +01:00
lebaudantoine 4ad897e756 ️(frontend) optimize enterRoom calls in useWaitingParticipants
Replace sequential await inside the loop with Promise.all, since
each enterRoom call is independent.

This prevents unnecessary delays when multiple participants are
waiting (e.g. 10 participants previously resulted in ~10x longer
execution time).
2026-03-04 10:22:29 +01:00
lebaudantoine 42647d6d25 🦺(backend) strengthen API validation for recording options
Improve validation of parameters accepted when starting a
recording to prevent unsupported or unexpected values.

Language validation will be further tightened to only accept
languages supported by the transcribe microservice.

Add extensive API validation tests to cover these scenarios.
2026-03-03 19:05:15 +01:00
leo 14526808ab ♻️(summary) clean up code and unify logging in preparation for testing
Refactor the summary service to better separate concerns, making components
easier to isolate and test. Unify logging logic to ensure consistent
behavior and reduce duplication across the service layer. These changes
set up the codebase for granular testing.
2026-03-03 15:44:53 +01:00
Florent Chehab 25167495cc 🐛(migrations) use settings in migrations
Use settings directly in migrations to avoid noop
migrations. This might have undisered side effects
if we change the config over time 'invalid' data will be
in the database.

It's a simple quick fix.
Keeping some migrations that are no useless to avoid changing
too much the migration history for users.

Similar to https://github.com/suitenumerique/people/commit/
469014ac415b25be0ceed08b31a87d2d40d743cd
2026-03-03 14:48:06 +01:00
lebaudantoine 720eb6a93e ♻️(backend) extract forbidden permission fields from the serializer
These fields previously triggered a suspicious operation exception
when passed to the API.

Make the list configurable so the serializer behavior can be
adjusted without requiring a new release.
2026-03-03 13:30:10 +01:00
lebaudantoine bfbf253033 🔒️(backend) enhance API input validation to strengthen security
During the bug bounty, attempts were made to pass unexpected hidden
fields to manipulate room behavior and join as a ghost.

Treat these parameters as suspicious. They are not sent by the
frontend, so their presence likely indicates tampering.

Explicitly allow the parameters but emit warning logs to help detect
and investigate suspicious activity.
2026-03-03 13:30:10 +01:00
lebaudantoine 692e0e359e (backend) install pydantic and django-pydantic-field to strengthen API
Super useful for validation when handling unstructured dictionaries.

Follow qbey's recommendation and align with the
suitenumerique/conversation project approach to improve schema
validation and data integrity.
2026-03-03 13:30:10 +01:00
Cyril 1d23cb889a ️(frontend) announce mic/camera state for screen readers on shortcut
announce "Microphone/Camera turned on/off" when toggling via
keyboard shortcut so screen reader users get feedback
2026-03-03 09:46:47 +01:00
lebaudantoine b2ad423886 🔖(minor) bump release to 1.9.0 2026-03-02 14:33:25 +01:00
lebaudantoine 2c7b4bea04 🔒️(ci) disable Trivy scan pending clarification from Aqua Security
The Trivy GitHub repository was wiped over the weekend, raising
suspicions of a potential supply chain attack.

Temporarily disable the scan until the situation is clarified.
2026-03-02 11:29:31 +01:00
lebaudantoine 1eda18ea6e 🔧(ci) introduce Claude security review GitHub Action
Add automated security review on new pull requests to strengthen
early detection of potential vulnerabilities.

Leverage Claude to help identify security issues and highlight
areas requiring special attention.
2026-03-02 11:29:31 +01:00
Cyril 8d5488c333 ️(frontend) add skip link component for keyboard navigation
Improve a11y: skip to main heading, bypass header. RGAA 12.7.
2026-02-27 22:49:03 +01:00
lebaudantoine 5c0e6b6479 ⬆️(frontend) update react-aria-components to a newer version
The previously pinned version (July release) did not support
passing the aria-disabled prop to React Aria Button.

A more recent release (August) introduced this capability.
Upgrade is required to make Cyril's proposal work.
2026-02-27 19:39:55 +01:00
Cyril 077cf59082 ️(frontend) keep carousel nav buttons focusable at first and last slide
use aria-disabled  to prevent focus loss when reaching slide limits
2026-02-27 19:39:55 +01:00
Cyril 4881fa20f5 ️(frontend) fix carousel focus ring visibility with NVDA
add :focus fallback for nav buttons when focus-visible detection fails
2026-02-27 19:39:55 +01:00
Cyril 116db1e697 ️(frontend) improve IntroSlider accessibility for screen readers
add aria-labels with slide position, carousel semantics, live region
2026-02-27 19:39:55 +01:00
Florent Chehab 4b76e9571f ⬆️ (python) bump minimal required python version to 3.13
We are going to use features only available in python 3.13.
We already ship docker images based on python 3.13.

For https://github.com/suitenumerique/meet/pull/1030
2026-02-27 12:37:14 +01:00
Cyril e8739d7e70 ️(frontend) improve JoinMeetingDialog screen reader
Focus input on modal open and improve screen reader announcements
2026-02-26 18:35:15 +01:00
Florent Chehab 602bcf3185 🩹(devex) fix Makefile special character support
Under some shells echo doesn't work as expected with the special formatting.

Using printf when creating the variables make it work and should be more robust.
2026-02-25 18:08:57 +01:00
leo f5e0ddf692 (summary) add localization support for transcription context text
Transcription and summarization results were always generated
using a French text structure (e.g. "Réunion du..."), regardless
of user preference or meeting language. Introduced basic localization
support to adapt generated string languages.
2026-02-25 18:07:19 +01:00
85 changed files with 2618 additions and 1339 deletions
+7 -5
View File
@@ -24,13 +24,15 @@ data
.cache
.circleci
.git
.vscode
.iml
.idea
db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Frontend
node_modules
**/node_modules
+33 -33
View File
@@ -43,12 +43,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -86,12 +86,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -130,12 +130,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -174,13 +174,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -220,14 +220,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+29
View File
@@ -0,0 +1,29 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+31 -2
View File
@@ -8,9 +8,34 @@ and this project adheres to
## [Unreleased]
## [1.10.0] - 2026-03-05
### Fixed
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
@@ -21,13 +46,17 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(a11y) fix focus ring on tab container components
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
### Changed
+1
View File
@@ -2,6 +2,7 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.8.0"
version = "1.10.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+81 -13
View File
@@ -1,10 +1,14 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from typing import Literal
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -201,6 +205,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
@@ -213,10 +238,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
options = SchemaField(
schema=RecordingOptions | None,
required=False,
allow_null=True,
default=dict,
help_text="Recording options",
)
@@ -261,6 +287,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -272,10 +320,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
permission = SchemaField(
schema=ParticipantPermission | None,
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
help_text="Participant permissions",
)
name = serializers.CharField(
max_length=255,
@@ -285,6 +334,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant",
)
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
@@ -300,12 +376,4 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
+7 -3
View File
@@ -296,12 +296,14 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
options = serializer.validated_data.get("options")
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
@@ -607,13 +609,15 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -167,6 +167,7 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
@@ -8,6 +8,7 @@ import random
from unittest import mock
from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
@@ -132,11 +133,7 @@ def test_update_participant_success(mock_livekit_client):
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
@@ -151,6 +148,151 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
@pytest.mark.parametrize("value", (False, True))
def test_update_participant_unimplemented_can_subscribe_metrics(value):
"""Test update participant raises 400 when can_subscribe_metrics is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
"can_subscribe_metrics": value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "can_subscribe_metrics" in str(response.data)
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -226,7 +368,17 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
assert response.json() == {
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
def test_update_participant_wrong_metadata_attributes():
@@ -199,3 +199,308 @@ def test_start_recording_success(
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
+6
View File
@@ -596,6 +596,12 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
+6 -4
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.8.0"
version = "1.10.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.13",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.13"
dependencies = [
"boto3==1.42.49",
"Brotli==1.2.0",
@@ -39,7 +39,8 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.11",
"django-pydantic-field==0.5.4",
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
@@ -50,6 +51,7 @@ dependencies = [
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0",
"requests==2.32.5",
+910 -897
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.8.0",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -36,7 +36,7 @@
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
@@ -4,6 +4,7 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -144,6 +145,21 @@ type Slide = {
isAvailableInBeta?: boolean
}
const carouselNavButton = css({
_focusVisible: {
outline: '2px solid var(--colors-focus-ring) !important',
outlineOffset: '1px',
},
_disabled: {
color: 'greyscale.400',
cursor: 'default',
pointerEvents: 'none',
_pressed: {
backgroundColor: 'transparent',
},
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
@@ -163,11 +179,45 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container>
<Container
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<div
className={css({
display: 'flex',
@@ -180,10 +230,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
className={carouselNavButton}
aria-label={previousAriaLabel}
aria-disabled={slideIndex === 0}
onPress={goPrev}
>
<RiArrowLeftSLine />
</Button>
@@ -191,7 +241,11 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Slide
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -205,10 +259,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
className={carouselNavButton}
aria-label={nextAriaLabel}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
>
<RiArrowRightSLine />
</Button>
@@ -5,37 +5,42 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Field
type="text"
autoFocus
isRequired
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
validate={validateRoomId}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
overflow: 'visible',
})}
>
<div
@@ -753,7 +753,7 @@ export const Join = ({
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
import type { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
@@ -74,13 +74,17 @@ export const useWaitingParticipants = () => {
): Promise<void> => {
try {
setListEnabled(false)
for (const participant of waitingParticipants) {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
}
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
)
)
await refetchWaiting()
} catch (e) {
console.error(e)
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => {
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
@@ -1,5 +1,28 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
const Hint = styled('div', {
base: {
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'.lk-grid-layout > *:first-child:focus-within &': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
},
})
export interface KeyboardShortcutHintProps {
children: ReactNode
@@ -12,21 +35,5 @@ export interface KeyboardShortcutHintProps {
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
return <Hint>{children}</Hint>
}
@@ -1,8 +1,21 @@
import type { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = ({
participant,
isScreenShare = false,
@@ -17,26 +30,14 @@ export const ParticipantName = ({
if (isScreenShare) {
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
<Text variant="sm" style={participantNameScreenShareStyles}>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
{displayedName}
</Text>
)
@@ -183,7 +183,7 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<>
<span>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -197,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2',
}}
/>
</>
</span>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -210,10 +210,12 @@ export const ParticipantTile: (
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -229,9 +231,7 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
</div>
)
})
@@ -102,6 +102,11 @@ export function FloatingReaction({
paddingTop: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '16px',
maxWidth: '12rem',
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{name}
@@ -1,5 +1,6 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
@@ -87,10 +88,24 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => await toggle(),
handler: async () => {
const nextState = !enabled
try {
const didChange = await toggle(nextState)
if (didChange === false) return
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
keyPrefix: `selectDevice.${kind}`,
})
announce(message, 'assertive')
} catch {
// no announce
}
},
isDisabled: cannotUseDevice,
})
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -15,6 +16,11 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'recording',
handler: toggleTools,
})
return (
<div
className={css({
@@ -12,6 +12,7 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -21,6 +22,8 @@ export function DesktopControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
handler: () => {
@@ -32,6 +35,13 @@ export function DesktopControlBar({
firstButton?.focus()
},
})
useRegisterKeyboardShortcut({
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
return (
<div
ref={desktopControlBarEl}
@@ -31,6 +31,9 @@ import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
@@ -97,6 +100,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const { toggleSettingsDialog } = useSettingsDialog()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
@@ -111,6 +115,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
handler: useCallback(() => {
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
}, [toggleSettingsDialog]),
})
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
@@ -4,8 +4,10 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
@@ -3,15 +3,20 @@ import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { H } from '@/primitives'
const tableStyle = css({
width: '100%',
borderCollapse: 'collapse',
overflowY: 'auto',
'& caption': {
fontWeight: 'bold',
marginBottom: '0.75rem',
textAlign: 'left',
},
'& th, & td': {
padding: '0.65rem 0',
textAlign: 'left',
fontWeight: 'normal',
},
'& tbody tr': {
borderBottom: '1px solid rgba(255,255,255,0.08)',
@@ -29,12 +34,11 @@ export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<H lvl={2}>{t('shortcuts.listLabel')}</H>
<table className={tableStyle}>
<thead className="sr-only">
<caption>{t('shortcuts.listLabel')}</caption>
<thead>
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
@@ -22,6 +22,8 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
type DeviceItems = Array<{ value: string; label: string }>
const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
@@ -59,7 +61,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
? EMPTY_PROPS
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
@@ -117,6 +119,40 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}
}, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => {
return [
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t])
const videoQualityItems = useMemo(() => {
return [
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]
}, [t])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
@@ -178,20 +214,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
items={resolutionItems}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
@@ -206,20 +229,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
items={videoQualityItems}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
@@ -14,7 +14,25 @@ export const useSettingsDialog = () => {
settingsStore.areSettingsOpen = true
}
const closeSettingsDialog = () => {
settingsStore.areSettingsOpen = false
}
const toggleSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) {
closeSettingsDialog()
} else {
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
}
return {
openSettingsDialog,
closeSettingsDialog,
toggleSettingsDialog,
}
}
@@ -5,6 +5,7 @@ import { Shortcut } from './types'
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
@@ -29,6 +30,11 @@ export type ShortcutDescriptor = {
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{
id: 'focus-toolbar',
category: 'navigation',
@@ -25,9 +25,9 @@ export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
}) => {
return (
<>
<div className={cx(badgeStyle, className)} aria-hidden="true">
<span>{visualLabel}</span>
</div>
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
{visualLabel}
</kbd>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
@@ -31,9 +31,9 @@ export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
return (
<tr>
<td className={text({ variant: 'body' })}>
<th scope="row" className={text({ variant: 'body' })}>
{t(`actions.${descriptor.id}`)}
</td>
</th>
<td className={shortcutCellStyle}>
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
</td>
@@ -19,7 +19,10 @@ export const useKeyboardShortcuts = () => {
shiftKey,
altKey,
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
}
if (!shortcut) return
e.preventDefault()
await shortcut()
+3
View File
@@ -5,6 +5,7 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered'
@@ -21,6 +22,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
{showHeader && <SkipLink />}
<div
className={css({
display: 'flex',
@@ -35,6 +37,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
>
{showHeader && <Header />}
<main
id={MAIN_CONTENT_ID}
className={css({
flexGrow: 1,
overflow: 'auto',
+69
View File
@@ -0,0 +1,69 @@
import { type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const MAIN_CONTENT_ID = 'main-content'
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
const StyledSkipLink = styled('a', {
base: {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
textDecoration: 'none',
_focusVisible: {
position: 'fixed',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
width: 'auto',
height: 'auto',
margin: 0,
padding: '0.625rem 1rem',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
backgroundColor: 'white',
color: 'primary.800',
fontWeight: 500,
fontSize: '0.875rem',
border: '1px solid',
borderColor: 'primary.800',
borderRadius: 4,
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: 2,
},
},
})
export const SkipLink = () => {
const { t } = useTranslation()
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const main = document.getElementById(MAIN_CONTENT_ID)
if (!main) return
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
const target = heading ?? main
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1')
}
target.focus()
}
return (
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
{t('skipLink')}
</StyledSkipLink>
)
}
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"selected": "ausgewählt",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
"title": "Melden Sie sich mit Ihrem Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": {
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+9 -5
View File
@@ -31,12 +31,14 @@
},
"introSlider": {
"previous": {
"label": "Zurück",
"tooltip": "Zurück"
"label": "Vorherige Folie",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
},
"next": {
"label": "Weiter",
"tooltip": "Weiter"
"label": "Nächste Folie",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
},
"beta": {
"text": "An der Beta teilnehmen",
@@ -53,6 +55,8 @@
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
}
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
}
}
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
"turnedOn": "Kamera aktiviert",
"label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
"turnedOn": "Mikrofon aktiviert",
"label": "Mikrofon"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "F2: zur Symbolleiste unten.",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
},
"selected": "selected",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "etalab 2.0 license"
},
"loginHint": {
"title": "Log in with your ProConnect account",
"title": "Log in with your account",
"body": "Instead of waiting, log in with your ProConnect account.",
"button": {
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"skipLink": "Skip to main content",
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+9 -5
View File
@@ -31,12 +31,14 @@
},
"introSlider": {
"previous": {
"label": "previous",
"tooltip": "previous"
"label": "Previous slide",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
},
"next": {
"label": "next",
"tooltip": "next"
"label": "Next slide",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
},
"beta": {
"text": "Join the beta",
@@ -53,6 +55,8 @@
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
}
},
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
}
}
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"turnedOff": "Camera turned off",
"turnedOn": "Camera turned on",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"turnedOff": "Microphone turned off",
"turnedOn": "Microphone turned on",
"label": "Microphone"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "F2: go to the bottom toolbar.",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
},
"selected": "sélectionné",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "licence etalab 2.0"
},
"loginHint": {
"title": "Connectez-vous avec votre compte ProConnect",
"title": "Connectez-vous avec votre compte",
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
"button": {
"ariaLabel": "Fermer la suggestion",
"label": "OK"
}
},
"skipLink": "Aller au contenu principal",
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
+8 -4
View File
@@ -30,14 +30,18 @@
}
},
"introSlider": {
"carouselLabel": "Diaporama de présentation",
"previous": {
"label": "précédent",
"tooltip": "précédent"
"label": "Diapositive précédente",
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
"tooltip": "Diapositive précédente"
},
"next": {
"label": "suivant",
"tooltip": "suivant"
"label": "Diapositive suivante",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
"tooltip": "Diapositive suivante"
},
"slidePosition": "Diapositive {{current}} sur {{total}}",
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"turnedOff": "Webcam désactivée",
"turnedOn": "Webcam activée",
"label": "Webcam",
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"turnedOff": "Micro désactivé",
"turnedOn": "Micro activé",
"label": "Microphone"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
+3 -1
View File
@@ -24,6 +24,7 @@
"notFound": {
"heading": "Pagina niet gevonden"
},
"selected": "geselecteerd",
"submit": "OK",
"footer": {
"links": {
@@ -44,13 +45,14 @@
"license": "etalab 2.0 licentie"
},
"loginHint": {
"title": "Log in met je ProConnect-account",
"title": "Log in met je account",
"body": "In plaats van te wachten, log in met je ProConnect-account.",
"button": {
"ariaLabel": "Sluit de suggestie",
"label": "OK"
}
},
"skipLink": "Naar de hoofdinhoud gaan",
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
+9 -5
View File
@@ -31,12 +31,14 @@
},
"introSlider": {
"previous": {
"label": "vorige",
"tooltip": "vorige"
"label": "Vorige dia",
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
"tooltip": "Vorige dia"
},
"next": {
"label": "volgende",
"tooltip": "volgende"
"label": "Volgende dia",
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
"tooltip": "Volgende dia"
},
"beta": {
"text": "Word lid van de bèta",
@@ -53,6 +55,8 @@
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
}
},
"carouselLabel": "Introductie-diavoorstelling",
"slidePosition": "Dia {{current}} van {{total}}"
}
}
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"turnedOff": "Camera uitgeschakeld",
"turnedOn": "Camera ingeschakeld",
"label": "Camera",
"placeholder": "Schakel de camera in om de preview te zien"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"turnedOff": "Microfoon uitgeschakeld",
"turnedOn": "Microfoon ingeschakeld",
"label": "Microfoon"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "F2: naar de werkbalk onderaan.",
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
+12 -1
View File
@@ -1,5 +1,7 @@
import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { VisuallyHidden } from '@/styled-system/jsx'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import type { RecipeVariantProps } from '@/styled-system/types'
@@ -19,6 +21,7 @@ export const MenuList = <T extends string | number = string>({
} & MenuProps<unknown> &
RecipeVariantProps<typeof menuRecipe>) => {
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
const { t } = useTranslation('global')
const classes = menuRecipe({
extraPadding: true,
variant: variant,
@@ -39,11 +42,19 @@ export const MenuList = <T extends string | number = string>({
className={classes.item}
key={value}
id={value as string}
textValue={typeof label === 'string' ? label : undefined}
onAction={() => {
onAction(value as T)
}}
>
{label}
{({ isSelected }) => (
<>
{label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
</MenuItem>
)
})}
+14 -2
View File
@@ -1,5 +1,5 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { styled, VisuallyHidden } from '@/styled-system/jsx'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
@@ -9,6 +9,7 @@ import {
SelectProps as RACSelectProps,
SelectValue,
} from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -110,6 +111,7 @@ export const Select = <T extends string | number>({
...props
}: SelectProps<T>) => {
const IconComponent = iconComponent
const { t } = useTranslation('global')
return (
<RACSelect {...props}>
{label}
@@ -138,8 +140,18 @@ export const Select = <T extends string | number>({
}
id={item.value}
key={item.value}
textValue={
typeof item.label === 'string' ? item.label : undefined
}
>
{item.label}
{({ isSelected }) => (
<>
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
</ListBoxItem>
))}
</ListBox>
+3 -1
View File
@@ -11,6 +11,8 @@ import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
import { RecordingDownloadRoute } from '@/features/recording'
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
export const routes: Record<
| 'home'
| 'room'
@@ -37,7 +39,7 @@ export const routes: Record<
room: {
name: 'room',
to: (roomId: string) => `/${roomId.trim()}`,
path: new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`),
path: roomIdRegex,
Component: RoomRoute,
},
feedback: {
+5
View File
@@ -22,6 +22,11 @@ body,
outline: 2px solid transparent;
}
main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 2px;
}
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
+21
View File
@@ -151,3 +151,24 @@
[data-lk-theme] .lk-participant-tile {
box-shadow: var(--lk-box-shadow);
}
/* Participant name ellipsis: truncate when overflowing */
.lk-participant-metadata {
gap: 1rem;
}
.lk-participant-metadata > *:first-child {
min-width: 0;
}
.lk-participant-metadata > *:first-child {
flex: 1;
}
.lk-participant-metadata > *:first-child .lk-participant-metadata-item,
.lk-participant-metadata
.lk-participant-metadata-item
.lk-participant-name-wrapper {
min-width: 0;
}
.lk-participant-metadata > *:first-child .lk-participant-metadata-item {
display: flex;
align-items: center;
}
@@ -128,6 +128,10 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
@@ -141,6 +141,10 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
@@ -156,6 +156,10 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.15
version: 0.0.16
@@ -0,0 +1,90 @@
{{- if .Values.ingressWebhook.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressWebhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressWebhook.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressWebhook.annotations "kubernetes.io/ingress.class" .Values.ingressWebhook.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-webhook
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressWebhook.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressWebhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressWebhook.className }}
{{- end }}
{{- if .Values.ingressWebhook.tls.enabled }}
tls:
{{- if .Values.ingressWebhook.host }}
- secretName: {{ .Values.ingressWebhook.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressWebhook.host | quote }}
{{- end }}
{{- range .Values.ingressWebhook.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressWebhook.host }}
- host: {{ .Values.ingressWebhook.host | quote }}
http:
paths:
- path: {{ .Values.ingressWebhook.path }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- with .Values.ingressWebhook.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- range .Values.ingressWebhook.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ .Values.ingressWebhook.path }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- with $.Values.ingressWebhook.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- end }}
+25
View File
@@ -50,6 +50,31 @@ ingress:
## @param ingress.customBackends Add custom backends to ingress
customBackends: []
## @param ingressWebhook.enabled whether to enable the Ingress or not
## @param ingressWebhook.className IngressClass to use for the Ingress
## @param ingressWebhook.host Host for the Ingress
## @param ingressWebhook.path Path to use for the Ingress
ingressWebhook:
enabled: false
className: null
host: meet.example.com
path: /api/v1.0/rooms/webhooks-livekit/
## @param ingressWebhook.hosts Additional host to configure for the Ingress
hosts: []
# - chart-example.local
## @param ingressWebhook.tls.enabled Weather to enable TLS for the Ingress
## @param ingressWebhook.tls.secretName Secret name for TLS config
## @skip ingressWebhook.tls.additional
## @extra ingressWebhook.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressWebhook.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
## @param ingressWebhook.customBackends Add custom backends to ingress
customBackends: []
## @param ingressAdmin.enabled whether to enable the Ingress or not
## @param ingressAdmin.className IngressClass to use for the Ingress
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.8.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.8.0",
"version": "1.10.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.8.0",
"version": "1.10.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.8.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.8.0",
"version": "1.10.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.8.0",
"version": "1.10.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.8.0"
version = "1.10.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+6 -4
View File
@@ -15,8 +15,8 @@ from summary.core.config import get_settings
settings = get_settings()
class TaskCreation(BaseModel):
"""Task data."""
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
owner_id: str
filename: str
@@ -28,6 +28,7 @@ class TaskCreation(BaseModel):
recording_time: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
@field_validator("language")
@classmethod
@@ -45,8 +46,8 @@ router = APIRouter(prefix="/tasks")
@router.post("/")
async def create_task(request: TaskCreation):
"""Create a task."""
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
@@ -59,6 +60,7 @@ async def create_task(request: TaskCreation):
request.recording_time,
request.language,
request.download_link,
request.context_language,
],
queue=settings.transcribe_queue,
)
+5 -8
View File
@@ -112,19 +112,16 @@ class MetadataManager:
if self._is_disabled or self.has_task_id(task_id):
return
initial_metadata = {
"start_time": time.time(),
"asr_model": settings.whisperx_asr_model,
"retries": 0,
}
_, filename, email, _, received_at, *_ = task_args
start_time = time.time()
initial_metadata = {
**initial_metadata,
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"filename": filename,
"email": email,
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
"queuing_time": round(start_time - received_at, 2),
}
self._save_metadata(task_id, initial_metadata)
+114 -123
View File
@@ -10,14 +10,13 @@ import openai
import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
from requests import Session, exceptions
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.file_service import FileService, FileServiceException
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
@@ -29,6 +28,7 @@ from summary.core.prompt import (
PROMPT_USER_PART,
)
from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.webhook_service import submit_content
settings = get_settings()
analytics = get_analytics()
@@ -55,89 +55,17 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
file_service = FileService(logger=logger)
file_service = FileService()
def create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
def transcribe_audio(task_id, filename, language):
"""Transcribe an audio file using WhisperX.
Downloads the audio from MinIO, sends it to WhisperX for transcription,
and tracks metadata throughout the process.
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
Returns the transcription object, or None if the file could not be retrieved.
"""
lines = []
for action in llm_output.get("actions", []):
title = action.get("title", "").strip()
assignees = ", ".join(action.get("assignees", [])) or "-"
due_date = action.get("due_date") or "-"
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
lines.append(line)
if lines:
return "### Prochaines étapes\n\n" + "\n".join(lines)
return ""
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
filename: str,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task performs the following operations:
1. Retrieves the audio file from MinIO storage
2. Transcribes the audio using WhisperX model
3. Sends the results via webhook
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
task_id = self.request.id
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key.get_secret_value(),
@@ -145,10 +73,9 @@ def process_audio_transcribe_summarize_v2(
max_retries=settings.whisperx_max_retries,
)
# Transcription
try:
with (
file_service.prepare_audio_file(filename) as (audio_file, metadata),
):
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
if language is None:
@@ -179,13 +106,32 @@ def process_audio_transcribe_summarize_v2(
except FileServiceException:
logger.exception("Unexpected error for filename: %s", filename)
return
return None
metadata_manager.track_transcription_metadata(task_id, transcription)
return transcription
formatter = TranscriptFormatter()
content, title = formatter.format(
def format_transcript(
transcription,
context_language,
language,
room,
recording_date,
recording_time,
download_link,
):
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
TranscriptFormatter to produce markdown content and a title.
Returns a (content, title) tuple.
"""
locale = get_locale(context_language, language)
formatter = TranscriptFormatter(locale)
return formatter.format(
transcription,
room=room,
recording_date=recording_date,
@@ -193,34 +139,93 @@ def process_audio_transcribe_summarize_v2(
download_link=download_link,
)
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
response = post_with_retries(settings.webhook_url, data)
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
for action in llm_output.get("actions", []):
title = action.get("title", "").strip()
assignees = ", ".join(action.get("assignees", [])) or "-"
due_date = action.get("due_date") or "-"
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
lines.append(line)
if lines:
return "### Prochaines étapes\n\n" + "\n".join(lines)
return ""
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
filename: str,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
4. Conditional summarization queuing
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
"""
logger.info(
"Webhook success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
logger.debug("Full response: %s", response_data)
task_id = self.request.id
transcription = transcribe_audio(task_id, filename, language)
if transcription is None:
return
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_date,
recording_time,
download_link,
)
submit_content(content, title, email, sub)
metadata_manager.capture(task_id, settings.posthog_event_success)
# LLM Summarization
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
and settings.is_summary_enabled
@@ -286,12 +291,11 @@ def summarize_transcription(
# a singleton client. This is a performance trade-off we accept to ensure per-user
# privacy controls in observability traces.
llm_observability = LLMObservability(
logger=logger,
user_has_tracing_consent=user_has_tracing_consent,
session_id=self.request.id,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
llm_service = LLMService(llm_observability=llm_observability)
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
@@ -334,22 +338,9 @@ def summarize_transcription(
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
summary_title = settings.summary_title_template.format(title=title)
data = {
"title": settings.summary_title_template.format(
title=title,
),
"content": summary,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
response = post_with_retries(settings.webhook_url, data)
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
submit_content(summary, summary_title, email, sub)
llm_observability.flush()
logger.debug("LLM observability flushed")
+4 -6
View File
@@ -1,7 +1,7 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated, List, Optional, Set
from typing import Annotated, List, Literal, Optional, Set
from fastapi import Depends
from pydantic import SecretStr
@@ -51,7 +51,6 @@ class Settings(BaseSettings):
# Transcription processing
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
hallucination_replacement_text: str = "[Texte impossible à transcrire]"
# Webhook-related settings
webhook_max_retries: int = 2
@@ -60,11 +59,10 @@ class Settings(BaseSettings):
webhook_api_token: SecretStr
webhook_url: str
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
# Output related settings
document_default_title: Optional[str] = "Transcription"
document_title_template: Optional[str] = (
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
)
summary_title_template: Optional[str] = "Résumé de {title}"
# Summary related settings
+19 -19
View File
@@ -1,5 +1,6 @@
"""File service to encapsulate files' manipulations."""
import logging
import os
import subprocess
import tempfile
@@ -15,6 +16,9 @@ from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
class FileServiceException(Exception):
"""Base exception for file service operations."""
@@ -24,10 +28,8 @@ class FileServiceException(Exception):
class FileService:
"""Service for downloading and preparing files from MinIO storage."""
def __init__(self, logger):
def __init__(self):
"""Initialize FileService with MinIO client and configuration."""
self._logger = logger
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
@@ -53,16 +55,16 @@ class FileService:
The file is downloaded to a temporary location for local manipulation
such as validation, conversion, or processing before being used.
"""
self._logger.info("Download recording | object_key: %s", remote_object_key)
logger.info("Download recording | object_key: %s", remote_object_key)
if not remote_object_key:
self._logger.warning("Invalid object_key '%s'", remote_object_key)
logger.warning("Invalid object_key '%s'", remote_object_key)
raise ValueError("Invalid object_key")
extension = Path(remote_object_key).suffix.lower()
if extension not in self._allowed_extensions:
self._logger.warning("Invalid file extension '%s'", extension)
logger.warning("Invalid file extension '%s'", extension)
raise ValueError(f"Invalid file extension '{extension}'")
response = None
@@ -81,8 +83,8 @@ class FileService:
tmp.flush()
local_path = Path(tmp.name)
self._logger.info("Recording successfully downloaded")
self._logger.debug("Recording local file path: %s", local_path)
logger.info("Recording successfully downloaded")
logger.debug("Recording local file path: %s", local_path)
return local_path
@@ -100,7 +102,7 @@ class FileService:
file_metadata = mutagen.File(local_path).info
duration = file_metadata.length
self._logger.info(
logger.info(
"Recording file duration: %.2f seconds",
duration,
)
@@ -109,14 +111,14 @@ class FileService:
error_msg = "Recording too long. Limit is %.2fs seconds" % (
self._max_duration,
)
self._logger.error(error_msg)
logger.error(error_msg)
raise ValueError(error_msg)
return duration
def _extract_audio_from_video(self, video_path: Path) -> Path:
"""Extract audio from video file (e.g., MP4) and save as audio file."""
self._logger.info("Extracting audio from video file: %s", video_path)
logger.info("Extracting audio from video file: %s", video_path)
with tempfile.NamedTemporaryFile(
suffix=".m4a", delete=False, prefix="audio_extract_"
@@ -140,16 +142,16 @@ class FileService:
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
)
self._logger.info("Audio successfully extracted to: %s", output_path)
logger.info("Audio successfully extracted to: %s", output_path)
return output_path
except FileNotFoundError as e:
self._logger.error("ffmpeg not found. Please install ffmpeg.")
logger.error("ffmpeg not found. Please install ffmpeg.")
if output_path.exists():
os.remove(output_path)
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
except subprocess.CalledProcessError as e:
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
logger.error("Audio extraction failed: %s", e.stderr.decode())
if output_path.exists():
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
@@ -173,7 +175,7 @@ class FileService:
extension = downloaded_path.suffix.lower()
if extension in settings.recording_video_extensions:
self._logger.info("Video file detected, extracting audio...")
logger.info("Video file detected, extracting audio...")
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
processed_path = extracted_audio_path
else:
@@ -194,8 +196,6 @@ class FileService:
try:
os.remove(path)
self._logger.debug("Temporary file removed: %s", path)
logger.debug("Temporary file removed: %s", path)
except OSError as e:
self._logger.warning(
"Failed to remove temporary file %s: %s", path, e
)
logger.warning("Failed to remove temporary file %s: %s", path, e)
+8 -7
View File
@@ -1,5 +1,6 @@
"""LLM service to encapsulate LLM's calls."""
import logging
from typing import Any, Mapping, Optional
import openai
@@ -10,6 +11,9 @@ from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
class LLMObservability:
"""Manage observability and tracing for LLM calls using Langfuse.
@@ -21,13 +25,11 @@ class LLMObservability:
def __init__(
self,
logger,
session_id: str,
user_id: str,
user_has_tracing_consent: bool = False,
):
"""Initialize the LLMObservability client."""
self._logger = logger
self._observability_client: Optional[Langfuse] = None
self.session_id = session_id
self.user_id = user_id
@@ -75,7 +77,7 @@ class LLMObservability:
}
if not self.is_enabled:
self._logger.debug("Using regular OpenAI client (observability disabled)")
logger.debug("Using regular OpenAI client (observability disabled)")
return openai.OpenAI(**base_args)
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
@@ -83,7 +85,7 @@ class LLMObservability:
# is missing. Conditional import ensures Langfuse only initializes when enabled.
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
logger.debug("Using LangfuseOpenAI client (observability enabled)")
return langfuse_openai.OpenAI(**base_args)
def flush(self):
@@ -99,11 +101,10 @@ class LLMException(Exception):
class LLMService:
"""Service for performing calls to the LLM configured in the settings."""
def __init__(self, llm_observability, logger):
def __init__(self, llm_observability):
"""Init the LLMService once."""
self._client = llm_observability.get_openai_client()
self._observability = llm_observability
self._logger = logger
def call(
self,
@@ -140,5 +141,5 @@ class LLMService:
return response.choices[0].message.content
except Exception as e:
self._logger.exception("LLM call failed: %s", e)
logger.exception("LLM call failed: %s", e)
raise LLMException(f"LLM call failed: {e}") from e
@@ -0,0 +1,30 @@
"""Locale support for the summary service."""
from typing import Optional
from summary.core.config import get_settings
from summary.core.locales import de, en, fr, nl
from summary.core.locales.strings import LocaleStrings
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
def get_locale(*languages: Optional[str]) -> LocaleStrings:
"""Return locale strings for the first matching language candidate.
Accept language codes in decreasing priority order and return the
locale for the first one that matches a known locale.
Fall back to the configured default_context_language.
"""
for lang in languages:
if not lang:
continue
if lang in _LOCALES:
return _LOCALES[lang].STRINGS
# Provide fallback for longer formats of ISO 639-1 (e.g. "en-au" -> "en")
base_lang = lang.split("-")[0]
if base_lang in _LOCALES:
return _LOCALES[base_lang].STRINGS
return _LOCALES[get_settings().default_context_language].STRINGS
+34
View File
@@ -0,0 +1,34 @@
"""German locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**In Ihrer Transkription wurde kein Audioinhalt erkannt.**
*Wenn Sie glauben, dass es sich um einen Fehler handelt, zögern Sie nicht,
unseren technischen Support zu kontaktieren: visio@numerique.gouv.fr*
.
.
.
Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
- War ein Mikrofon aktiviert?
- Waren Sie nah genug am Mikrofon?
- Ist das Mikrofon von guter Qualität?
- Dauert die Aufnahme länger als 30 Sekunden?
""",
download_header_template=(
"\n*Laden Sie Ihre Aufnahme herunter, "
"indem Sie [diesem Link folgen]({download_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
document_title_template=(
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
),
)
+33
View File
@@ -0,0 +1,33 @@
"""English locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**No audio content was detected in your transcription.**
*If you believe this is an error, please do not hesitate to contact
our technical support: visio@numerique.gouv.fr*
.
.
.
A few things we recommend you check:
- Was a microphone enabled?
- Were you close enough to the microphone?
- Is the microphone of good quality?
- Is the recording longer than 30 seconds?
""",
download_header_template=(
"\n*Download your recording by [following this link]({download_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
document_title_template=(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
),
)
+33
View File
@@ -0,0 +1,33 @@
"""French locale strings (default)."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Aucun contenu audio n'a été détecté dans votre transcription.**
*Si vous pensez qu'il s'agit d'une erreur, n'hésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- L'enregistrement dure-t-il plus de 30 secondes ?
""",
download_header_template=(
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
document_title_template=(
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
),
)
+33
View File
@@ -0,0 +1,33 @@
"""Dutch locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Er is geen audio-inhoud gedetecteerd in uw transcriptie.**
*Als u denkt dat dit een fout is, aarzel dan niet om contact op te nemen
met onze technische ondersteuning: visio@numerique.gouv.fr*
.
.
.
Een paar punten die wij u aanraden te controleren:
- Was er een microfoon ingeschakeld?
- Was u dicht genoeg bij de microfoon?
- Is de microfoon van goede kwaliteit?
- Duurt de opname langer dan 30 seconden?
""",
download_header_template=(
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
document_title_template=(
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
),
)
@@ -0,0 +1,15 @@
"""Locale types for the summary service."""
from dataclasses import dataclass
@dataclass(frozen=True)
class LocaleStrings:
"""All translatable output strings for the summary pipeline."""
# transcript_formatter.py
empty_transcription: str
download_header_template: str
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
@@ -4,34 +4,13 @@ import logging
from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
settings = get_settings()
logger = logging.getLogger(__name__)
DEFAULT_EMPTY_TRANSCRIPTION = """
**Aucun contenu audio na été détecté dans votre transcription.**
*Si vous pensez quil sagit dune erreur, nhésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- Lenregistrement dure-t-il plus de 30 secondes ?
"""
class TranscriptFormatter:
"""Formats WhisperX transcription output into readable conversation format.
@@ -42,12 +21,10 @@ class TranscriptFormatter:
- Generating descriptive titles from context
"""
def __init__(self):
"""Initialize formatter with settings."""
def __init__(self, locale: LocaleStrings):
"""Initialize formatter with settings and locale."""
self.hallucination_patterns = settings.hallucination_patterns
self.hallucination_replacement_text = settings.hallucination_replacement_text
self.default_title = settings.document_default_title
self.default_empty_transcription = DEFAULT_EMPTY_TRANSCRIPTION
self._locale = locale
def _get_segments(self, transcription):
"""Extract segments from transcription object or dictionary."""
@@ -71,7 +48,7 @@ class TranscriptFormatter:
segments = self._get_segments(transcription)
if not segments:
content = self.default_empty_transcription
content = self._locale.empty_transcription
else:
content = self._format_speaker(segments)
content = self._remove_hallucinations(content)
@@ -83,7 +60,7 @@ class TranscriptFormatter:
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
replacement = self.hallucination_replacement_text or ""
replacement = self._locale.hallucination_replacement_text or ""
for pattern in self.hallucination_patterns:
content = content.replace(pattern, replacement)
@@ -111,9 +88,8 @@ class TranscriptFormatter:
if not download_link:
return content
header = (
f"\n*Télécharger votre enregistrement "
f"en [suivant ce lien]({download_link})*\n"
header = self._locale.download_header_template.format(
download_link=download_link
)
content = header + content
@@ -127,9 +103,9 @@ class TranscriptFormatter:
) -> str:
"""Generate title from context or return default."""
if not room or not recording_date or not recording_time:
return self.default_title
return self._locale.document_default_title
return settings.document_title_template.format(
return self._locale.document_title_template.format(
room=room,
room_recording_date=recording_date,
room_recording_time=recording_time,
@@ -0,0 +1,73 @@
"""Service for delivering content to external destinations."""
import json
import logging
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
def _post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = _create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
def submit_content(content, title, email, sub):
"""Submit content to the configured webhook destination.
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(settings.webhook_url, data)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)