mirror of
https://github.com/suitenumerique/meet.git
synced 2026-11-13 13:59:19 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb345610a7 | |||
| 48d144877a | |||
| 5b6ed14a31 | |||
| 6f8f5d0386 | |||
| 25122deba2 | |||
| 9ede799cb9 | |||
| cc4275c23c | |||
| d1489f8a61 | |||
| c6beb8ed3e | |||
| 505c55d3b9 | |||
| 72d964656c | |||
| 58bf9a7d82 | |||
| 72af9d629e | |||
| 3cd2cb5591 | |||
| fcd37b0f26 | |||
| b6a8ae7d35 | |||
| 68848c0d6f | |||
| 2ea5c85436 | |||
| 57a4446710 | |||
| df723e6a0e | |||
| 900ebd3edd | |||
| c4fbfdd302 | |||
| 6f1b9ddba7 | |||
| e412dec76f | |||
| 6af43ebf1d | |||
| 4e953da226 | |||
| f45628d881 | |||
| 6fb698a63d | |||
| 5887795858 | |||
| 21b9b42cb1 |
+17
-8
@@ -2,19 +2,28 @@
|
||||
# Review regularly and remove entries once Debian ships a fix.
|
||||
|
||||
# util-linux
|
||||
CVE-2026-76642
|
||||
CVE-2026-78408
|
||||
CVE-2026-78409
|
||||
CVE-2026-78410
|
||||
CVE-2026-76642 exp:2026-12-05
|
||||
CVE-2026-78408 exp:2026-12-05
|
||||
CVE-2026-78409 exp:2026-12-05
|
||||
CVE-2026-78410 exp:2026-12-05
|
||||
|
||||
# acl
|
||||
CVE-2026-54369
|
||||
CVE-2026-54369 exp:2026-12-05
|
||||
|
||||
# ncurses
|
||||
CVE-2025-69720
|
||||
CVE-2025-69720 exp:2026-12-05
|
||||
|
||||
# systemd
|
||||
CVE-2026-16742
|
||||
CVE-2026-16742 exp:2026-12-05
|
||||
|
||||
# perl-base (fix deferred by Debian)
|
||||
CVE-2026-9538
|
||||
CVE-2026-9538 exp:2026-12-05
|
||||
|
||||
# libsqlite3-0 (fixed in sid 3.54.1-1; trixie marked no-dsa / minor)
|
||||
CVE-2026-119373 exp:2026-12-05
|
||||
CVE-2026-119374 exp:2026-12-05
|
||||
CVE-2026-119379 exp:2026-12-05
|
||||
|
||||
# zlib (fixed in sid 1:1.3.dfsg+really1.3.2-4; trixie still vulnerable)
|
||||
CVE-2026-76844 exp:2026-12-05
|
||||
CVE-2026-85091 exp:2026-12-05
|
||||
|
||||
@@ -10,6 +10,7 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐(i18n) add Portuguese language support
|
||||
- ✨(frontend) add jittered exponential backoff to LiveKit reconnections
|
||||
- ✨(frontend) tell participants when a recording is interrupted
|
||||
|
||||
@@ -22,11 +23,25 @@ and this project adheres to
|
||||
- ⬆️(backend) update python dependencies
|
||||
- ⬆️(agents) update python dependencies
|
||||
- ⬆️(summary) update python dependencies
|
||||
- 🔒️(backend) issue room access tokens via POST rooms/{id}/join/ only
|
||||
- 🔒️(frontend) send the display name in the join request body
|
||||
- 🔥(backend) drop is_administrable from unregistered room payloads
|
||||
- 🍱(frontend) add Portuguese glyphs to Marianne subsetted fonts
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📌(summary) pin kombu to keep celery workers compatible with redis 5
|
||||
- 🐛(frontend) show a page when the connection ends unexpectedly
|
||||
- 🐛(frontend) show the transcript mic hint when every mic is muted
|
||||
- 🐛(frontend) show join errors instead of silently waiting in the lobby
|
||||
- 🐛(frontend) send users who cannot enter directly back to the lobby
|
||||
- 🔒️(backend) refuse unregistered rooms whose slug is a UUID
|
||||
- 🔒️(agents) upgrade python to 3.14.10 to fix CVE-2026-109518
|
||||
- 🔒️(docker) upgrade musl-utils to 1.2.6-r3 to fix CVE-2026-107891
|
||||
- ♿️(frontend) close the invite dialog with the Escape key #1916
|
||||
- 🔒️(docker) move frontend to nginx/nginx-unprivileged image
|
||||
- 🐛(frontend) accept meeting links with query params in join dialog
|
||||
- 🐛(backend) allow muting when everyone_can_mute is stored as null #1942
|
||||
|
||||
## [1.37.0] - 2026-11-01
|
||||
|
||||
@@ -38,6 +53,13 @@ and this project adheres to
|
||||
- ⬆️(backend) upgrade django-lasuite to v1.0.0
|
||||
- ⚡️(docker) build frontend assets natively for arm64 images
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(summary) protect signed URLs in models, source logs and analytics metadata
|
||||
- 🔒️(summary) mask secret validation inputs and sanitize upstream and task errors
|
||||
- 🔒️(summary) scrub sensitive Sentry event and breadcrumb content while retaining
|
||||
redacted frame locals and application DEBUG diagnostics
|
||||
|
||||
## [1.36.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@ RUN python -m pip install --upgrade pip
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add --no-cache --upgrade 'ca-certificates>=20261020-r0' && \
|
||||
apk add --no-cache --upgrade 'ca-certificates>=20261020-r0' \
|
||||
'musl-utils>=1.2.6-r3' && \
|
||||
update-ca-certificates
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
|
||||
+29
@@ -16,6 +16,35 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
### Room access tokens moved to POST /join/
|
||||
|
||||
LiveKit access tokens are no longer returned when reading a room. They are henceforth issued by a dedicated `POST
|
||||
/api/v1.0/rooms/{id}/join/` endpoint, which receives the display name in its JSON body. Display names therefore no
|
||||
longer appear in URLs and token retrieval by logged-in users is now protected against CSRF.
|
||||
|
||||
- `GET /api/v1.0/rooms/{id}/` no longer returns the `livekit` block, and neither do the list, create and update
|
||||
responses
|
||||
- The `username` field of `POST /api/v1.0/rooms/{id}/request-entry/` is now limited to 255 characters
|
||||
- When the caller may enter the room directly, the response contains the room data plus a `livekit` block
|
||||
- When the caller must go through the lobby, the response has no `livekit` block
|
||||
- Session-authenticated callers must send the CSRF token (`X-CSRFToken` header and `csrftoken` cookie)
|
||||
- Every join returning a `livekit` block mints a new token, valid for 6 hours, and anonymous callers get a new
|
||||
participant identity each time.
|
||||
- Joining again returns no `livekit` block to callers admitted through the lobby.
|
||||
- Unregistered rooms are always entered directly: joining one always returns a `livekit` block.
|
||||
- Unregistered rooms are no longer described with an `is_administrable` field,
|
||||
|
||||
### Unregistered rooms can no longer be named after a UUID
|
||||
|
||||
When `ALLOW_UNREGISTERED_ROOMS` is enabled, reading or joining a room whose identifier normalizes to an empty slug or to
|
||||
a UUID now returns a 404 instead of describing or letting callers join the unregistered room.
|
||||
|
||||
This closes a lobby bypass since a registered LiveKit room is named after its room id. An identifier crafted to not
|
||||
parse as a UUID, yet to normalize to an existing room's id, matched no registered room and was treated as an
|
||||
unregistered one. The token minted for that unregistered room was valid for the registered room: anyone could join a
|
||||
trusted or restricted room without being admitted through the lobby.
|
||||
|
||||
## v1.37.0
|
||||
|
||||
### Crisp support chat replaced by the La Suite support form
|
||||
|
||||
@@ -58,19 +58,13 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' && \
|
||||
apk del curl && \
|
||||
RUN apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14.8-slim AS base
|
||||
FROM python:3.14.10-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
|
||||
@@ -137,7 +137,7 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
- Admins and owners can always mute.
|
||||
- When `everyone_can_mute` is enabled on the room, any participant
|
||||
currently in the room (proven by a valid LiveKit token for that room)
|
||||
can mute.
|
||||
can mute. It is enabled unless explicitly set to `False`.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@@ -149,8 +149,7 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
|
||||
return True
|
||||
|
||||
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
|
||||
if not everyone_can_mute:
|
||||
if obj.configuration.get("everyone_can_mute") is False:
|
||||
return False
|
||||
|
||||
if not is_livekit_token_auth:
|
||||
|
||||
@@ -27,9 +27,12 @@ from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISPLAY_NAME_MAX_LENGTH = 255
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
@@ -170,17 +173,18 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
Add LiveKit credentials for public instance or related users/groups
|
||||
"""
|
||||
"""Add accesses only for administrator and owner users."""
|
||||
output = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
|
||||
if not request:
|
||||
return output
|
||||
|
||||
role = instance.get_role(request.user)
|
||||
if "role" in self.context:
|
||||
role = self.context["role"]
|
||||
else:
|
||||
role = instance.get_role(request.user)
|
||||
|
||||
is_admin_or_owner = models.RoleChoices.check_administrator_role(
|
||||
role
|
||||
) or models.RoleChoices.check_owner_role(role)
|
||||
@@ -193,26 +197,9 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and request.user.is_authenticated
|
||||
)
|
||||
or role is not None
|
||||
or instance.is_public
|
||||
)
|
||||
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
if not LobbyService.can_bypass_lobby(
|
||||
room=instance, user=request.user, role=role
|
||||
):
|
||||
del output["pin_code"]
|
||||
|
||||
return output
|
||||
@@ -355,10 +342,23 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
return value
|
||||
|
||||
|
||||
class JoinRoomSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the data sent to join a room."""
|
||||
|
||||
username = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=DISPLAY_NAME_MAX_LENGTH,
|
||||
help_text="Display name in the room. The default name is used when it "
|
||||
"is missing, null or blank.",
|
||||
)
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
username = serializers.CharField(required=True, max_length=DISPLAY_NAME_MAX_LENGTH)
|
||||
participant_id = serializers.CharField(
|
||||
required=False, allow_null=True, max_length=128
|
||||
)
|
||||
@@ -675,7 +675,9 @@ class RaiseHandSerializer(BaseValidationOnlySerializer):
|
||||
class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for renaming a participant in a room."""
|
||||
|
||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||
name = serializers.CharField(
|
||||
min_length=1, max_length=DISPLAY_NAME_MAX_LENGTH, allow_blank=False
|
||||
)
|
||||
|
||||
|
||||
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -309,35 +309,35 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
ensure_room_not_deleted(obj)
|
||||
return obj
|
||||
|
||||
def _get_object_or_unregistered_slug(self):
|
||||
"""Return the requested room, or the slug of an unregistered room."""
|
||||
try:
|
||||
return self.get_object(), None
|
||||
except Http404:
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS or not slug or utils.is_uuid(slug):
|
||||
raise
|
||||
return None, slug
|
||||
|
||||
@staticmethod
|
||||
def _get_unregistered_room_data(slug):
|
||||
"""Describe an unregistered room, which only exists by its slug."""
|
||||
return {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
}
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
Allow unregistered rooms when activated.
|
||||
For unregistered rooms we only return a null id and the livekit room and token.
|
||||
LiveKit credentials are never returned: they are obtained by joining.
|
||||
"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
except Http404:
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||
raise
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
},
|
||||
}
|
||||
else:
|
||||
data = self.get_serializer(instance).data
|
||||
room, slug = self._get_object_or_unregistered_slug()
|
||||
if room is None:
|
||||
return drf_response.Response(self._get_unregistered_room_data(slug))
|
||||
|
||||
return drf_response.Response(data)
|
||||
return drf_response.Response(self.get_serializer(room).data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
@@ -572,6 +572,55 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="join",
|
||||
permission_classes=[],
|
||||
)
|
||||
def join(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Join a room, getting LiveKit credentials when allowed to enter directly.
|
||||
|
||||
The response contains the room with a livekit object holding the url,
|
||||
room and token to connect with when the requester may enter without
|
||||
going through the lobby. Otherwise, it has no livekit object and the
|
||||
client should request entry through the lobby.
|
||||
|
||||
Unregistered rooms, when allowed, are always joined directly.
|
||||
"""
|
||||
|
||||
serializer = serializers.JoinRoomSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
username = serializer.validated_data.get("username") or None
|
||||
|
||||
room, slug = self._get_object_or_unregistered_slug()
|
||||
if room is None:
|
||||
return drf_response.Response(
|
||||
{
|
||||
**self._get_unregistered_room_data(slug),
|
||||
"livekit": utils.generate_livekit_config(
|
||||
room_id=slug, user=request.user, username=username
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
role = room.get_role(request.user)
|
||||
data = self.get_serializer(
|
||||
room, context={**self.get_serializer_context(), "role": role}
|
||||
).data
|
||||
|
||||
if LobbyService.can_bypass_lobby(room=room, user=request.user, role=role):
|
||||
data["livekit"] = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=data["configuration"],
|
||||
role=role,
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -66,6 +66,8 @@ def test_api_rooms_create_authenticated(reset_cache):
|
||||
assert room.name == "my room"
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
assert "livekit" not in response.json()
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
|
||||
rooms_data = cache.keys("room-creation-callback_*")
|
||||
assert not rooms_data
|
||||
@@ -495,3 +497,4 @@ def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
assert "livekit" not in response.json()
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: join.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
from ..utils import generate_user_access_token
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
livekit_token = fake.sha256()
|
||||
|
||||
ANONYMOUS = "anonymous"
|
||||
NO_ROLE = "no-role"
|
||||
|
||||
# Who may join a room directly, without going through the lobby
|
||||
# room.get_role(user) returns None both for an anonymous user and for an
|
||||
# authenticated user without access, hence the NO_ROLE
|
||||
ACCESS_MATRIX = [
|
||||
(RoomAccessLevel.PUBLIC, ANONYMOUS, True),
|
||||
(RoomAccessLevel.TRUSTED, ANONYMOUS, False),
|
||||
(RoomAccessLevel.RESTRICTED, ANONYMOUS, False),
|
||||
(RoomAccessLevel.PUBLIC, NO_ROLE, True),
|
||||
(RoomAccessLevel.TRUSTED, NO_ROLE, True),
|
||||
(RoomAccessLevel.RESTRICTED, NO_ROLE, False),
|
||||
*[
|
||||
(access_level, role, True)
|
||||
for access_level in RoomAccessLevel
|
||||
for role in RoleChoices
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def decode_token(token):
|
||||
"""Decode a LiveKit access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def join_url(room_id):
|
||||
"""Return the url to join a room."""
|
||||
return f"/api/v1.0/rooms/{room_id!s}/join/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access_level", "requester", "can_join_directly"), ACCESS_MATRIX
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_access(mock_token, access_level, requester, can_join_directly):
|
||||
"""
|
||||
Joining a room should return LiveKit credentials and the pin code only to
|
||||
users who may enter without going through the lobby, and the accesses
|
||||
only to the room's administrators and owners.
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=access_level,
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
user = AnonymousUser()
|
||||
username = fake.user_name()
|
||||
|
||||
role = None
|
||||
if requester != ANONYMOUS:
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
if requester != NO_ROLE:
|
||||
role = requester
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
accesses = content.pop("accesses", None)
|
||||
|
||||
if role in (RoleChoices.ADMIN, RoleChoices.OWNER):
|
||||
assert {access["id"] for access in accesses} == {
|
||||
str(access.id) for access in room.accesses.all()
|
||||
}
|
||||
else:
|
||||
assert accesses is None
|
||||
|
||||
expected = {
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(access_level),
|
||||
}
|
||||
if can_join_directly:
|
||||
expected["pin_code"] = room.pin_code
|
||||
expected["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": str(room.id),
|
||||
"token": livekit_token,
|
||||
}
|
||||
assert content == expected
|
||||
|
||||
if can_join_directly:
|
||||
mock_token.assert_called_once_with(
|
||||
room=str(room.id),
|
||||
user=user,
|
||||
username=username,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=role,
|
||||
participant_id=None,
|
||||
)
|
||||
else:
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_by_slug(mock_token):
|
||||
"""It should be possible to join a room by its slug, not normalized."""
|
||||
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url("Réunion"), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == str(room.id)
|
||||
assert response.json()["livekit"]["room"] == str(room.id)
|
||||
mock_token.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_members_num_queries(mock_token, django_assert_num_queries):
|
||||
"""The role of the requester should only be looked up once."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=room, user=user, role=RoleChoices.MEMBER)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
response = client.post(join_url(room.id), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
assert mock_token.call_args.kwargs["role"] == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.OWNER])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_administrators_num_queries(
|
||||
mock_token, role, django_assert_num_queries
|
||||
):
|
||||
"""Only the accesses should be queried on top of the role for administrators."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
response = client.post(join_url(room.id), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
assert "accesses" in response.json()
|
||||
assert mock_token.call_args.kwargs["role"] == role
|
||||
|
||||
|
||||
def test_api_rooms_join_get_not_allowed():
|
||||
"""Joining a room should only be possible with a POST request."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().get(join_url(room.id))
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("authenticated", [False, True])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_allowed(mock_token, authenticated, settings):
|
||||
"""
|
||||
Joining an unregistered room should return LiveKit credentials for the
|
||||
room named after the slug, when unregistered rooms are allowed.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
client = APIClient()
|
||||
user = AnonymousUser()
|
||||
username = fake.user_name()
|
||||
if authenticated:
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
join_url("unregistered-room"), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": "unregistered-room",
|
||||
"token": livekit_token,
|
||||
},
|
||||
}
|
||||
mock_token.assert_called_once_with(
|
||||
room="unregistered-room",
|
||||
user=user,
|
||||
username=username,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_allowed_not_normalized(mock_token, settings):
|
||||
"""
|
||||
Joining an unregistered room by a slug that is not normalized should use
|
||||
the LiveKit room named after the url-safe slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
|
||||
response = APIClient().post(join_url("Réunion"), {}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": "reunion",
|
||||
"token": livekit_token,
|
||||
},
|
||||
}
|
||||
mock_token.assert_called_once_with(
|
||||
room="reunion",
|
||||
user=AnonymousUser(),
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_join_unregistered_token_claims(settings):
|
||||
"""The token of an unregistered room should be scoped to it and carry the name."""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
join_url("unregistered-room"), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["video"]["room"] == "unregistered-room"
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_not_allowed(mock_token, settings):
|
||||
"""
|
||||
Joining an unregistered room should return a 404 if unregistered rooms
|
||||
are not allowed.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = False
|
||||
|
||||
response = APIClient().post(join_url("unregistered-room"), {}, format="json")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lookup", ["---", "!!!", "_"])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_empty_slug(mock_token, lookup, settings):
|
||||
"""
|
||||
Joining an unregistered room whose slug is empty once normalized should
|
||||
return a 404 rather than credentials for a room with no name.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
|
||||
response = APIClient().post(join_url(lookup), {}, format="json")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", ["{}!", "_{}", "{}~", "{}!!"])
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.RESTRICTED]
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_unregistered_uuid_slug(
|
||||
mock_token, access_level, template, settings
|
||||
):
|
||||
"""
|
||||
Joining an unregistered room whose slug is a UUID once normalized should
|
||||
return a 404 rather than credentials for the registered room with that id.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = True
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(template.format(str(room.id).upper())), {}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_soft_deleted(
|
||||
mock_token, lookup, allow_unregistered_rooms, settings
|
||||
):
|
||||
"""
|
||||
Joining a soft-deleted room should return a 410, and never fall back
|
||||
to an unregistered room with the same slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
room.soft_delete()
|
||||
|
||||
response = APIClient().post(join_url(getattr(room, lookup)), {}, format="json")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected_username"),
|
||||
[
|
||||
({"username": "Jean Bon"}, "Jean Bon"),
|
||||
({"username": " Jean Bon "}, "Jean Bon"),
|
||||
({"username": "x" * 255}, "x" * 255),
|
||||
({}, None),
|
||||
({"username": None}, None),
|
||||
({"username": ""}, None),
|
||||
({"username": " "}, None),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username(mock_token, payload, expected_username):
|
||||
"""
|
||||
The trimmed display name should be passed on to the token, a missing or
|
||||
blank one leaving the default name to the token generation.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url(room.id), payload, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["livekit"]["token"] == livekit_token
|
||||
assert mock_token.call_args.kwargs["username"] == expected_username
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username_too_long(mock_token):
|
||||
"""A display name exceeding 255 characters should be rejected."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": "a" * 256}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"username": ["Ensure this field has no more than 255 characters."]
|
||||
}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value=livekit_token)
|
||||
def test_api_rooms_join_username_in_query_string_ignored(mock_token):
|
||||
"""The display name should only be read from the request body."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
f"{join_url(room.id)}?username={username}", {}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_token.call_args.kwargs["username"] is None
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_anonymous():
|
||||
"""The token should carry the submitted display name and be scoped to the room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
username = fake.user_name()
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["video"]["room"] == str(room.id)
|
||||
assert claims["video"]["roomAdmin"] is False
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_anonymous_default_name():
|
||||
"""An anonymous user joining without a display name should get the default one."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(join_url(room.id), {"username": ""}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
def test_api_rooms_join_token_claims_owner():
|
||||
"""The token of an owner should carry the submitted name and the room admin grant."""
|
||||
username = fake.user_name()
|
||||
user = UserFactory(full_name=fake.name())
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
assert claims["sub"] == str(user.sub)
|
||||
assert claims["video"]["roomAdmin"] is True
|
||||
|
||||
|
||||
def test_api_rooms_join_display_name_edit_disabled(settings):
|
||||
"""
|
||||
An authenticated user should keep their account's name when display name
|
||||
edition is disabled, whatever name they submit.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
full_name = f"{fake.first_name()} {fake.last_name()}"
|
||||
user = UserFactory(full_name=full_name)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Impostor"}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == full_name
|
||||
|
||||
|
||||
def test_api_rooms_join_display_name_edit_disabled_anonymous(settings):
|
||||
"""Anonymous users should still choose their display name when edition is disabled."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
username = fake.user_name()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = APIClient().post(
|
||||
join_url(room.id), {"username": username}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["name"] == username
|
||||
|
||||
|
||||
def test_api_rooms_join_session_without_csrf_token():
|
||||
"""A session-authenticated request without a CSRF token should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Jane"}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"].startswith("CSRF Failed")
|
||||
|
||||
|
||||
def test_api_rooms_join_session_with_csrf_token():
|
||||
"""A session-authenticated request with a valid CSRF token should be accepted."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
csrf_secret = get_random_string(32)
|
||||
client.cookies[settings.CSRF_COOKIE_NAME] = csrf_secret
|
||||
|
||||
response = client.post(
|
||||
join_url(room.id),
|
||||
{"username": "Jane"},
|
||||
format="json",
|
||||
HTTP_X_CSRFTOKEN=csrf_secret,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
|
||||
|
||||
def test_api_rooms_join_session_with_csrf_token_foreign_origin():
|
||||
"""A session-authenticated request from a foreign origin should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.force_login(user)
|
||||
csrf_secret = get_random_string(32)
|
||||
client.cookies[settings.CSRF_COOKIE_NAME] = csrf_secret
|
||||
|
||||
response = client.post(
|
||||
join_url(room.id),
|
||||
{"username": "Jane"},
|
||||
format="json",
|
||||
HTTP_X_CSRFTOKEN=csrf_secret,
|
||||
HTTP_ORIGIN="https://evil.example",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"].startswith("CSRF Failed: Origin checking failed")
|
||||
|
||||
|
||||
def test_api_rooms_join_anonymous_without_csrf_token():
|
||||
"""
|
||||
An anonymous request carries no ambient credential, so it should not be
|
||||
subject to CSRF checks.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
|
||||
response = client.post(join_url(room.id), {"username": "Anonymous"}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" in response.json()
|
||||
|
||||
|
||||
def test_api_rooms_join_user_access_token_without_csrf_token():
|
||||
"""
|
||||
A request authenticated with a user access token carries no ambient
|
||||
credential, so it should not be subject to CSRF checks.
|
||||
"""
|
||||
username = fake.user_name()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[(user, "owner")])
|
||||
client = APIClient(enforce_csrf_checks=True)
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.post(join_url(room.id), {"username": username}, format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
assert "accesses" in response.json()
|
||||
claims = decode_token(response.json()["livekit"]["token"])
|
||||
assert claims["sub"] == str(user.sub)
|
||||
@@ -61,6 +61,7 @@ def test_api_rooms_list_authenticated():
|
||||
}
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
assert all("livekit" not in result for result in results)
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
@@ -172,3 +173,4 @@ def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
assert "livekit" not in response.data["results"][0]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: lobby functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613,W0212
|
||||
# pylint: disable=W0621,W0613,W0212,too-many-lines
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -360,6 +360,40 @@ def test_request_entry_invalid_data():
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_request_entry_username_too_long():
|
||||
"""Should return 400 for a username exceeding 255 characters."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(utils, "notify_participants") as mock_notify:
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "a" * 256},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"username": ["Ensure this field has no more than 255 characters."]
|
||||
}
|
||||
mock_notify.assert_not_called()
|
||||
assert not LobbyService()._index_members(room.id)
|
||||
|
||||
|
||||
def test_request_entry_username_at_max_length():
|
||||
"""Should accept a username of exactly 255 characters."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with mock.patch.object(utils, "notify_participants", return_value=None):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "a" * 255},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "a" * 255
|
||||
|
||||
|
||||
def test_request_entry_room_not_found():
|
||||
"""Should return 404 for non-existent room."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -166,6 +166,30 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_null_allows_non_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should treat a stored null everyone_can_mute as unset, hence enabled."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": None})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
|
||||
"""Should allow admins and owners to mute when everyone_can_mute is False."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -366,6 +366,22 @@ def test_rename_participant_name_too_long(room, token):
|
||||
assert "name" in response.data
|
||||
|
||||
|
||||
def test_rename_participant_name_at_max_length(mock_livekit_client, room, token):
|
||||
"""Test rename accepts a name of exactly 255 characters."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "a" * 255},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
assert call_kwargs[0][0].name == "a" * 255
|
||||
|
||||
|
||||
def test_rename_participant_forbidden_without_token(room):
|
||||
"""Test rename returns 403 when no LiveKit token is provided."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
"""Test rooms API endpoints in the Meet core app: retrieve."""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
@@ -112,18 +109,11 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
"""
|
||||
Retrieving an unregistered room should return a Livekit token
|
||||
if unregistered rooms are allowed.
|
||||
Retrieving an unregistered room should describe it if unregistered rooms
|
||||
are allowed, without any LiveKit token: those are obtained by joining.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/unregistered-room/")
|
||||
@@ -133,32 +123,17 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
"token": "foo",
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room="unregistered-room", user=AnonymousUser(), username=None
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_token):
|
||||
"""
|
||||
Getting an unregistered room by a slug that is not normalized should work
|
||||
and use the Livekit room on the url-safe name.
|
||||
and return the url-safe slug.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/Réunion/")
|
||||
@@ -168,17 +143,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
"token": "foo",
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room="reunion", user=AnonymousUser(), username=None
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=False)
|
||||
@@ -193,6 +160,36 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@pytest.mark.parametrize("lookup", ["---", "!!!", "_"])
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_empty_slug(lookup):
|
||||
"""
|
||||
Retrieving an unregistered room whose slug is empty once normalized should
|
||||
return a 404.
|
||||
"""
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{lookup}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@pytest.mark.parametrize("template", ["{}!", "_{}", "{}~"])
|
||||
def test_api_rooms_retrieve_anonymous_unregistered_uuid_slug(template):
|
||||
"""
|
||||
Retrieving an unregistered room whose slug is a UUID once normalized should
|
||||
return a 404.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{template.format(room.id)}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -216,52 +213,33 @@ def test_api_rooms_retrieve_soft_deleted(
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"""
|
||||
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
|
||||
Anonymous users should be able to retrieve a public room with its pin code,
|
||||
but should not be given any token: those are obtained by joining.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room is public.
|
||||
Authenticated users should be allowed to retrieve a public room to which
|
||||
they are not related, with its pin code but without any token.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(
|
||||
@@ -278,44 +256,23 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room has a trusted access_level.
|
||||
Authenticated users should be allowed to retrieve a trusted room to which
|
||||
they are not related, with its pin code but without any token.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
@@ -329,30 +286,16 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated():
|
||||
@@ -381,13 +324,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
@@ -415,40 +351,19 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_administrators(
|
||||
mock_token, django_assert_num_queries, settings
|
||||
):
|
||||
@@ -511,30 +426,16 @@ def test_api_rooms_retrieve_administrators(
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@@ -567,3 +468,77 @@ def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
assert response.data["id"] == str(room.id)
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
assert "accesses" in response.data
|
||||
assert "livekit" not in response.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_never_returns_livekit_credentials(
|
||||
mock_token, role, access_level
|
||||
):
|
||||
"""Retrieving a room should never mint a LiveKit token."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_anonymous_never_returns_livekit_credentials(
|
||||
mock_token, access_level
|
||||
):
|
||||
"""An anonymous user should never get a LiveKit token by retrieving a room."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access_level", "authenticated", "role", "pin_code_exposed"),
|
||||
[
|
||||
(RoomAccessLevel.PUBLIC, False, None, True),
|
||||
(RoomAccessLevel.TRUSTED, False, None, False),
|
||||
(RoomAccessLevel.RESTRICTED, False, None, False),
|
||||
(RoomAccessLevel.PUBLIC, True, None, True),
|
||||
(RoomAccessLevel.TRUSTED, True, None, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, None, False),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.MEMBER, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.ADMIN, True),
|
||||
(RoomAccessLevel.RESTRICTED, True, RoleChoices.OWNER, True),
|
||||
],
|
||||
)
|
||||
def test_api_rooms_retrieve_pin_code_exposure(
|
||||
access_level, authenticated, role, pin_code_exposed
|
||||
):
|
||||
"""
|
||||
The pin code should only be exposed to users who may join the room
|
||||
without going through the lobby.
|
||||
"""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
client = APIClient()
|
||||
if authenticated:
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ("pin_code" in response.json()) is pin_code_exposed
|
||||
if pin_code_exposed:
|
||||
assert response.json()["pin_code"] == room.pin_code
|
||||
|
||||
@@ -113,6 +113,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
@@ -467,6 +468,7 @@ def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
|
||||
room.accesses.filter(user=user).update(role=privileged_role)
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
assert "livekit" not in response.json()
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Test utils.is_uuid
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
|
||||
from core import utils
|
||||
|
||||
fake = Faker()
|
||||
|
||||
UUID = fake.uuid4()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
UUID,
|
||||
UUID.upper(),
|
||||
UUID.replace("-", ""),
|
||||
f"{{{UUID}}}",
|
||||
f"urn:uuid:{UUID}",
|
||||
],
|
||||
ids=["canonical", "uppercase", "hex", "braces", "urn"],
|
||||
)
|
||||
def test_utils_is_uuid_true(value):
|
||||
"""Strings that parse as a UUID should be recognized."""
|
||||
assert utils.is_uuid(value) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["", "my-room", "abc-defg-hij", f"{UUID}!", f"_{UUID}", UUID[:-1]],
|
||||
ids=["empty", "slug", "room-slug", "suffix", "prefix", "truncated"],
|
||||
)
|
||||
def test_utils_is_uuid_false(value):
|
||||
"""Strings that do not parse as a UUID should not be recognized."""
|
||||
assert utils.is_uuid(value) is False
|
||||
@@ -16,7 +16,7 @@ import string
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -336,6 +336,15 @@ def generate_room_slug():
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def is_uuid(value: str) -> bool:
|
||||
"""Tell whether a value parses as a UUID."""
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type using multiple methods for better accuracy.
|
||||
|
||||
@@ -708,3 +708,7 @@ msgstr "Deutsch"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanisch"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugiesisch"
|
||||
|
||||
@@ -703,3 +703,7 @@ msgstr "German"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spanish"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portuguese"
|
||||
|
||||
@@ -705,3 +705,7 @@ msgstr "Alemán"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Español"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugués"
|
||||
|
||||
@@ -709,3 +709,7 @@ msgstr "Allemand"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Espagnol"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugais"
|
||||
|
||||
@@ -703,3 +703,7 @@ msgstr "Duits"
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Spaans"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Portugees"
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
# Portuguese (Brazil) translation for La Suite numérique Meet.
|
||||
# Copyright (C) 2026 La Suite numérique
|
||||
# This file is distributed under the same license as the Meet package.
|
||||
# Contributed by Alan Turing Laboratory (ATLab) - Federal University of Ceará (UFC), Brazil.
|
||||
# Railton Vasconcelos <railton.vasconcelos14@gmail.com>, 2026.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-meet\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"PO-Revision-Date: 2026-11-07 21:00-0300\n"
|
||||
"Last-Translator: Railton Vasconcelos <railton.vasconcelos14@gmail.com> (ATLab - UFC)\n"
|
||||
"Language-Team: Alan Turing Laboratory (ATLab) - Federal University of Ceará (UFC)\n"
|
||||
"Language: pt_BR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Informações pessoais"
|
||||
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr "Conteúdo"
|
||||
|
||||
#: core/admin.py:217
|
||||
msgid "Deletion"
|
||||
msgstr "Exclusão"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr "Informações derivadas"
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr "Carimbos de data e hora"
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr "Pré-visualização do arquivo"
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
msgid "No owner"
|
||||
msgstr "Sem proprietário"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
msgid "Multiple owners"
|
||||
msgstr "Vários proprietários"
|
||||
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Reenviar notificação ao serviço externo"
|
||||
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Falha ao notificar sobre a gravação %(id)s"
|
||||
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Falha ao notificar sobre a gravação %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notificações enviadas com sucesso para %(count)s gravação(ões)."
|
||||
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s gravação(ões) expirada(s) ignorada(s)."
|
||||
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marcar gravações selecionadas como 'Falha ao parar'"
|
||||
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s gravação(ões) marcada(s) com sucesso como 'Falha ao parar'."
|
||||
|
||||
#: core/admin.py:394
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s gravação(ões) com status inelegível ignorada(s)."
|
||||
|
||||
#: core/admin.py:517
|
||||
msgid "No scopes"
|
||||
msgstr "Sem escopos"
|
||||
|
||||
#: core/admin.py:519
|
||||
msgid "Scopes"
|
||||
msgstr "Escopos"
|
||||
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Criado por mim"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "Você deve ser administrador ou proprietário de uma sala para adicionar acessos a ela."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Esta extensão de arquivo não é permitida."
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Você atingiu o número máximo de arquivos para este tipo."
|
||||
|
||||
#: core/models.py:38
|
||||
msgid "Member"
|
||||
msgstr "Membro"
|
||||
|
||||
#: core/models.py:39
|
||||
msgid "Administrator"
|
||||
msgstr "Administrador"
|
||||
|
||||
#: core/models.py:40
|
||||
msgid "Owner"
|
||||
msgstr "Proprietário"
|
||||
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Iniciada"
|
||||
|
||||
#: core/models.py:57
|
||||
msgid "Active"
|
||||
msgstr "Ativa"
|
||||
|
||||
#: core/models.py:58
|
||||
msgid "Stopped"
|
||||
msgstr "Parada"
|
||||
|
||||
#: core/models.py:59
|
||||
msgid "Saved"
|
||||
msgstr "Salva"
|
||||
|
||||
#: core/models.py:60
|
||||
msgid "Aborted"
|
||||
msgstr "Cancelada"
|
||||
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Start"
|
||||
msgstr "Falha ao iniciar"
|
||||
|
||||
#: core/models.py:62
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Falha ao parar"
|
||||
|
||||
#: core/models.py:63
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificação enviada com sucesso"
|
||||
|
||||
#: core/models.py:66
|
||||
msgid "External process successful"
|
||||
msgstr "Processo externo concluído com sucesso"
|
||||
|
||||
#: core/models.py:68
|
||||
msgid "External process failed"
|
||||
msgstr "Falha no processo externo"
|
||||
|
||||
#: core/models.py:97
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "GRAVAÇÃO_DE_TELA"
|
||||
|
||||
#: core/models.py:98
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIÇÃO"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "Public Access"
|
||||
msgstr "Acesso público"
|
||||
|
||||
#: core/models.py:105
|
||||
msgid "Trusted Access"
|
||||
msgstr "Acesso confiável"
|
||||
|
||||
#: core/models.py:106
|
||||
msgid "Restricted Access"
|
||||
msgstr "Acesso restrito"
|
||||
|
||||
#: core/models.py:118
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:119
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "chave primária do registro como UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
msgid "created on"
|
||||
msgstr "criado em"
|
||||
|
||||
#: core/models.py:126
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "data e hora em que o registro foi criado"
|
||||
|
||||
#: core/models.py:131
|
||||
msgid "updated on"
|
||||
msgstr "atualizado em"
|
||||
|
||||
#: core/models.py:132
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "data e hora da última atualização do registro"
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Opcional para usuários pendentes; obrigatório na ativação da conta. Até 255 "
|
||||
"caracteres. Apenas caracteres ASCII imprimíveis."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Insira um sub válido. Este valor deve conter apenas caracteres ASCII imprimíveis."
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "endereço de e-mail de identidade"
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "endereço de e-mail administrativo"
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "nome completo"
|
||||
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "nome curto"
|
||||
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "idioma"
|
||||
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "O idioma no qual o usuário deseja visualizar a interface."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "O fuso horário no qual o usuário deseja visualizar os horários."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr "nível de acesso padrão da sala"
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
"Nível de acesso aplicado por padrão às novas salas criadas por este usuário. "
|
||||
"Quando vazio, o padrão da instância é utilizado."
|
||||
|
||||
#: core/models.py:199
|
||||
msgid "default room configuration"
|
||||
msgstr "configuração padrão da sala"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr "Configurações aplicadas por padrão às novas salas criadas por este usuário."
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: core/models.py:207
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Define se o usuário é um dispositivo ou um usuário real."
|
||||
|
||||
#: core/models.py:210
|
||||
msgid "staff status"
|
||||
msgstr "status da equipe"
|
||||
|
||||
#: core/models.py:212
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Define se o usuário pode acessar este painel de administração."
|
||||
|
||||
#: core/models.py:215
|
||||
msgid "active"
|
||||
msgstr "ativo"
|
||||
|
||||
#: core/models.py:218
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Define se este usuário deve ser tratado como ativo. Desmarque esta opção em "
|
||||
"vez de excluir contas."
|
||||
|
||||
#: core/models.py:231
|
||||
msgid "user"
|
||||
msgstr "usuário"
|
||||
|
||||
#: core/models.py:232
|
||||
msgid "users"
|
||||
msgstr "usuários"
|
||||
|
||||
#: core/models.py:298
|
||||
msgid "Resource"
|
||||
msgstr "Recurso"
|
||||
|
||||
#: core/models.py:299
|
||||
msgid "Resources"
|
||||
msgstr "Recursos"
|
||||
|
||||
#: core/models.py:357
|
||||
msgid "Resource access"
|
||||
msgstr "Acesso a recurso"
|
||||
|
||||
#: core/models.py:358
|
||||
msgid "Resource accesses"
|
||||
msgstr "Acessos a recursos"
|
||||
|
||||
#: core/models.py:364
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Já existe um acesso para este usuário e este recurso."
|
||||
|
||||
#: core/models.py:421
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuração da sala de videoconferência"
|
||||
|
||||
#: core/models.py:422
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valores dos parâmetros de videoconferência para configurar a sala."
|
||||
|
||||
#: core/models.py:429
|
||||
msgid "Room PIN code"
|
||||
msgstr "Código PIN da sala"
|
||||
|
||||
#: core/models.py:430
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Código único de n dígitos que identifica esta sala no modo telefonia."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
msgid "Room"
|
||||
msgstr "Sala"
|
||||
|
||||
#: core/models.py:437
|
||||
msgid "Rooms"
|
||||
msgstr "Salas"
|
||||
|
||||
#: core/models.py:608
|
||||
msgid "Worker ID"
|
||||
msgstr "ID do worker"
|
||||
|
||||
#: core/models.py:610
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Insira um identificador para a gravação do worker. Este ID é mantido mesmo "
|
||||
"quando o worker para, facilitando o rastreamento."
|
||||
|
||||
#: core/models.py:618
|
||||
msgid "Recording mode"
|
||||
msgstr "Modo de gravação"
|
||||
|
||||
#: core/models.py:619
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Define o modo de gravação utilizado."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
msgid "Recording options"
|
||||
msgstr "Opções de gravação"
|
||||
|
||||
#: core/models.py:632
|
||||
msgid "External Process ID"
|
||||
msgstr "ID do processo externo"
|
||||
|
||||
#: core/models.py:633
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID do processo externo associado à gravação."
|
||||
|
||||
#: core/models.py:639
|
||||
msgid "Recording"
|
||||
msgstr "Gravação"
|
||||
|
||||
#: core/models.py:640
|
||||
msgid "Recordings"
|
||||
msgstr "Gravações"
|
||||
|
||||
#: core/models.py:750
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relação gravação/usuário"
|
||||
|
||||
#: core/models.py:751
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relações gravação/usuário"
|
||||
|
||||
#: core/models.py:757
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Este usuário já está nesta gravação."
|
||||
|
||||
#: core/models.py:763
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Esta equipe já está nesta gravação."
|
||||
|
||||
#: core/models.py:769
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Deve ser definido o usuário ou a equipe, mas não ambos."
|
||||
|
||||
#: core/models.py:786
|
||||
msgid "Create rooms"
|
||||
msgstr "Criar salas"
|
||||
|
||||
#: core/models.py:787
|
||||
msgid "List rooms"
|
||||
msgstr "Listar salas"
|
||||
|
||||
#: core/models.py:788
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Obter detalhes da sala"
|
||||
|
||||
#: core/models.py:789
|
||||
msgid "Update rooms"
|
||||
msgstr "Atualizar salas"
|
||||
|
||||
#: core/models.py:790
|
||||
msgid "Delete rooms"
|
||||
msgstr "Excluir salas"
|
||||
|
||||
#: core/models.py:803
|
||||
msgid "Application name"
|
||||
msgstr "Nome da aplicação"
|
||||
|
||||
#: core/models.py:804
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nome descritivo para esta aplicação."
|
||||
|
||||
#: core/models.py:814
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Criptografado ao salvar. Copie agora se este for um novo segredo."
|
||||
|
||||
#: core/models.py:825
|
||||
msgid "Application"
|
||||
msgstr "Aplicação"
|
||||
|
||||
#: core/models.py:826
|
||||
msgid "Applications"
|
||||
msgstr "Aplicações"
|
||||
|
||||
#: core/models.py:849
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Insira um domínio válido"
|
||||
|
||||
#: core/models.py:852
|
||||
msgid "Domain"
|
||||
msgstr "Domínio"
|
||||
|
||||
#: core/models.py:853
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domínio de e-mail em nome do qual esta aplicação pode atuar."
|
||||
|
||||
#: core/models.py:865
|
||||
msgid "Application domain"
|
||||
msgstr "Domínio da aplicação"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "Application domains"
|
||||
msgstr "Domínios da aplicação"
|
||||
|
||||
#: core/models.py:884
|
||||
msgid "Pending"
|
||||
msgstr "Pendente"
|
||||
|
||||
#: core/models.py:885
|
||||
msgid "Analyzing"
|
||||
msgstr "Analisando"
|
||||
|
||||
#: core/models.py:892
|
||||
msgid "Ready"
|
||||
msgstr "Pronto"
|
||||
|
||||
#: core/models.py:898
|
||||
msgid "Background image"
|
||||
msgstr "Imagem de fundo"
|
||||
|
||||
#: core/models.py:910
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: core/models.py:934
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informações de detecção de malware quando o status da análise não for seguro."
|
||||
|
||||
#: core/models.py:939
|
||||
msgid "File"
|
||||
msgstr "Arquivo"
|
||||
|
||||
#: core/models.py:940
|
||||
msgid "Files"
|
||||
msgstr "Arquivos"
|
||||
|
||||
#: core/models.py:1060
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Este arquivo já foi excluído permanentemente."
|
||||
|
||||
#: core/models.py:1070
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr "Para excluir permanentemente um arquivo, ele deve primeiro ser marcado para exclusão."
|
||||
|
||||
#: core/recording/event/notification.py:124
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Sua gravação está pronta"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
msgid "Transcription"
|
||||
msgstr "Transcrição"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Reunião \"{room}\" em {room_recording_date} às {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videochamada em andamento: {sender.email} está aguardando você se conectar"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
msgid "Logo email"
|
||||
msgstr "Logotipo do e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "convida você para participar de uma videochamada em andamento"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "ENTRAR NA CHAMADA"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Se não conseguir clicar no botão, copie e cole a URL no seu navegador para "
|
||||
"entrar na chamada."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Dicas para uma melhor experiência:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use o Chrome ou o Firefox para melhor qualidade de chamada"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Teste seu microfone e sua câmera antes de entrar"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Certifique-se de ter uma conexão de internet estável"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Obrigado por usar o %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
"Este e-mail foi enviado para %(email)s por <a href=\"%(href)s\">%(name)s</a>"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Sua gravação está pronta!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Sua gravação de \"%(room_name)s\" em %(recording_date)s às "
|
||||
"%(recording_time)s já está pronta para download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " A gravação expirará em %(days)s dias. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" O compartilhamento da gravação por link ainda não está disponível. Apenas "
|
||||
"os organizadores podem baixá-la. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Para manter esta gravação permanentemente:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Clique no link \"<a href=\"%(link)s\">Abrir</a>\" abaixo "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use o botão \"Baixar\" na interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Salve o arquivo no local de sua preferência"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Se tiver alguma dúvida ou precisar de ajuda, entre em contato com nossa "
|
||||
"equipe de suporte em %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr "Este e-mail foi enviado para %(email)s por %(name)s [%(href)s]"
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#, python-format
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Clique no link \"Abrir [%(link)s]\" abaixo "
|
||||
|
||||
#: meet/settings.py:238
|
||||
msgid "English"
|
||||
msgstr "Inglês"
|
||||
|
||||
#: meet/settings.py:239
|
||||
msgid "French"
|
||||
msgstr "Francês"
|
||||
|
||||
#: meet/settings.py:240
|
||||
msgid "Dutch"
|
||||
msgstr "Holandês"
|
||||
|
||||
#: meet/settings.py:241
|
||||
msgid "German"
|
||||
msgstr "Alemão"
|
||||
|
||||
#: meet/settings.py:242
|
||||
msgid "Spanish"
|
||||
msgstr "Espanhol"
|
||||
|
||||
#: meet/settings.py:243
|
||||
msgid "Portuguese"
|
||||
msgstr "Português"
|
||||
@@ -302,6 +302,7 @@ class Base(Configuration):
|
||||
("nl-nl", _("Dutch")),
|
||||
("de-de", _("German")),
|
||||
("es-es", _("Spanish")),
|
||||
("pt-br", _("Portuguese")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -47,14 +47,10 @@ ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
FROM nginx/nginx-unprivileged:1.30.6-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
|
||||
'libx11>=1.8.14-r0' 'ca-certificates>=20261020-r0' \
|
||||
'brotli-libs>=1.2.1-r0' && \
|
||||
apk del curl && \
|
||||
RUN apk del curl && \
|
||||
rm -f /usr/share/nginx/html/50x.html
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de", "nl", "es"],
|
||||
"locales": ["en", "fr", "de", "nl", "es", "pt"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
@@ -9,12 +9,8 @@ import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
@@ -30,9 +26,9 @@ export const CreateMeetingMenu = () => {
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
createRoom({ slug }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
state: { create: true },
|
||||
})
|
||||
)
|
||||
}}
|
||||
@@ -45,7 +41,7 @@ export const CreateMeetingMenu = () => {
|
||||
className={menuRecipe({ icon: true, variant: 'light' }).item}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then(setLaterRoom)
|
||||
createRoom({ slug }).then(setLaterRoom)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
import { extractRoomId } 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 extracted = extractRoomId(data.roomId as string)
|
||||
if (!extracted) return
|
||||
navigateTo('room', `${extracted.roomId}${extracted.suffix}`)
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
return !isRoomValid(trimmed) ? (
|
||||
return !extractRoomId(trimmed) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
|
||||
@@ -31,7 +31,6 @@ export type ApiRoom = {
|
||||
name: string
|
||||
slug: string
|
||||
pin_code?: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
|
||||
@@ -6,15 +6,13 @@ import type { ApiRoom } from './ApiRoom'
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
return fetchApi('rooms/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export const fetchRoom = ({
|
||||
roomId,
|
||||
username,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
||||
export const fetchRoom = ({ roomId }: { roomId: string }) => {
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type JoinRoomParams = {
|
||||
roomId: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export const joinRoom = ({ roomId, username }: JoinRoomParams) => {
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/join/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@@ -10,17 +10,16 @@ import {
|
||||
ConnectionErrorReason,
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
type Room,
|
||||
type RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { QueryAware } from '@/components/QueryAware'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import type { ApiRoom } from '../api/ApiRoom'
|
||||
import { joinRoom } from '../api/joinRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
@@ -32,7 +31,7 @@ import {
|
||||
captureMediaEvent,
|
||||
reportError,
|
||||
} from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { type ApiConfig, useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
@@ -48,6 +47,13 @@ import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioO
|
||||
import { ConnectionEndReason } from '@/features/rooms/utils/connectionEndReason'
|
||||
import { createLiveKitRoom } from '@/features/rooms/livekit/utils/reconnectPolicy'
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
const handleError = (e: Error) => {
|
||||
const failure = getMediaDeviceFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
@@ -66,65 +72,111 @@ const handleError = (e: Error) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const warmUpConnection = async (room: Room, livekit: ApiConfig['livekit']) => {
|
||||
await room.prepareConnection(livekit.url)
|
||||
|
||||
if (isFireFox() && livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
livekit.url.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered insecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getConnectionEndReason = (
|
||||
reason: DisconnectReason | undefined,
|
||||
hasConnected: boolean
|
||||
) => {
|
||||
switch (reason) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
return undefined
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
return reason
|
||||
default:
|
||||
// Reconnecting gave up, or the join itself failed
|
||||
return hasConnected
|
||||
? ConnectionEndReason.Lost
|
||||
: ConnectionEndReason.Failed
|
||||
}
|
||||
}
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
initialRoomData,
|
||||
mode = 'join',
|
||||
onLobbyRequired,
|
||||
}: {
|
||||
roomId: string
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
onLobbyRequired: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
status: createStatus,
|
||||
isError: isCreateError,
|
||||
} = useCreateRoom({
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(fetchKey, data)
|
||||
},
|
||||
})
|
||||
const [hasJoined, setHasJoined] = useState(false)
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
const hasConnectedRef = useRef(false)
|
||||
|
||||
const { mutateAsync: createRoom, isError: isCreateError } = useCreateRoom()
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
error: fetchError,
|
||||
data,
|
||||
data: createRoomData,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
queryKey: [keys.room, roomId],
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
roomId: roomId as string,
|
||||
username: username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
// A deleted room can't be recreated, surface the error instead
|
||||
if (error.statusCode == '410') throw error
|
||||
joinRoom({ roomId, username }).catch(async (error) => {
|
||||
if (error.statusCode != 404) throw error
|
||||
await createRoom({ slug: roomId })
|
||||
return joinRoom({ roomId, username })
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isLobbyRequired =
|
||||
!!createRoomData && !createRoomData.livekit && !hasJoined
|
||||
const status =
|
||||
createRoomData?.livekit || hasJoined
|
||||
? 'success'
|
||||
: isLobbyRequired
|
||||
? 'pending'
|
||||
: fetchStatus
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
@@ -155,62 +207,11 @@ export const Conference = ({
|
||||
apiConfig?.livekit.default_video_codec,
|
||||
])
|
||||
|
||||
const reconnect = apiConfig?.livekit.reconnect
|
||||
const room = useMemo(
|
||||
() => createLiveKitRoom(roomOptions, reconnect),
|
||||
[roomOptions, reconnect]
|
||||
() => createLiveKitRoom(roomOptions, apiConfig?.livekit.reconnect),
|
||||
[roomOptions, apiConfig?.livekit.reconnect]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered insecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
const hasConnectedRef = useRef(false)
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
@@ -225,7 +226,61 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
const handleConnected = useCallback(async () => {
|
||||
hasConnectedRef.current = true
|
||||
if (!apiConfig) return
|
||||
if (
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled &&
|
||||
!hasAutoMutedRef.current &&
|
||||
userConfig.audioEnabled &&
|
||||
room.numParticipants > apiConfig.auto_mute_on_join_threshold
|
||||
) {
|
||||
hasAutoMutedRef.current = true
|
||||
await room.localParticipant.setMicrophoneEnabled(false)
|
||||
notifyAutoMutedOnJoin()
|
||||
}
|
||||
}, [
|
||||
apiConfig,
|
||||
room,
|
||||
userConfig.audioEnabled,
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled,
|
||||
])
|
||||
|
||||
const handleDisconnected = useCallback(
|
||||
(reason?: DisconnectReason) => {
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: getConnectionEndReason(reason, hasConnectedRef.current),
|
||||
room_id: roomId,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[roomId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (createRoomData?.livekit) setHasJoined(true)
|
||||
}, [createRoomData?.livekit])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLobbyRequired) onLobbyRequired()
|
||||
}, [isLobbyRequired, onLobbyRequired])
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
warmUpConnection(room, apiConfig.livekit).then(() =>
|
||||
setIsConnectionWarmedUp(true)
|
||||
)
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
if (fetchError?.statusCode == 410) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
@@ -245,20 +300,13 @@ export const Conference = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||
<QueryAware status={status}>
|
||||
<Screen header={false} footer={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
token={createRoomData?.livekit?.token}
|
||||
connect={isConnectionWarmedUp}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
@@ -273,65 +321,8 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={handleError}
|
||||
onConnected={async () => {
|
||||
hasConnectedRef.current = true
|
||||
if (!apiConfig) return
|
||||
if (
|
||||
userPreferencesSnap.is_auto_mute_large_room_enabled &&
|
||||
!hasAutoMutedRef.current &&
|
||||
userConfig.audioEnabled &&
|
||||
room.numParticipants > apiConfig.auto_mute_on_join_threshold
|
||||
) {
|
||||
hasAutoMutedRef.current = true
|
||||
await room.localParticipant.setMicrophoneEnabled(false)
|
||||
notifyAutoMutedOnJoin()
|
||||
}
|
||||
}}
|
||||
onDisconnected={(e) => {
|
||||
const metadata = {
|
||||
room_id: roomId,
|
||||
}
|
||||
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: { ...metadata },
|
||||
}
|
||||
)
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: e,
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
default:
|
||||
// Reconnecting gave up, or the join itself failed
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: {
|
||||
reason: hasConnectedRef.current
|
||||
? ConnectionEndReason.Lost
|
||||
: ConnectionEndReason.Failed,
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
onConnected={handleConnected}
|
||||
onDisconnected={handleDisconnected}
|
||||
>
|
||||
<WatchMediaDeviceErrors />
|
||||
<VideoConference />
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
|
||||
|
||||
// fixme - extract in a proper primitive this dialog without overlay
|
||||
const StyledRACDialog = styled(Dialog, {
|
||||
@@ -64,9 +67,20 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(roomData)
|
||||
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
|
||||
const dismiss = () => {
|
||||
// The dialog opens on its own when joining, so there is no trigger to
|
||||
// restore focus to: move it to the control bar instead of body.
|
||||
getFirstControlBarFocusable(CONTROL_BAR_REGION_ID)?.focus()
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
useEscapeToClose(showInviteDialog, dialogRef, dismiss)
|
||||
|
||||
if (!showInviteDialog) return null
|
||||
return (
|
||||
<StyledRACDialog>
|
||||
<StyledRACDialog ref={dialogRef}>
|
||||
<VStack
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
@@ -81,9 +95,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
onPress={dismiss}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
@@ -10,15 +10,40 @@ import { Form, Text } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { saveUsername, userStore } from '@/stores/user'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
|
||||
import { joinRoom, type JoinRoomParams } from '../api/joinRoom'
|
||||
import { ApiAccessLevel, type ApiRoom } from '../api/ApiRoom'
|
||||
import { ApiLobbyStatus } from '../api/requestEntry'
|
||||
import { useLobby } from '../hooks/useLobby'
|
||||
|
||||
const LobbyMessage = ({
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
body: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{title}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={children ? css({ marginBottom: '1.5rem' }) : undefined}
|
||||
>
|
||||
{body}
|
||||
</Text>
|
||||
{children}
|
||||
</VStack>
|
||||
)
|
||||
|
||||
export const Lobby = ({
|
||||
roomId,
|
||||
enterRoom,
|
||||
@@ -27,124 +52,92 @@ export const Lobby = ({
|
||||
enterRoom: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { data: configData } = useConfig()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const { username } = useSnapshot(userStore)
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
const displayName = username || user?.full_name
|
||||
|
||||
// Join strategy:
|
||||
// 1. Submitting the form asks to join the room, which checks access
|
||||
// 2. The response is written to the [keys.room] cache entry, which
|
||||
// Conference reuses without refetching. It holds the LiveKit credentials
|
||||
// when the user may bypass the lobby
|
||||
// 3. Otherwise the user waits in the lobby, and the credentials returned
|
||||
// once accepted are added to it
|
||||
const {
|
||||
mutate: join,
|
||||
data: roomData,
|
||||
error,
|
||||
isError,
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && error?.statusCode == 404) {
|
||||
} = useMutation<ApiRoom, ApiError, JoinRoomParams>({
|
||||
mutationFn: joinRoom,
|
||||
onSuccess: (room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
},
|
||||
onError: ({ statusCode }) => {
|
||||
// The room component will handle the room creation if the user is authenticated
|
||||
enterRoom()
|
||||
}
|
||||
}, [isError, error, enterRoom])
|
||||
|
||||
const handleAccepted = (response: ApiRequestEntry) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
}
|
||||
if (statusCode == 404) enterRoom()
|
||||
},
|
||||
})
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username: username || user?.full_name || 'anonymous',
|
||||
onAccepted: handleAccepted,
|
||||
username: displayName || 'anonymous',
|
||||
onAccepted: (response) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
},
|
||||
})
|
||||
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data, error } = await refetchRoom()
|
||||
|
||||
if (error?.statusCode == 410) return
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
return
|
||||
}
|
||||
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const isRoomDeleted = isError && error?.statusCode == 410
|
||||
const lobbyStatus = isRoomDeleted ? ApiLobbyStatus.DELETED : status
|
||||
const hasJoinFailed = isError && !isRoomDeleted && error?.statusCode != 404
|
||||
|
||||
const handleSubmit = () => {
|
||||
join(
|
||||
{ roomId, username: displayName || undefined },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.livekit) {
|
||||
enterRoom()
|
||||
return
|
||||
}
|
||||
if (data.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
switch (lobbyStatus) {
|
||||
case ApiLobbyStatus.DELETED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('deleted.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('deleted.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<LobbyMessage title={t('deleted.title')} body={t('deleted.body')} />
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('timeoutInvite.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<LobbyMessage
|
||||
title={t('timeoutInvite.title')}
|
||||
body={t('timeoutInvite.body')}
|
||||
/>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('denied.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
return <LobbyMessage title={t('denied.title')} body={t('denied.body')} />
|
||||
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={css({ marginBottom: '1.5rem' })}
|
||||
>
|
||||
{t('waiting.body')}
|
||||
</Text>
|
||||
<LobbyMessage title={t('waiting.title')} body={t('waiting.body')}>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
</LobbyMessage>
|
||||
)
|
||||
|
||||
default:
|
||||
@@ -168,7 +161,7 @@ export const Lobby = ({
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
defaultValue={displayName}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
@@ -178,6 +171,11 @@ export const Lobby = ({
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
{hasJoinFailed && (
|
||||
<Text variant="warning" role="alert" margin={false} centered>
|
||||
{t('errors.joinFailed')}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
roomIdPattern,
|
||||
isRoomValid,
|
||||
extractRoomId,
|
||||
flexibleRoomIdPattern,
|
||||
} from './utils/isRoomValid'
|
||||
export { generateRoomId } from './utils/generateRoomId'
|
||||
|
||||
@@ -3,13 +3,11 @@ import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
@@ -34,12 +32,10 @@ export const Admin = () => {
|
||||
}
|
||||
}, [isAdminOrOwner])
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
// Read the cached room only: an observer with its own queryFn could serve
|
||||
// the refetch that follows a setting change with a GET, which returns no
|
||||
// LiveKit credentials
|
||||
const readOnlyData = useRoomData()
|
||||
|
||||
const {
|
||||
toggleMicrophone,
|
||||
|
||||
@@ -32,10 +32,12 @@ const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||
// Set by the conference when the user turns out to have to go through the
|
||||
// lobby, even when the join screen was skipped
|
||||
const [isLobbyRequired, setIsLobbyRequired] = useState(false)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
|
||||
@@ -75,10 +77,16 @@ const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
if (isLobbyRequired || (!hasSubmittedEntry && !skipJoinScreen)) {
|
||||
return (
|
||||
<BaseRoom>
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
<Join
|
||||
enterRoom={() => {
|
||||
setIsLobbyRequired(false)
|
||||
setHasSubmittedEntry(true)
|
||||
}}
|
||||
roomId={roomId}
|
||||
/>
|
||||
</BaseRoom>
|
||||
)
|
||||
}
|
||||
@@ -86,9 +94,10 @@ const Room = () => {
|
||||
return (
|
||||
<BaseRoom>
|
||||
<Conference
|
||||
initialRoomData={initialRoomData}
|
||||
key={roomId}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
onLobbyRequired={() => setIsLobbyRequired(true)}
|
||||
/>
|
||||
</BaseRoom>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeRoomId } from './isRoomValid'
|
||||
import { extractRoomId, normalizeRoomId } from './isRoomValid'
|
||||
|
||||
describe('extractRoomId', () => {
|
||||
const origin = 'https://meet.example.com'
|
||||
|
||||
const plain = { roomId: 'abc-defg-hij', suffix: '' }
|
||||
|
||||
it('accepts a bare room id', () => {
|
||||
expect(extractRoomId('abc-defg-hij', origin)).toEqual(plain)
|
||||
expect(extractRoomId(' abc-defg-hij ', origin)).toEqual(plain)
|
||||
})
|
||||
|
||||
it('accepts a meeting URL on this origin', () => {
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij`, origin)).toEqual(plain)
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij/`, origin)).toEqual(plain)
|
||||
})
|
||||
|
||||
it('keeps query strings and fragments in the suffix', () => {
|
||||
expect(
|
||||
extractRoomId(`${origin}/abc-defg-hij?from=outlook-addin`, origin)
|
||||
).toEqual({ roomId: 'abc-defg-hij', suffix: '?from=outlook-addin' })
|
||||
expect(
|
||||
extractRoomId(
|
||||
`${origin}/abc-defg-hij/?utm_source=mail&utm_medium=email#x`,
|
||||
origin
|
||||
)
|
||||
).toEqual({
|
||||
roomId: 'abc-defg-hij',
|
||||
suffix: '?utm_source=mail&utm_medium=email#x',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects other origins and invalid ids', () => {
|
||||
expect(
|
||||
extractRoomId('https://evil.example/abc-defg-hij', origin)
|
||||
).toBeNull()
|
||||
expect(extractRoomId(`${origin}/not-a-room`, origin)).toBeNull()
|
||||
expect(extractRoomId(`${origin}/abc-defg-hij/extra`, origin)).toBeNull()
|
||||
expect(extractRoomId('abc-defg-hij?from=x', origin)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeRoomId', () => {
|
||||
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
|
||||
|
||||
@@ -10,6 +10,28 @@ export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
roomRegex.test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
|
||||
export const extractRoomId = (
|
||||
input: string,
|
||||
origin: string = window.location.origin
|
||||
): { roomId: string; suffix: string } | null => {
|
||||
const value = input.trim()
|
||||
if (roomRegex.test(value)) return { roomId: value, suffix: '' }
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.origin !== origin) return null
|
||||
|
||||
const path = url.pathname.slice(1)
|
||||
const roomId = path.endsWith('/') ? path.slice(0, -1) : path
|
||||
return roomRegex.test(roomId)
|
||||
? { roomId, suffix: `${url.search}${url.hash}` }
|
||||
: null
|
||||
}
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
||||
if (cleanId.length === 10) {
|
||||
|
||||
@@ -15,7 +15,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es', 'pt'],
|
||||
fallbackLng,
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const languageLabels: Record<string, string> = {
|
||||
de: 'Deutsch',
|
||||
nl: 'Nederlands',
|
||||
es: 'Español',
|
||||
pt: 'Português',
|
||||
}
|
||||
|
||||
export const useLanguageLabels = () => {
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Wird anderen Teilnehmenden angezeigt",
|
||||
"usernameLabel": "Dein Name",
|
||||
"errors": {
|
||||
"joinFailed": "Beitritt zum Meeting nicht möglich. Bitte versuche es erneut.",
|
||||
"usernameEmpty": "Dein Name darf nicht leer sein"
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"joinFailed": "Unable to join the meeting. Please try again.",
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Se muestra a los demás participantes",
|
||||
"usernameLabel": "Tu nombre",
|
||||
"errors": {
|
||||
"joinFailed": "No se ha podido acceder a la reunión. Inténtalo de nuevo.",
|
||||
"usernameEmpty": "Tu nombre no puede estar vacío"
|
||||
},
|
||||
"cameraDisabled": "La cámara está desactivada.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"joinFailed": "Impossible de rejoindre la réunion. Veuillez réessayer.",
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"usernameHint": "Getoond aan andere deelnemers",
|
||||
"usernameLabel": "Uw naam",
|
||||
"errors": {
|
||||
"joinFailed": "Deelnemen aan de vergadering is niet gelukt. Probeer het opnieuw.",
|
||||
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"accessibility": {
|
||||
"title": "Acessibilidade",
|
||||
"introduction": "O <i>Visio</i> está comprometido em tornar seus serviços digitais acessíveis, em conformidade com o Artigo 47 da Lei nº 2005-102, de 11 de fevereiro de 2005.",
|
||||
"declaration": {
|
||||
"title": "Declaração de Acessibilidade",
|
||||
"date": "Estabelecida em 4 de dezembro de 2024."
|
||||
},
|
||||
"scope": "Esta declaração de acessibilidade aplica-se ao site visio.numerique.gouv.fr",
|
||||
"complianceStatus": {
|
||||
"title": "Status de Conformidade",
|
||||
"body": "O site visio.numerique.gouv.fr não está em conformidade com o RGAA 4.1. O site ainda não foi auditado. No entanto, a equipe se esforça para criar um site acessível a todos, seguindo as recomendações do RGAA."
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Melhoria e Contato",
|
||||
"body": "Se você não conseguir acessar um conteúdo ou serviço, pode entrar em contato com o responsável por lasuite.numerique.gouv.fr para ser direcionado a uma alternativa acessível ou obter o conteúdo de outra forma.",
|
||||
"contact": {
|
||||
"email": "E-mail: visio@numerique.gouv.fr",
|
||||
"address": "Endereço: DINUM, 20 avenue de Ségur 75007 Paris"
|
||||
},
|
||||
"response": "Tentamos responder em até 2 dias úteis."
|
||||
},
|
||||
"recourse": {
|
||||
"title": "Meios de Recurso",
|
||||
"introduction": "Este procedimento deve ser utilizado no seguinte caso: você relatou ao responsável pelo site um problema de acessibilidade que o impede de acessar um conteúdo ou um dos serviços do portal e não recebeu uma resposta satisfatória.",
|
||||
"options": {
|
||||
"intro": "Você pode:",
|
||||
"option1": "Enviar uma mensagem ao Defensor dos Direitos",
|
||||
"option2": "Entrar em contato com o delegado do Defensor dos Direitos em sua região",
|
||||
"option3": "Enviar uma carta pelo correio (gratuito, não precisa de selo): </br> Défenseur des droits Libre réponse 71120 75342 Paris CEDEX 07"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"title": "Testar sua configuração",
|
||||
"runTest": "Iniciar teste",
|
||||
"runAgain": "Testar novamente",
|
||||
"cancel": "Cancelar",
|
||||
"detailsFor": "Detalhes de {{step}}",
|
||||
"downloadReport": "Baixar relatório",
|
||||
"homeLink": "Testar sua configuração",
|
||||
"progress": "{{done}}/{{total}}",
|
||||
"progressLabel": "Progresso do teste de conexão",
|
||||
"groups": {
|
||||
"local": "Navegador e dispositivos",
|
||||
"network": "Conectividade com o servidor"
|
||||
},
|
||||
"steps": {
|
||||
"browser": "Navegador",
|
||||
"microphone": "Microfone",
|
||||
"camera": "Câmera",
|
||||
"devices": "Dispositivos de mídia",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconexão",
|
||||
"selectedCandidate": "Rota selecionada",
|
||||
"publishAudio": "Publicação de áudio",
|
||||
"publishVideo": "Publicação de vídeo"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pendente",
|
||||
"running": "Em execução…",
|
||||
"success": "Aprovado",
|
||||
"warning": "Não ideal",
|
||||
"failed": "Falhou",
|
||||
"skipped": "Ignorado"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "aprovados",
|
||||
"warnings": "não ideais",
|
||||
"failed": "falharam",
|
||||
"skipped": "ignorados"
|
||||
},
|
||||
"summary": {
|
||||
"idle": "Pronto para testar",
|
||||
"idleHint": "O teste leva cerca de um minuto. Sua câmera e microfone são usados apenas durante a execução.",
|
||||
"running": "Testando…",
|
||||
"runningHint": "Mantenha esta página aberta até que todas as verificações terminem.",
|
||||
"passed": "Tudo funciona perfeitamente",
|
||||
"passedHint": "Seu navegador, seus dispositivos e sua rede estão prontos para uma reunião.",
|
||||
"partial": "Parcialmente testado",
|
||||
"partialHint": "Algumas verificações foram ignoradas. Permita o acesso à sua câmera e ao microfone para testá-los.",
|
||||
"warning": "Conexão abaixo do ideal",
|
||||
"warningHint": "Você pode participar das reuniões, mas a qualidade de vídeo e áudio pode ser reduzida devido às configurações da sua rede. Sua equipe de TI pode melhorar isso.",
|
||||
"warningDocLink": "Requisitos de rede para sua equipe de TI",
|
||||
"warningDocLinkAriaLabel": "Abrir os requisitos de rede para sua equipe de TI - abre em uma nova janela",
|
||||
"failed_one": "{{count}} verificação falhou",
|
||||
"failed_other": "{{count}} verificações falharam",
|
||||
"failedHint": "Abra as verificações que falharam abaixo para ver os detalhes e envie o relatório para sua equipe de TI."
|
||||
},
|
||||
"help": {
|
||||
"firewall": "Se os testes de rede falharem, verifique as permissões do navegador e as regras de filtragem de rede (WebRTC, WebSocket, TURN) com sua equipe de TI."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"backToHome": "Voltar para a tela inicial",
|
||||
"cancel": "Cancelar",
|
||||
"closeDialog": "Fechar diálogo",
|
||||
"error": {
|
||||
"heading": "Ocorreu um erro ao carregar a página"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Sua opinião ajuda a melhorar o produto!",
|
||||
"cta": "Compartilhar sua opinião - nova aba"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "Você não tem permissão para ver esta página"
|
||||
},
|
||||
"loading": "Carregando…",
|
||||
"loggedInUserTooltip": "Conectado como ",
|
||||
"login": {
|
||||
"buttonLabel": "Entrar",
|
||||
"proconnectButtonLabel": "Entrar com ProConnect",
|
||||
"proconnectLinkLabel": "O que é ProConnect? - nova aba",
|
||||
"proconnectLink": "O que é ProConnect?"
|
||||
},
|
||||
"logout": "Sair",
|
||||
"notFound": {
|
||||
"heading": "Verifique o código da reunião",
|
||||
"body": "Verifique se você digitou corretamente o código da reunião na URL. Exemplo:"
|
||||
},
|
||||
"selected": "selecionado",
|
||||
"submit": "OK",
|
||||
"support": {
|
||||
"buttonLabel": "Contatar suporte",
|
||||
"openError": "Não foi possível abrir o formulário de contato. Tente novamente mais tarde.",
|
||||
"sent": {
|
||||
"title": "Mensagem enviada",
|
||||
"body": "A equipe de suporte recebeu sua mensagem.",
|
||||
"close": "Fechar"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
"infogouv": "info.gouv.fr",
|
||||
"servicepublic": "service-public.fr",
|
||||
"datagouv": "data.gouv.fr",
|
||||
"legalsTerms": "Aviso Legal",
|
||||
"data": "Dados Pessoais e Cookies",
|
||||
"accessibility": "Acessibilidade: não conforme",
|
||||
"connectionTest": "Testar sua configuração",
|
||||
"ariaLabel": "nova janela",
|
||||
"codeAnnotation": "Nosso código é aberto e disponível neste",
|
||||
"code": "Repositório de Código Aberto",
|
||||
"technicalDetails": "Aviso Técnico",
|
||||
"termsOfService": "Termos de serviço"
|
||||
},
|
||||
"mentions": "Salvo indicação em contrário, os conteúdos deste site estão disponíveis sob",
|
||||
"license": "licença etalab 2.0"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Entre com sua conta",
|
||||
"body": "Em vez de esperar, entre com sua conta.",
|
||||
"button": {
|
||||
"ariaLabel": "Fechar a sugestão",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"skipLink": "Ir para o conteúdo principal",
|
||||
"clipboardContent": {
|
||||
"url": "Para entrar na videoconferência, clique neste link: {{roomUrl}}",
|
||||
"numberAndPin": "Para entrar por telefone, ligue para {{phoneNumber}} e digite o código: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"createMeeting": "Criar uma reunião",
|
||||
"heading": "Videoconferência Simples e Segura",
|
||||
"intro": "Comunique-se e trabalhe com facilidade, sem comprometer sua soberania",
|
||||
"joinInputError": "Use um link ou código de reunião. Exemplos:",
|
||||
"joinInputExample": "URL ou código de 10 letras",
|
||||
"joinInputLabel": "Link da reunião",
|
||||
"joinInputSubmit": "Entrar na reunião",
|
||||
"joinMeeting": "Entrar em uma reunião",
|
||||
"joinMeetingTipContent": "Você pode entrar em uma reunião colando o link completo na barra de endereços do navegador.",
|
||||
"joinMeetingTipHeading": "Você sabia?",
|
||||
"loginToCreateMeeting": "Faça login para criar uma reunião",
|
||||
"moreLinkLabel": "Saiba mais sobre {{appTitle}} - nova aba",
|
||||
"moreLink": "Saiba mais",
|
||||
"moreAbout": "sobre {{appTitle}}",
|
||||
"connectionTestLink": "Testar sua configuração",
|
||||
"createMenu": {
|
||||
"laterOption": "Criar uma reunião para depois",
|
||||
"instantOption": "Iniciar uma reunião instantânea"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Seus dados de conexão",
|
||||
"description": "Compartilhe essas informações com os convidados. Eles poderão entrar na reunião sem precisar se autenticar. Esta reunião é permanente e pode ser reutilizada.",
|
||||
"permissions": "Pessoas com este link não precisam de sua permissão para entrar nesta reunião.",
|
||||
"copy": "Copiar informações",
|
||||
"copied": "Informações copiadas para a área de transferência",
|
||||
"copyUrl": "Copiar o link da reunião",
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "Slide anterior",
|
||||
"labelWithPosition": "Slide anterior ({{current}} de {{total}})",
|
||||
"tooltip": "Slide anterior"
|
||||
},
|
||||
"next": {
|
||||
"label": "Próximo slide",
|
||||
"labelWithPosition": "Próximo slide ({{current}} de {{total}})",
|
||||
"tooltip": "Próximo slide"
|
||||
},
|
||||
"beta": {
|
||||
"text": "Participar do beta",
|
||||
"tooltip": "Preencher o formulário"
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Mude para a simplicidade. Experimente agora!",
|
||||
"body": "Descubra uma solução intuitiva e acessível, projetada para todos os agentes públicos, seus parceiros e muito mais."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Faça chamadas em grupo sem limites",
|
||||
"body": "Reuniões sem limite de tempo, com comunicação fluida e de alta qualidade, independentemente do tamanho do grupo."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transforme suas reuniões com IA",
|
||||
"body": "Obtenha transcrições precisas e acionáveis para aumentar sua produtividade. Recurso em beta — experimente agora!"
|
||||
},
|
||||
"carouselLabel": "Apresentação de slides introdutória",
|
||||
"slidePosition": "Slide {{current}} de {{total}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Aviso Legal",
|
||||
"creator": {
|
||||
"title": "Editor",
|
||||
"body": "O serviço Visio é publicado pela Direção Interministerial do Digital do Estado (DINUM), localizada em:",
|
||||
"contact": {
|
||||
"title": "Informações de Contato",
|
||||
"address": "20 avenue de Ségur",
|
||||
"city": "75007 Paris",
|
||||
"phone": "Tel. da recepção: 01.71.21.01.70",
|
||||
"siret": "SIRET: 12000101100010 (secretaria-geral do governo)",
|
||||
"siren": "SIREN: 120 001 011"
|
||||
}
|
||||
},
|
||||
"director": {
|
||||
"title": "Diretor de Publicação",
|
||||
"body": "A diretora de publicação é Madame Stéphanie Schaer, Diretora Interministerial do Digital."
|
||||
},
|
||||
"hosting": {
|
||||
"title": "Hospedagem",
|
||||
"body": "O serviço é hospedado pela Outscale West-1 CloudGouv SecNumCloud, localizada na França."
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "Acessibilidade",
|
||||
"body": "A conformidade com os padrões de acessibilidade digital é um objetivo futuro. O site ainda não foi auditado. No entanto, a equipe se esforça para criar um site acessível a todos, seguindo as recomendações do RGAA.",
|
||||
"more": "Para mais informações: ",
|
||||
"link": "link para a página de acessibilidade",
|
||||
"status": "Status de acessibilidade: não conforme"
|
||||
},
|
||||
"reuse": {
|
||||
"title": "Reutilização de Conteúdo e Links",
|
||||
"body1": "Salvo menção explícita de propriedade intelectual detida por terceiros, os conteúdos deste site são disponibilizados sob a ",
|
||||
"license": "licença aberta Etalab 2.0",
|
||||
"body2": "Você é livre, em especial, para reproduzir, copiar, modificar, extrair, transformar, comunicar, distribuir, redistribuir, publicar, transmitir e explorar os conteúdos, desde que mencione a fonte, a data de sua última atualização e não induza terceiros a erro quanto às informações neles contidas.",
|
||||
"body3": "Qualquer site público ou privado está autorizado a estabelecer, sem autorização prévia, um link (inclusive link direto) para as informações divulgadas neste site."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"defaultName": "Um colaborador",
|
||||
"joined": {
|
||||
"description": "{{name}} entrou na sala"
|
||||
},
|
||||
"raised": {
|
||||
"description": "{{name}} levantou a mão.",
|
||||
"cta": "Abrir lista de espera"
|
||||
},
|
||||
"muted": "{{name}} silenciou seu microfone. Nenhum participante pode ouvi-lo.",
|
||||
"openChat": "Abrir chat",
|
||||
"chatMessageReceived": "{{name}} diz: {{message}}",
|
||||
"lowerHand": {
|
||||
"auto": "Parece que você começou a falar, então sua mão será abaixada.",
|
||||
"dismiss": "Manter mão levantada"
|
||||
},
|
||||
"autoMuteLargeRoom": {
|
||||
"auto": "Você entrou em uma reunião grande. Seu microfone foi silenciado automaticamente.",
|
||||
"dismiss": "Ativar microfone"
|
||||
},
|
||||
"reaction": {
|
||||
"description": "{{name}} reagiu com {{emoji}}"
|
||||
},
|
||||
"permissionsRemoved": {
|
||||
"camera": "O anfitrião desativou o vídeo para todos os participantes.",
|
||||
"microphone": "O anfitrião desativou o microfone para todos os participantes.",
|
||||
"screen_share": "O anfitrião desativou o compartilhamento de tela para todos os participantes."
|
||||
},
|
||||
"waitingParticipants": {
|
||||
"one": "Uma pessoa quer entrar nesta chamada.",
|
||||
"several": "Várias pessoas querem entrar nesta chamada.",
|
||||
"open": "Abrir",
|
||||
"accept": "Aceitar"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "A transcrição está aguardando um microfone. Ative seu microfone para que a transcrição possa começar.",
|
||||
"started": "{{name}} iniciou a transcrição da reunião.",
|
||||
"stopped": "{{name}} parou a transcrição da reunião.",
|
||||
"limitReached": "A transcrição excedeu a duração máxima permitida e será salva automaticamente.",
|
||||
"interrupted": "A transcrição foi interrompida inesperadamente; a parte já registrada será salva.",
|
||||
"failed": "A transcrição foi interrompida inesperadamente e não pôde ser salva.",
|
||||
"aborted": "Não foi possível iniciar a transcrição.",
|
||||
"requested": "{{name}} quer iniciar a transcrição da reunião."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "A gravação está aguardando áudio ou vídeo. Ative seu microfone ou câmera, ou compartilhe sua tela, para que a gravação possa começar.",
|
||||
"started": "{{name}} iniciou a gravação da reunião.",
|
||||
"stopped": "{{name}} parou a gravação da reunião.",
|
||||
"limitReached": "A gravação excedeu a duração máxima permitida e será salva automaticamente.",
|
||||
"interrupted": "A gravação foi interrompida inesperadamente; a parte já gravada será salva.",
|
||||
"failed": "A gravação foi interrompida inesperadamente e não pôde ser salva.",
|
||||
"aborted": "Não foi possível iniciar a gravação.",
|
||||
"requested": "{{name}} quer iniciar a gravação da reunião."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
"message": "Sua gravação está sendo salva! Enviaremos a transcrição para <strong>{{email}}</strong> assim que estiver pronta.",
|
||||
"default": "Sua gravação está sendo salva! Enviaremos a transcrição para seu email assim que estiver pronta."
|
||||
},
|
||||
"screenRecording": {
|
||||
"message": "Sua gravação está sendo salva! Enviaremos uma notificação para <strong>{{email}}</strong> assim que estiver pronta.",
|
||||
"default": "Sua gravação está sendo salva! Enviaremos uma notificação para seu email assim que estiver pronta."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Seu papel mudou. Agora você é {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Anfitrião",
|
||||
"administrator": "Co-anfitrião",
|
||||
"member": "Membro"
|
||||
}
|
||||
},
|
||||
"openMenu": "Abrir menu"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Gravação indisponível",
|
||||
"body": "A gravação está indisponível ou pode ter sido excluída. Apenas o anfitrião da reunião pode acessá-la. Sinta-se à vontade para contatá-lo se necessário."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Gravação expirada",
|
||||
"body": "Esta gravação foi excluída em {{date}}."
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Autenticação necessária",
|
||||
"body": "Por favor, faça login para acessar esta gravação."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Download Indisponível",
|
||||
"body": "Esta gravação ainda não está pronta para download. Tente novamente mais tarde."
|
||||
},
|
||||
"success": {
|
||||
"title": "Sua gravação está pronta!",
|
||||
"body": "Gravação da reunião {{room}} de {{created_at}}.",
|
||||
"expiration": "Atenção, esta gravação expirará após {{expiration_days}} dia(s).",
|
||||
"button": "Baixar",
|
||||
"warning": {
|
||||
"title": "Compartilhamento de link desativado",
|
||||
"body": "O compartilhamento da gravação via link ainda não está disponível. Apenas anfitriões podem baixá-la."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
{
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"normal": "Você saiu da reunião",
|
||||
"duplicateIdentity": "Você entrou na reunião de outro dispositivo",
|
||||
"participantRemoved": "Você foi removido da reunião por um anfitrião",
|
||||
"roomDeleted": "Esta reunião foi excluída",
|
||||
"connectionLost": "A conexão com a reunião foi perdida",
|
||||
"connectionFailed": "Não foi possível entrar na reunião"
|
||||
},
|
||||
"home": "Voltar para o início",
|
||||
"back": "Reentrar na reunião"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Carregando…",
|
||||
"select": "Selecione um valor",
|
||||
"permissionsNeeded": "Permissão necessária",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "Nenhuma câmera detectada. Verifique se está conectada corretamente.",
|
||||
"audioinput": "Nenhum microfone detectado. Verifique se está conectado corretamente."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Câmera indisponível: provavelmente está sendo usada por outro aplicativo ou guia do navegador.",
|
||||
"audioinput": "Microfone indisponível: provavelmente está sendo usado por outro aplicativo ou guia do navegador."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Configurações de áudio",
|
||||
"video": "Configurações de vídeo"
|
||||
},
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"videoinput": {
|
||||
"choose": "Selecionar câmera",
|
||||
"permissionsNeeded": "Selecionar câmera - permissão necessária",
|
||||
"disable": "Desativar câmera",
|
||||
"enable": "Ativar câmera",
|
||||
"turnedOff": "Câmera desligada",
|
||||
"turnedOn": "Câmera ligada",
|
||||
"label": "Câmera",
|
||||
"placeholder": "Ative a câmera para ver a pré-visualização"
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Selecionar microfone",
|
||||
"level": "Nível de entrada do microfone",
|
||||
"muteTest": "Ative seu microfone para testá-lo",
|
||||
"permissionsNeeded": "Selecionar microfone - permissão necessária",
|
||||
"disable": "Desativar microfone",
|
||||
"enable": "Ativar microfone",
|
||||
"turnedOff": "Microfone desligado",
|
||||
"turnedOn": "Microfone ligado",
|
||||
"label": "Microfone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Selecionar alto-falante",
|
||||
"permissionsNeeded": "Selecionar alto-falante - permissão necessária",
|
||||
"test": "Testar alto-falantes",
|
||||
"testing": "Reproduzindo som de teste…"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Aplicar planos de fundo e efeitos",
|
||||
"title": "Efeitos",
|
||||
"subTitle": "Configure os efeitos da sua câmera."
|
||||
},
|
||||
"heading": "Entrar na reunião?",
|
||||
"joinLabel": "Entrar",
|
||||
"joinMeeting": "Entrar na reunião",
|
||||
"toggleOff": "Clique para desligar",
|
||||
"toggleOn": "Clique para ligar",
|
||||
"usernameHint": "Visível para outros participantes",
|
||||
"usernameLabel": "Seu nome",
|
||||
"errors": {
|
||||
"joinFailed": "Não foi possível entrar na reunião. Tente novamente.",
|
||||
"usernameEmpty": "Seu nome não pode estar vazio"
|
||||
},
|
||||
"cameraDisabled": "Câmera desativada.",
|
||||
"cameraNotFound": "Nenhuma câmera detectada. Verifique se está conectada corretamente.",
|
||||
"cameraInUse": "Sua câmera está indisponível. Provavelmente está sendo usada por outro aplicativo ou guia do navegador.",
|
||||
"cameraStarting": "Câmera iniciando…",
|
||||
"cameraNotGranted": "Deseja que os outros possam ver você durante a reunião?",
|
||||
"cameraAndMicNotGranted": "Deseja que os outros possam ver e ouvir você durante a reunião?",
|
||||
"permissionsButton": {
|
||||
"cameraAndMicNotGranted": "Permitir acesso à câmera e microfone",
|
||||
"cameraNotGranted": "Permitir acesso à câmera"
|
||||
},
|
||||
"videoPreview": {
|
||||
"enabled": "Pré-visualização de vídeo ativada",
|
||||
"disabled": "Pré-visualização de vídeo desativada"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Solicitando entrada...",
|
||||
"body": "Você poderá entrar nesta chamada quando alguém autorizar"
|
||||
},
|
||||
"denied": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Sua solicitação de entrada foi negada."
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Ninguém respondeu à sua solicitação"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "Você não pode entrar nesta chamada",
|
||||
"body": "Esta reunião foi excluída."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Isso fará você sair da reunião.",
|
||||
"shareDialog": {
|
||||
"copy": "Copiar informações",
|
||||
"copyUrl": "Copiar link",
|
||||
"copied": "Informações copiadas para a área de transferência",
|
||||
"heading": "Sua reunião está pronta",
|
||||
"description": "Compartilhe este link com as pessoas que deseja convidar para a reunião.",
|
||||
"permissions": "Pessoas com este link não precisam de sua permissão para entrar nesta reunião.",
|
||||
"closeDialog": "Fechar diálogo",
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Páginas de participantes",
|
||||
"count": "{{currentPage}} de {{totalPageCount}}",
|
||||
"next": "próxima página",
|
||||
"previous": "página anterior"
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
"title": {
|
||||
"audioinput": "Falha ao ativar microfone",
|
||||
"videoinput": "Falha ao ativar câmera"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Seu microfone já está em uso por outra aba ou aplicativo, o que impede sua ativação nesta chamada. Se desejar, feche a outra aba ou aplicativo para usá-lo aqui.",
|
||||
"videoinput": "Sua câmera já está em uso por outra aba ou aplicativo, o que impede sua ativação nesta chamada. Se desejar, feche a outra aba ou aplicativo para usá-la aqui."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "Nenhum microfone detectado",
|
||||
"videoinput": "Nenhuma câmera detectada"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Nenhum microfone foi detectado no seu dispositivo. Verifique se está conectado corretamente e se não está desativado por um botão físico de mudo, depois tente novamente.",
|
||||
"videoinput": "Nenhuma câmera foi detectada no seu dispositivo. Verifique se está conectada corretamente e se não está bloqueada por uma tampa ou botão físico, depois tente novamente."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{appTitle}} não tem permissão para usar sua câmera",
|
||||
"microphone": "{{appTitle}} não tem permissão para usar seu microfone",
|
||||
"cameraAndMicrophone": "{{appTitle}} não tem permissão para usar seu microfone ou câmera",
|
||||
"default": "{{appTitle}} não tem permissão para usar certas funcionalidades"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": {
|
||||
"others": "Clique no ícone de configurações ICON_PLACEHOLDER na barra de endereços do navegador",
|
||||
"safari": "Clique no menu 'Safari' e abra 'Configurações para {{appDomain}}'."
|
||||
},
|
||||
"details": {
|
||||
"camera": "Permita o acesso à câmera",
|
||||
"microphone": "Permita o acesso ao microfone",
|
||||
"cameraAndMicrophone": "Permita o acesso à câmera e ao microfone",
|
||||
"default": "Ative as permissões necessárias."
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Permitir que o navegador use sua câmera",
|
||||
"microphone": "Permitir que o navegador use seu microfone",
|
||||
"cameraAndMicrophone": "Permitir que o navegador use sua câmera e seu microfone"
|
||||
},
|
||||
"device": {
|
||||
"camera": "câmera",
|
||||
"microphone": "microfone",
|
||||
"cameraAndMicrophone": "câmera e microfone"
|
||||
},
|
||||
"intro": "Seu navegador já tem permissão, mas as configurações do sistema do computador estão bloqueando o acesso à sua {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "Nos Ajustes do Sistema, abra “Privacidade e Segurança”.",
|
||||
"2": "Permita que o navegador acesse sua {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "Nas Configurações do Windows, abra “Privacidade e segurança”.",
|
||||
"2": "Ative o acesso a {{device}} e permita que aplicativos da área de trabalho o utilizem."
|
||||
},
|
||||
"android": {
|
||||
"1": "Nas Configurações do Android, abra “Apps” e selecione seu navegador.",
|
||||
"2": "Em “Permissões”, permita o acesso a {{device}} e recarregue esta página."
|
||||
},
|
||||
"other": {
|
||||
"1": "Nas configurações de privacidade do sistema, permita que o navegador acesse sua {{device}}.",
|
||||
"2": "Depois, tente novamente."
|
||||
}
|
||||
},
|
||||
"openSettings": "Abrir configurações do sistema"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Seu microfone está ativado, mas nenhum som está sendo detectado. Clique para saber mais.",
|
||||
"dialog": {
|
||||
"title": "Nenhum som detectado",
|
||||
"intro": "Seu microfone está ativado, mas não detectamos nenhum som há algum tempo. Verifique o seguinte:",
|
||||
"causes": {
|
||||
"system": "As configurações de privacidade do seu sistema podem estar bloqueando o acesso ao microfone.",
|
||||
"hardware": "Um botão de mudo pode estar ativado, ou seu microfone pode estar desconectado ou com defeito.",
|
||||
"wrongDevice": "O microfone incorreto pode estar selecionado. Tente escolher outro no menu de microfone."
|
||||
},
|
||||
"hint": "Fale novamente após verificar essas configurações. Este aviso desaparecerá assim que o som for detectado.",
|
||||
"close": "Entendi",
|
||||
"discard": "Desativar detecção de microfone silencioso"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Mais informações",
|
||||
"ariaLabel": "Problema de permissões. Mostrar mais informações"
|
||||
},
|
||||
"error": {
|
||||
"createRoom": {
|
||||
"heading": "Autenticação Necessária",
|
||||
"body": "Esta sala ainda não foi criada. Por favor, autentique-se para criá-la ou aguarde um usuário autenticado fazer isso."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Reunião excluída",
|
||||
"body": "Esta reunião foi excluída e não é mais possível entrar nela."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Não foi possível compartilhar sua tela",
|
||||
"ariaLabel": "Não foi possível compartilhar sua tela",
|
||||
"message": "Seu navegador pode não ter permissão para gravar a tela no seu computador.",
|
||||
"settingsInstructions": "Acesse",
|
||||
"settingsLabel": {
|
||||
"macos": "Ajustes do Sistema",
|
||||
"windows": "Configurações de privacidade do Windows"
|
||||
},
|
||||
"helpLinkText": "Para saber mais, veja",
|
||||
"helpLinkLabel": "Problema de apresentação",
|
||||
"closeButton": "Fechar",
|
||||
"newTab": "Nova janela"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Você ainda está aí?",
|
||||
"body": "Você é o único participante. Esta chamada será encerrada em {{duration}}. Deseja continuar?",
|
||||
"settingsPrefix": "Para parar de ver esta mensagem, vá em",
|
||||
"settingsLink": "Configurações > Geral",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Continuar a chamada",
|
||||
"leaveButton": "Sair agora",
|
||||
"countdownAnnouncement": "Chamada encerra em {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"region": "Controles da chamada",
|
||||
"microphone": "Microfone",
|
||||
"camera": "Câmera",
|
||||
"chat": {
|
||||
"open": "Fechar o chat",
|
||||
"closed": "Abrir o chat",
|
||||
"input": {
|
||||
"textArea": {
|
||||
"label": "Digite uma mensagem",
|
||||
"placeholder": "Digite uma mensagem"
|
||||
},
|
||||
"button": {
|
||||
"label": "Enviar mensagem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"open": "Ocultar informações",
|
||||
"closed": "Mostrar informações"
|
||||
},
|
||||
"hand": {
|
||||
"raise": "Levantar a mão",
|
||||
"lower": "Abaixar a mão"
|
||||
},
|
||||
"subtitles": {
|
||||
"closed": "Mostrar legendas",
|
||||
"open": "Ocultar legendas"
|
||||
},
|
||||
"screenShare": {
|
||||
"start": "Compartilhar tela",
|
||||
"stop": "Parar compartilhamento de tela"
|
||||
},
|
||||
"leave": "Sair",
|
||||
"participants": {
|
||||
"open": "Ocultar todos",
|
||||
"closed": "Ver todos",
|
||||
"count_one": "{{count}} participante",
|
||||
"count_other": "{{count}} participantes"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Ocultar mais ferramentas",
|
||||
"closed": "Mostrar mais ferramentas"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Ocultar admin",
|
||||
"closed": "Abrir admin"
|
||||
},
|
||||
"moreOptions": "Mais opções",
|
||||
"reactions": {
|
||||
"button": "Enviar reação",
|
||||
"toolbar": "Enviar reação",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "você",
|
||||
"emojis": {
|
||||
"thumbs-up": "joinha",
|
||||
"thumbs-down": "negativo",
|
||||
"clapping-hands": "palmas",
|
||||
"red-heart": "coração vermelho",
|
||||
"face-with-tears-of-joy": "rindo às lágrimas",
|
||||
"face-with-open-mouth": "rosto surpreso",
|
||||
"party-popper": "festa",
|
||||
"folded-hands": "mãos unidas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pictureInPicture": {
|
||||
"placeholder": {
|
||||
"title": "Sua chamada está em outra janela.",
|
||||
"description": "O modo Picture-in-Picture permite que você continue conectado à chamada enquanto realiza outras tarefas.",
|
||||
"bringBack": "Trazer a chamada de volta aqui"
|
||||
},
|
||||
"stage": "Participantes",
|
||||
"controlBar": "Controles da reunião",
|
||||
"title": "Reunião em picture-in-picture"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "Mais Opções",
|
||||
"items": {
|
||||
"feedback": "Nos dê sua opinião",
|
||||
"settings": "Configurações",
|
||||
"screenRecording": "Gravação de tela",
|
||||
"transcript": "Transcrição",
|
||||
"username": "Atualizar seu nome",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"switchCamera": "Trocar câmera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Picture-in-picture",
|
||||
"exit": "Fechar picture-in-picture"
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Tela cheia",
|
||||
"exit": "Sair do modo tela cheia"
|
||||
},
|
||||
"support": "Contatar suporte",
|
||||
"documentation": "Documentação"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Sua câmera está desativada. Escolha uma opção para ativá-la.",
|
||||
"cameraDisabled": "Sua câmera está desativada.",
|
||||
"notAvailable": "Efeitos de vídeo estarão disponíveis em breve no seu navegador. Estamos trabalhando nisso! Enquanto isso, use o Google Chrome para melhor desempenho ou Firefox :(",
|
||||
"heading": "Desfoque",
|
||||
"clear": "Desativar efeito",
|
||||
"blur": {
|
||||
"title": "Desfoque de fundo",
|
||||
"status": {
|
||||
"none": "Fundo limpo.",
|
||||
"light": "Fundo levemente desfocado.",
|
||||
"strong": "Fundo fortemente desfocado."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Desfocar levemente o fundo",
|
||||
"clear": "Desativar desfoque"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Desfocar o fundo",
|
||||
"clear": "Desativar desfoque"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Planos de fundo virtuais",
|
||||
"selectedLabel": "Plano de fundo aplicado:",
|
||||
"apply": "Substituir seu plano de fundo:",
|
||||
"personal": {
|
||||
"title": "Meus planos de fundo",
|
||||
"selectedLabel": "Desmarcar plano de fundo (está atualmente aplicado).",
|
||||
"apply": "Usar como plano de fundo.",
|
||||
"selectFileTooltip": "Selecione um arquivo de imagem para usar como plano de fundo pessoal",
|
||||
"notLoggedInWarning": "Você não está logado, os planos de fundo pessoais não serão salvos entre reuniões.",
|
||||
"warningUploadDisabled": "Planos de fundo pessoais não são salvos entre reuniões no momento.",
|
||||
"uploadLimitReached": "Você não pode enviar mais planos de fundo pessoais.",
|
||||
"uploadInProgress": "Imagem sendo enviada…",
|
||||
"deleteLabel": "Excluir plano de fundo.",
|
||||
"errors": {
|
||||
"close": "Fechar",
|
||||
"file_too_large": {
|
||||
"title": "Arquivo muito grande",
|
||||
"description": "O arquivo é muito grande. Escolha um arquivo menor que {{maxSize, number}} MB."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Tipo de arquivo inválido",
|
||||
"description": "O tipo de arquivo não é suportado. Escolha um arquivo {{allowedExtension, list(type: 'disjunction')}}."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Sugestões",
|
||||
"descriptions": {
|
||||
"0": "Móvel de madeira canelada",
|
||||
"1": "Sala de reuniões",
|
||||
"2": "Loft com divisória de vidro preto",
|
||||
"3": "Sala de jantar",
|
||||
"4": "Prateleiras de madeira",
|
||||
"5": "Escada de madeira",
|
||||
"6": "Biblioteca cinza",
|
||||
"7": "Balcão de café"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Efeitos Visuais",
|
||||
"glasses": {
|
||||
"apply": "Adicionar Óculos",
|
||||
"clear": "Remover Óculos"
|
||||
},
|
||||
"french": {
|
||||
"apply": "Adicionar toque francês",
|
||||
"clear": "Remover toque francês"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Painel lateral - {{title}}",
|
||||
"backToTools": "Voltar às ferramentas da reunião",
|
||||
"heading": {
|
||||
"participants": "Participantes",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"chat": "Mensagens no chat",
|
||||
"transcript": "Transcrever",
|
||||
"screenRecording": "Gravar",
|
||||
"admin": "Configurações do anfitrião",
|
||||
"tools": "Mais ferramentas",
|
||||
"info": "Informações da reunião"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participantes",
|
||||
"effects": "Planos de fundo e Efeitos",
|
||||
"chat": "mensagens",
|
||||
"transcript": "transcrever",
|
||||
"screenRecording": "gravar",
|
||||
"admin": "configurações do anfitrião",
|
||||
"tools": "mais ferramentas",
|
||||
"info": "informações da reunião"
|
||||
},
|
||||
"closeButton": "Ocultar {{content}}",
|
||||
"escapeHint": "Pressione Escape para fechar"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "As mensagens são visíveis apenas para os participantes no momento em que são enviadas. Todas as mensagens são excluídas ao final da chamada.",
|
||||
"messagesLog": "Mensagens do chat"
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Acesse mais ferramentas para melhorar suas reuniões.",
|
||||
"linkAriaLabel": "Abrir documentação sobre ferramentas - abre em nova janela",
|
||||
"moreLink": "Abrir documentação",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcrever",
|
||||
"body": "Transforme reuniões em texto."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Gravar",
|
||||
"body": "Salve reuniões como vídeo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"roomInformation": {
|
||||
"title": "Informações de Conexão",
|
||||
"button": {
|
||||
"ariaLabel": "Copiar as informações da sua reunião",
|
||||
"copy": "Copiar informações",
|
||||
"copied": "Informações copiadas"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Ligar:",
|
||||
"pinCode": "Código:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"heading": "Transcreva sua reunião com o Assistente de IA",
|
||||
"body": "Transcreva até {{max_duration}} de reunião.",
|
||||
"bodyWithoutMaxDuration": "Transcreva sua reunião sem limites.",
|
||||
"details": {
|
||||
"receiver": "A transcrição será enviada ao anfitrião e co-anfitriões.",
|
||||
"destination": "Um novo documento será criado em",
|
||||
"destinationUnknown": "Um novo documento será criado",
|
||||
"language": "Idioma da reunião:",
|
||||
"recording": "Também iniciar uma gravação"
|
||||
},
|
||||
"button": {
|
||||
"start": "Iniciar transcrição da reunião",
|
||||
"stop": "Parar transcrição da reunião",
|
||||
"saving": "Salvando…",
|
||||
"starting": "Iniciando…",
|
||||
"anotherModeStarted": "Gravação de tela em andamento. Troque de painel e pare-a."
|
||||
},
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre transcrição - abre em nova janela",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Acesso Restrito",
|
||||
"body": "Por motivos de segurança, apenas o criador da reunião ou um admin pode iniciar uma transcrição (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à transcrição - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login Necessário",
|
||||
"body": "Apenas o criador da reunião ou um admin pode iniciar uma transcrição. Faça login para verificar suas permissões."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Faça login para transcrever",
|
||||
"body": "Nesta reunião, qualquer participante conectado pode iniciar uma transcrição (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à transcrição - abre em uma nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login necessário",
|
||||
"body": "Faça login e volte a este painel para iniciar a transcrição."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Recurso avançado",
|
||||
"body": "Este recurso não está disponível para você. Entre em contato com o suporte para mais informações.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso avançado - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Você não está logado!",
|
||||
"body": "Você precisa estar logado para usar este recurso. Faça login e tente novamente."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Transcrição",
|
||||
"body": "O anfitrião será notificado e poderá ativar a transcrição para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"heading": "Grave esta chamada para depois",
|
||||
"body": "Grave até {{max_duration}} de reunião.",
|
||||
"bodyWithoutMaxDuration": "Grave sua reunião sem limite.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre gravação - abre em nova janela",
|
||||
"details": {
|
||||
"receiver": "A gravação será enviada ao anfitrião e co-anfitriões.",
|
||||
"destination": "Esta gravação será armazenada temporariamente em nossos servidores",
|
||||
"loading": "Iniciando gravação",
|
||||
"linkMore": "Saiba mais",
|
||||
"transcription": "Transcrever esta gravação",
|
||||
"quality": "Qualidade de vídeo ajustável.",
|
||||
"qualityEdit": "Alterar"
|
||||
},
|
||||
"button": {
|
||||
"start": "Iniciar gravação da reunião",
|
||||
"stop": "Parar gravação da reunião",
|
||||
"saving": "Salvando…",
|
||||
"starting": "Iniciando…",
|
||||
"anotherModeStarted": "Transcrição em andamento. <br/> Troque de painel e pare-a."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Acesso Restrito",
|
||||
"body": "Por motivos de segurança, apenas o criador da reunião ou um admin pode iniciar uma gravação (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à gravação - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login Necessário",
|
||||
"body": "Apenas o criador da reunião ou um admin pode iniciar a gravação. Faça login para verificar suas permissões."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Faça login para gravar",
|
||||
"body": "Nesta reunião, qualquer participante conectado pode iniciar uma gravação (beta).",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso à gravação - abre em uma nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Login necessário",
|
||||
"body": "Faça login e volte a este painel para iniciar a gravação."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Recurso avançado",
|
||||
"body": "Este recurso não está disponível para você. Entre em contato com o suporte para mais informações.",
|
||||
"linkMore": "Abrir documentação",
|
||||
"linkAriaLabel": "Abrir documentação sobre acesso avançado - abre em nova janela",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Você não está logado!",
|
||||
"body": "Você precisa estar logado para usar este recurso. Faça login e tente novamente."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Solicitar Gravação",
|
||||
"body": "O anfitrião será notificado e poderá ativar a gravação para você.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limitado a {{max_duration}}) "
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Falha na Gravação",
|
||||
"body": {
|
||||
"stop": "Não foi possível parar a gravação. Tente novamente em instantes.",
|
||||
"start": "Não foi possível iniciar a gravação. Tente novamente em instantes."
|
||||
},
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Estas configurações de anfitrião permitem que você mantenha o controle da reunião. Apenas anfitriões podem acessar estes controles.",
|
||||
"access": {
|
||||
"title": "Acesso à sala",
|
||||
"description": "Estas configurações também se aplicarão a futuras ocorrências desta reunião.",
|
||||
"type": "Tipos de acesso à reunião",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "Aberta",
|
||||
"description": "Ninguém precisa solicitar para entrar na reunião."
|
||||
},
|
||||
"trusted": {
|
||||
"label": "Aberta para pessoas confiáveis",
|
||||
"description": "Pessoas autenticadas não precisam solicitar para entrar na reunião."
|
||||
},
|
||||
"restricted": {
|
||||
"label": "Restrita",
|
||||
"description": "Pessoas que não foram convidadas para a reunião devem solicitar para entrar."
|
||||
}
|
||||
}
|
||||
},
|
||||
"moderation": {
|
||||
"title": "Moderação da Reunião",
|
||||
"description": "Estas configurações restringem as ações que os participantes podem realizar durante a reunião.",
|
||||
"microphone": {
|
||||
"label": "Ligar microfone",
|
||||
"description": ""
|
||||
},
|
||||
"camera": {
|
||||
"label": "Ativar vídeo",
|
||||
"description": ""
|
||||
},
|
||||
"screenshare": {
|
||||
"label": "Compartilhar tela",
|
||||
"description": "Desativar esta opção impedirá que participantes compartilhem sua tela, e qualquer compartilhamento em andamento será interrompido imediatamente."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Silenciar outros",
|
||||
"description": "Quando desativado, participantes não poderão mais silenciar outros participantes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
"submit": "Enviar",
|
||||
"question": "O que você achou da qualidade da sua chamada?",
|
||||
"levels": {
|
||||
"min": "muito ruim",
|
||||
"max": "excelente"
|
||||
}
|
||||
},
|
||||
"openFeedback": {
|
||||
"question": "O que podemos fazer para melhorar?",
|
||||
"placeholder": "Descreva seus bugs ou compartilhe suas sugestões…",
|
||||
"submit": "Enviar",
|
||||
"skip": "Pular"
|
||||
},
|
||||
"confirmationMessage": {
|
||||
"heading": "Obrigado pelo seu envio",
|
||||
"body": "Nossa equipe de produto analisa cuidadosamente seu feedback. Retornaremos o mais breve possível."
|
||||
},
|
||||
"authenticationMessage": {
|
||||
"heading": "Como podemos entrar em contato?",
|
||||
"placeholder": "Seu email",
|
||||
"submit": "Enviar",
|
||||
"ignore": "Ignorar"
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "Na sala",
|
||||
"you": "Você",
|
||||
"unknown": "Participante desconhecido",
|
||||
"host": "Anfitrião",
|
||||
"cohost": "Co-anfitrião",
|
||||
"member": "Membro",
|
||||
"contributors": "Colaboradores",
|
||||
"collapsable": {
|
||||
"open": "Abrir lista {{name}}",
|
||||
"close": "Fechar lista {{name}}"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "Usuário não autenticado"
|
||||
},
|
||||
"muteYourself": "Fechar seu microfone",
|
||||
"muteParticipant": "Fechar o microfone de {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "Silenciar {{name}}",
|
||||
"description": "Silenciar {{name}} para todos os participantes? {{name}} será o único que poderá reativar seu microfone.",
|
||||
"confirm": "Silenciar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"raisedHands": "Mãos levantadas",
|
||||
"lowerParticipantHand": "Abaixar a mão de {{name}}",
|
||||
"lowerParticipantsHand": "Abaixar todas as mãos",
|
||||
"muteParticipants": "Silenciar todos os microfones",
|
||||
"waiting": {
|
||||
"title": "Sala de espera",
|
||||
"accept": {
|
||||
"button": "Aceitar",
|
||||
"label": "Aceitar {{name}} na reunião",
|
||||
"all": "Aceitar todos"
|
||||
},
|
||||
"deny": {
|
||||
"button": "Negar",
|
||||
"label": "Negar {{name}} na reunião",
|
||||
"all": "Negar todos"
|
||||
}
|
||||
},
|
||||
"moreOptions": "Mais opções"
|
||||
},
|
||||
"participantMenu": {
|
||||
"remove": {
|
||||
"label": "Remover da chamada",
|
||||
"ariaLabel": "Remover {{name}} da chamada"
|
||||
},
|
||||
"unpin": {
|
||||
"label": "Desafixar",
|
||||
"ariaLabel": "Desafixar {{name}}"
|
||||
},
|
||||
"pin": {
|
||||
"label": "Fixar",
|
||||
"ariaLabel": "Fixar {{name}}"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Tornar co-anfitrião",
|
||||
"ariaLabel": "Tornar {{name}} co-anfitrião"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Remover papel de co-anfitrião",
|
||||
"ariaLabel": "Remover papel de co-anfitrião de {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "O vídeo de {{name}} está fixado.",
|
||||
"unpin": "O vídeo de {{name}} não está mais fixado.",
|
||||
"self": {
|
||||
"pin": "Seu vídeo está fixado.",
|
||||
"unpin": "Seu vídeo não está mais fixado."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcrevendo",
|
||||
"starting": "Transcrição iniciando",
|
||||
"stopping": "Transcrição parando"
|
||||
},
|
||||
"screen_recording": {
|
||||
"started": "Gravação em andamento",
|
||||
"starting": "Iniciando gravação",
|
||||
"stopping": "Parando gravação"
|
||||
},
|
||||
"any": {
|
||||
"started": "Gravação em andamento"
|
||||
},
|
||||
"limitReachedAlert": {
|
||||
"title": "Limite de gravação excedido",
|
||||
"description": "A gravação excedeu a duração máxima permitida{{duration_message}}. Será salva automaticamente.",
|
||||
"durationMessage": " de {{duration}}",
|
||||
"button": "OK"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opções para {{name}}",
|
||||
"toolbarHint": "{{shortcut}}: acessar atalhos diretamente.",
|
||||
"pin": {
|
||||
"enable": "Fixar",
|
||||
"disable": "Desafixar"
|
||||
},
|
||||
"effects": "Aplicar planos de fundo e efeitos",
|
||||
"muteParticipant": "Silenciar {{name}}",
|
||||
"fullScreen": "Tela cheia"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Controles de zoom do compartilhamento de tela",
|
||||
"zoomIn": "Aumentar zoom",
|
||||
"zoomInWithShortcut": "Aumentar zoom ({{shortcut}})",
|
||||
"zoomOut": "Diminuir zoom",
|
||||
"zoomOutWithShortcut": "Diminuir zoom ({{shortcut}})",
|
||||
"wheelShortcut": "Control mais roda do mouse",
|
||||
"wheelShortcutMac": "⌘+roda do mouse",
|
||||
"wheelShortcutHint": "Atalho: Control mais roda do mouse para aumentar ou diminuir o zoom.",
|
||||
"wheelShortcutHintMac": "Atalho: Command mais roda do mouse para aumentar ou diminuir o zoom.",
|
||||
"fitToWindow": "Ajustar à janela",
|
||||
"fullScreen": "Tela cheia",
|
||||
"exitFullScreen": "Sair da tela cheia",
|
||||
"currentZoomLevel": "Zoom {{level}} %",
|
||||
"panHint": "Zoom {{level}} %. Arraste com o mouse ou volte o foco para o compartilhamento de tela e use as setas do teclado para navegar pela imagem.",
|
||||
"fullScreenEntered": "Tela cheia ativada",
|
||||
"fullScreenExited": "Tela cheia desativada"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Atalhos de teclado",
|
||||
"categories": {
|
||||
"navigation": "Navegação",
|
||||
"media": "Mídia",
|
||||
"interaction": "Interação"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Abrir ajuda de atalhos",
|
||||
"focus-toolbar": "Focar barra inferior",
|
||||
"focus-pagination": "Focar na paginação de participantes",
|
||||
"toggle-microphone": "Alternar microfone",
|
||||
"toggle-camera": "Alternar câmera",
|
||||
"push-to-talk": "Pressionar para falar (segure para ativar)",
|
||||
"reaction": "Painel de reações",
|
||||
"fullscreen": "Alternar tela cheia",
|
||||
"recording": "Alternar painel de gravação",
|
||||
"raise-hand": "Levantar ou abaixar a mão",
|
||||
"toggle-chat": "Alternar chat",
|
||||
"toggle-participants": "Alternar participantes",
|
||||
"open-shortcuts-settings": "Abrir configurações de atalhos",
|
||||
"zoom-in": "Aumentar zoom na tela compartilhada",
|
||||
"zoom-out": "Diminuir zoom na tela compartilhada",
|
||||
"zoom-reset": "Redefinir o zoom da tela compartilhada",
|
||||
"zoom-pan": "Mover pela tela compartilhada com zoom"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
"command": "Command",
|
||||
"alt": "Alt",
|
||||
"option": "Option",
|
||||
"shift": "Shift",
|
||||
"plus": "mais",
|
||||
"hold": "Segure {{key}}",
|
||||
"arrows": "Teclas de seta",
|
||||
"plusKey": "Tecla de mais",
|
||||
"minusKey": "Tecla de menos",
|
||||
"noShortcut": "Sem atalho"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Segure {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Para evitar exibição em loop infinito, não compartilhe sua tela inteira. Em vez disso, compartilhe uma aba ou outra janela.",
|
||||
"stop": "Parar apresentação",
|
||||
"ignore": "Ignorar"
|
||||
},
|
||||
"participantTile": {
|
||||
"screenShare": "Tela de {{name}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"createMeeting": {
|
||||
"createButton": "Criar",
|
||||
"joinButton": "Entrar com o Visio",
|
||||
"copyLinkTooltip": "Copiar link",
|
||||
"resetLabel": "Redefinir",
|
||||
"participantLimit": "Até 150 participantes.",
|
||||
"popupBlocked": "O pop-up foi bloqueado. Por favor, permita pop-ups para este site.",
|
||||
"settingsTooltip": "Configurações da reunião"
|
||||
},
|
||||
"roomSettings": {
|
||||
"title": "Configurações da reunião",
|
||||
"description": "Configurar a reunião {{roomSlug}}.",
|
||||
"notAllowed": "Você não tem permissão para modificar as configurações desta reunião.",
|
||||
"error": "Não foi possível carregar as configurações da reunião.",
|
||||
"closeButton": "Fechar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"account": {
|
||||
"currentlyLoggedAs": "Você está conectado como <0>{{user}}</0>",
|
||||
"heading": "Conta",
|
||||
"youAreNotLoggedIn": "Você não está conectado.",
|
||||
"nameLabel": "Seu Nome",
|
||||
"authentication": "Autenticação",
|
||||
"nameError": "Seu nome não pode estar vazio"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferências",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Sair de chamadas sem participantes",
|
||||
"description": "Sai automaticamente de uma chamada após alguns minutos se nenhum outro participante entrar"
|
||||
},
|
||||
"autoMuteLargeRoom": {
|
||||
"label": "Silenciar meu microfone automaticamente",
|
||||
"description": "Silencia automaticamente seu microfone ao entrar em uma reunião grande."
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Microfone",
|
||||
"label": "Selecione sua entrada de áudio",
|
||||
"disabled": "Microfone desativado"
|
||||
},
|
||||
"noiseReduction": {
|
||||
"label": "Otimização avançada de voz",
|
||||
"heading": "Filtros de áudio",
|
||||
"ariaLabel": {
|
||||
"enable": "Ativar redução de ruído",
|
||||
"disable": "Desativar redução de ruído"
|
||||
}
|
||||
},
|
||||
"speakers": {
|
||||
"heading": "Alto-falantes",
|
||||
"label": "Selecione sua saída de áudio",
|
||||
"test": "Testar",
|
||||
"ongoingTest": "Testando som…",
|
||||
"safariWarning": "A seleção de alto-falante ainda não está disponível no Safari devido a limitações do navegador."
|
||||
},
|
||||
"permissionsRequired": "Permissões necessárias"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Câmera",
|
||||
"label": "Selecione sua entrada de vídeo",
|
||||
"disabled": "Câmera desativada",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Pré-visualização de vídeo ativada",
|
||||
"disabled": "Pré-visualização de vídeo desativada"
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"heading": "Resolução",
|
||||
"publish": {
|
||||
"label": "Selecione sua resolução de envio (máx.)",
|
||||
"items": {
|
||||
"veryHigh": "Definição muito alta",
|
||||
"high": "Alta definição",
|
||||
"medium": "Definição padrão",
|
||||
"low": "Baixa definição"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Selecione sua resolução de recepção (máx.)",
|
||||
"items": {
|
||||
"high": "Alta definição (automática)",
|
||||
"medium": "Definição padrão",
|
||||
"low": "Baixa definição"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsRequired": "Permissões necessárias"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transcrição",
|
||||
"language": {
|
||||
"label": "Idioma da reunião",
|
||||
"options": {
|
||||
"french": "Francês (fr)",
|
||||
"english": "Inglês (en)",
|
||||
"dutch": "Holandês (nl)",
|
||||
"german": "Alemão (de)",
|
||||
"auto": "Automático"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notificações sonoras",
|
||||
"label": "notificações sonoras para",
|
||||
"actions": {
|
||||
"disable": "Desativar",
|
||||
"enable": "Ativar"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": {
|
||||
"label": "Participante entrou"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Mão levantada"
|
||||
},
|
||||
"messageReceived": {
|
||||
"label": "Mensagem recebida"
|
||||
},
|
||||
"participantWaiting": {
|
||||
"label": "Participante aguardando"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"listLabel": "Atalhos de teclado",
|
||||
"columnAction": "Ação",
|
||||
"columnShortcut": "Atalho"
|
||||
},
|
||||
"dialog": {
|
||||
"heading": "Configurações"
|
||||
},
|
||||
"language": {
|
||||
"heading": "Idioma",
|
||||
"label": "Idioma"
|
||||
},
|
||||
"settingsButtonLabel": "Configurações",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Anunciar reações em voz alta"
|
||||
},
|
||||
"font": {
|
||||
"label": "Fonte de exibição",
|
||||
"description": "Personalize a fonte usada na interface para melhorar seu conforto de leitura.",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"lexend": "Lexend (legibilidade melhorada)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (baixa visão)",
|
||||
"opendyslexic": "OpenDyslexic (dislexia)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Legendas",
|
||||
"textSize": {
|
||||
"label": "Tamanho do texto das legendas",
|
||||
"options": {
|
||||
"small": "Pequeno",
|
||||
"medium": "Médio",
|
||||
"large": "Grande"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Cor da fonte",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"white": "Branco",
|
||||
"black": "Preto",
|
||||
"blue": "Azul",
|
||||
"green": "Verde",
|
||||
"red": "Vermelho",
|
||||
"yellow": "Amarelo",
|
||||
"cyan": "Ciano",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Cor de fundo",
|
||||
"options": {
|
||||
"default": "Padrão",
|
||||
"white": "Branco",
|
||||
"black": "Preto",
|
||||
"blue": "Azul",
|
||||
"green": "Verde",
|
||||
"red": "Vermelho",
|
||||
"yellow": "Amarelo",
|
||||
"cyan": "Ciano",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Perfil",
|
||||
"audio": "Áudio",
|
||||
"video": "Vídeo",
|
||||
"general": "Geral",
|
||||
"notifications": "Notificações",
|
||||
"accessibility": "Acessibilidade",
|
||||
"transcription": "Transcrição",
|
||||
"shortcuts": "Atalhos",
|
||||
"rooms": "Salas",
|
||||
"recording": "Gravação"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Configurações padrão de sala",
|
||||
"description": "Escolha as configurações aplicadas por padrão às novas salas que você criar. Você ainda pode alterá-las para cada reunião nas configurações de anfitrião."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Qualidade da gravação",
|
||||
"locked": "Essas configurações não podem ser alteradas enquanto uma gravação estiver iniciando ou em andamento.",
|
||||
"profile": {
|
||||
"label": "Tipo de conteúdo",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Pessoas falando",
|
||||
"description": "Otimizado para pessoas falando na câmera com slides ocasionais. Menores arquivos."
|
||||
},
|
||||
"text": {
|
||||
"label": "Apresentações e texto",
|
||||
"description": "Mantém o texto nítido ao compartilhar slides, documentos ou código."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Conteúdo misto",
|
||||
"description": "Equilibrado para uma combinação de câmeras, compartilhamento de tela e movimento moderado."
|
||||
},
|
||||
"full": {
|
||||
"label": "Qualidade máxima",
|
||||
"description": "Maior fidelidade e movimento mais suave. Maiores arquivos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolução",
|
||||
"description": "Resoluções mais altas geram arquivos maiores.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Padrão"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"title": "Termos de Uso do Visio",
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Escopo",
|
||||
"content": "Este documento define os termos de uso do Visio (doravante “Visio” ou “o aplicativo”), um aplicativo de videoconferência projetado especificamente para administrações estatais e órgãos sob sua supervisão."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Objetivo do aplicativo",
|
||||
"content": "O Visio ajuda a promover a cooperação entre equipes de administrações estatais e órgãos sob sua supervisão, apoia o trabalho remoto e facilita a organização de reuniões, conferências ou sessões de treinamento.",
|
||||
"paragraphs": [
|
||||
"O Visio é desenvolvido e operado pela Direção Interministerial do Digital (DINUM).",
|
||||
"O aplicativo é disponibilizado pela DINUM às administrações estatais e órgãos sob sua supervisão, para uso por seus agentes, parceiros e prestadores de serviços convidados.",
|
||||
"Qualquer uso do Visio deve estar em conformidade com estes termos de uso e, quando aplicável, com a política de uso da respectiva administração supervisora."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Definições",
|
||||
"paragraphs": [
|
||||
"O Visio é o produto cujo uso é descrito nestes termos e apresentado na seguinte página: https://visio.numerique.gouv.fr.",
|
||||
"Usuários do Visio são quaisquer pessoas que utilizem o Visio para fins de comunicação por videoconferência, sejam elas agentes, parceiros ou prestadores de serviços das administrações pertinentes. A administração supervisora é a administração estatal ou órgão sob sua supervisão que disponibiliza o Visio aos seus agentes, parceiros ou prestadores de serviços.",
|
||||
"Organizadores de sala são os usuários responsáveis por uma sala de videoconferência após se autenticarem via ProConnect.",
|
||||
"A administração supervisora é a administração integrante do sistema de informação e comunicação do Estado que disponibiliza o Visio aos seus agentes, parceiros ou prestadores de serviços."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Acesso ao serviço",
|
||||
"definition": "O Visio é disponibilizado aos usuários por sua administração supervisora. Os organizadores podem então compartilhar um link concedendo acesso à sala com qualquer pessoa, sob sua responsabilidade."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Funcionalidades",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Funcionalidades disponíveis para organizadores",
|
||||
"content": "O Visio permite que agentes de administrações estatais e órgãos sob sua supervisão criem uma sala de videoconferência a partir de uma estação de trabalho padrão, vinculada à sua conta ProConnect.",
|
||||
"paragraph1": "Eles podem então compartilhar o link da reunião com outras pessoas, usuários, pertencentes ou não à administração.",
|
||||
"paragraph2": "Para esta finalidade, cada organizador de sala pode:",
|
||||
"capabilities": [
|
||||
"Proteger o acesso à sala com uma sala de espera;",
|
||||
"Autorizar a entrada de cada usuário na sala;",
|
||||
"Silenciar microfones e câmeras dos usuários;",
|
||||
"Remover um usuário da sala;",
|
||||
"Utilizar a ferramenta de transcrição de reunião após obter o consentimento dos usuários, sob sua responsabilidade."
|
||||
],
|
||||
"paragraph3": "Os agentes devem entrar em contato com sua administração supervisora para garantir o enquadramento adequado para o uso do Visio, especialmente em reuniões sujeitas a requisitos elevados de confidencialidade.",
|
||||
"paragraph4": "A ferramenta de transcrição automática pode ser utilizada pelos organizadores a partir do menu de configurações da sala. Os organizadores são responsáveis por garantir o consentimento dos usuários que participam da reunião transcrita. O idioma da transcrição pode ser alterado no mesmo menu. Ao final da reunião, um link é enviado ao organizador para acessar a transcrição armazenada na ferramenta chamada Docs, operada pela DINUM."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Funcionalidades disponíveis para usuários",
|
||||
"content": "O Visio permite que qualquer usuário, seja agente público ou não, participe de qualquer sala do Visio para a qual tenha sido convidado por um organizador e usufrua de suas funcionalidades.",
|
||||
"paragraph": "Para esta finalidade, cada usuário pode:",
|
||||
"capabilities": [
|
||||
"Fornecer um nome de exibição ao entrar na sala;",
|
||||
"Comunicar-se remotamente por áudio e/ou vídeo;",
|
||||
"Compartilhar sua tela;",
|
||||
"Trocar mensagens publicamente durante a sessão com todos os usuários na sala;",
|
||||
"Acessar informações técnicas sobre a sala e recursos de acessibilidade;",
|
||||
"Ativar ou desativar sua câmera e/ou microfone a qualquer momento;",
|
||||
"Proteger a privacidade do seu ambiente quando a câmera estiver ligada, selecionando e ativando um plano de fundo antes de entrar na sala ou durante a videoconferência."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "6. Compromissos e responsabilidades dos usuários",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "6.1 Usos permitidos",
|
||||
"paragraphs": [
|
||||
"O Visio é fornecido para facilitar trocas profissionais entre agentes de administrações estatais e órgãos sob sua supervisão e colaboradores externos.",
|
||||
"Os usuários são responsáveis pelas imagens e sons que disponibilizam na sala de videoconferência. Essa responsabilidade aplica-se tanto ao que sua câmera ou microfone possa transmitir quanto ao que seu dispositivo possa tornar acessível a terceiros. Para tanto, os usuários devem utilizar o aplicativo em ambiente profissional e não devem divulgar a terceiros informações protegidas por sigilo profissional (por exemplo: uso de filtros de privacidade, fones de ouvido, etc.).",
|
||||
"Os usuários são responsáveis pelos dados ou conteúdos que inserem no serviço de mensagens da sala, devendo garantir que apenas mensagens apropriadas sejam enviadas."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2 Usos proibidos",
|
||||
"paragraphs": [
|
||||
"Os usuários comprometem-se a não inserir no serviço de mensagens da sala do Visio, nem tornar acessível por imagem ou som, qualquer conteúdo ou informação que viole as leis e regulamentos aplicáveis."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Compromissos e responsabilidades da DINUM",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Segurança do aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM compromete-se a proteger o Visio, em especial adotando todas as medidas necessárias para garantir a segurança e a confidencialidade das comunicações.",
|
||||
"O aplicativo é homologado nas condições previstas pelo Decreto nº 2010-112 alterado."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Acesso ao aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM compromete-se a fornecer os meios necessários e razoáveis para garantir o acesso contínuo ao aplicativo.",
|
||||
"O aplicativo está disponível 24 horas por dia, 7 dias por semana, exceto durante períodos de manutenção.",
|
||||
"A DINUM busca atingir uma taxa anual de disponibilidade do aplicativo de 99,5%, excluindo manutenções programadas. Em caso de incidente ou manutenção, busca um tempo de restabelecimento de 72 horas fora do horário comercial.",
|
||||
"No âmbito da manutenção do ambiente de execução do aplicativo, a DINUM reserva-se o direito de suspender temporariamente o funcionamento do aplicativo. Sempre que possível, os usuários serão informados com pelo menos 48 horas de antecedência. Em situações de emergência, essa suspensão poderá ocorrer sem aviso prévio.",
|
||||
"Essas interrupções excepcionais podem ser necessárias, por exemplo, para operações de gerenciamento de dados, atualizações em produção ou mudanças arquiteturais.",
|
||||
"A indisponibilidade do aplicativo não confere aos usuários direito a qualquer forma de compensação."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Gestão de suporte",
|
||||
"paragraphs": [
|
||||
"A DINUM fornece suporte de primeiro nível aos usuários, exclusivamente sobre aspectos técnicos do aplicativo.",
|
||||
"Esse suporte pode ser acionado por meio do formulário de contato online.",
|
||||
"O prazo-meta de resposta às solicitações é de dois dias úteis a partir do envio do e-mail; esse prazo pode variar conforme a complexidade e o volume de solicitações a serem processadas."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Monitoramento do uso do aplicativo",
|
||||
"paragraphs": [
|
||||
"A DINUM reserva-se o direito de suspender ou excluir a conta de um usuário que tenha descumprido estes termos de uso, sem prejuízo de eventuais ações de responsabilidade civil ou penal cabíveis contra o interessado. As informações poderão ser transmitidas à administração supervisora do usuário, responsável pela disponibilização do aplicativo.",
|
||||
"A suspensão ou revogação de uma ou mais contas não gera direito a indenização de qualquer natureza."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Código Aberto e Licenças",
|
||||
"content": "O código-fonte do serviço é aberto e está disponível aqui: https://github.com/suitenumerique/meet. O conteúdo fornecido pela DINUM é licenciado sob a Licença Aberta 2.0 (Licence Ouverte 2.0), com exceção de logotipos e representações iconográficas ou fotográficas, que podem ser regidos por licenças próprias."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Alterações nos termos de uso",
|
||||
"content": "Os termos destas condições de uso podem ser modificados ou complementados a qualquer momento, sem aviso prévio, em função de alterações realizadas no serviço, evolução da legislação ou por qualquer outro motivo considerado necessário.\nEssas alterações e atualizações vinculam o usuário, que deve, portanto, consultar regularmente esta seção para verificar os termos vigentes."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
// Map frontend language codes to backend language codes
|
||||
|
||||
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de' | 'es-es'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de' | 'es'
|
||||
export type BackendLanguage =
|
||||
| 'en-us'
|
||||
| 'fr-fr'
|
||||
| 'nl-nl'
|
||||
| 'de-de'
|
||||
| 'es-es'
|
||||
| 'pt-br'
|
||||
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de' | 'es' | 'pt'
|
||||
|
||||
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
en: 'en-us',
|
||||
@@ -9,6 +15,7 @@ const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
|
||||
nl: 'nl-nl',
|
||||
de: 'de-de',
|
||||
es: 'es-es',
|
||||
pt: 'pt-br',
|
||||
}
|
||||
|
||||
export const convertToBackendLanguage = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM python:3.13.16-alpine3.24 AS base
|
||||
|
||||
RUN apk add --no-cache --upgrade 'ca-certificates>=20261020-r0'
|
||||
RUN apk add --no-cache --upgrade 'ca-certificates>=20261020-r0' \
|
||||
'musl-utils>=1.2.6-r3'
|
||||
|
||||
|
||||
# Install ffmpeg for audio/video processing (format conversion, extraction, compression)
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.6.3",
|
||||
"kombu==5.6.2",
|
||||
"redis==5.2.1",
|
||||
"boto3==1.43.118",
|
||||
"dockerflow==2026.3.4",
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.analytics import get_analytics
|
||||
from summary.core.celery_worker import (
|
||||
@@ -13,7 +14,9 @@ from summary.core.celery_worker import (
|
||||
summarize_v2_task,
|
||||
)
|
||||
from summary.core.config import AuthorizedTenant, get_settings
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.security import verify_tenant_api_key_v2
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookFailurePayload,
|
||||
@@ -52,7 +55,7 @@ async def create_transcribe_task_v2(
|
||||
task = process_audio_transcribe_v2_task.apply_async(
|
||||
args=[
|
||||
{
|
||||
**request.model_dump(),
|
||||
**dump_for_transport(request),
|
||||
"tenant_id": request_tenant.id,
|
||||
"received_at": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -125,7 +128,11 @@ async def get_transcribe_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return TranscribeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = TranscribeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return TranscribeWebhookFailurePayload(
|
||||
@@ -155,7 +162,11 @@ async def get_summarize_job_status(
|
||||
|
||||
if task.status == "SUCCESS":
|
||||
result = task.result
|
||||
return SummarizeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||
try:
|
||||
payload = SummarizeWebhookSuccessPayload.model_validate(result)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
return dump_for_transport(payload)
|
||||
|
||||
if task.status == "FAILURE":
|
||||
return SummarizeWebhookFailurePayload(
|
||||
|
||||
@@ -5,7 +5,6 @@ import socket
|
||||
import time
|
||||
from collections import Counter
|
||||
from functools import cached_property, lru_cache
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import redis
|
||||
from celery.utils.log import get_task_logger
|
||||
@@ -13,6 +12,7 @@ from posthog import Posthog
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -54,8 +54,8 @@ class Analytics:
|
||||
self._client.capture(
|
||||
event_name, distinct_id=distinct_id, properties=properties
|
||||
)
|
||||
except Exception as e:
|
||||
raise AnalyticsException("Failed to capture analytics event") from e
|
||||
except Exception:
|
||||
raise AnalyticsException("Failed to capture analytics event") from None
|
||||
|
||||
def is_feature_enabled(self, feature_name: str, distinct_id: str = None) -> bool:
|
||||
"""Check if a feature flag is enabled for a user."""
|
||||
@@ -64,8 +64,8 @@ class Analytics:
|
||||
|
||||
try:
|
||||
return self._client.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e:
|
||||
logger.error("Error checking feature flag %s: %s", feature_name, e)
|
||||
except Exception:
|
||||
logger.error("Error checking feature flag %s", feature_name)
|
||||
return False
|
||||
|
||||
|
||||
@@ -133,8 +133,7 @@ class MetadataManager:
|
||||
}
|
||||
|
||||
if isinstance(task_payload, TranscribeTaskJob):
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
clean_url = redact_url(task_payload.cloud_storage_url.get_secret_value())
|
||||
initial_metadata["source_url"] = clean_url
|
||||
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||
elif isinstance(task_payload, SummarizeTaskJob):
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from celery import Celery, signals
|
||||
from celery import Celery, Task, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from openai.types.audio import Transcription
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from requests import exceptions
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.docs_service import create_document_in_lasuite_docs
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
from summary.core.file_service import (
|
||||
CorruptedAudioFile,
|
||||
FileService,
|
||||
@@ -31,6 +34,7 @@ from summary.core.models import (
|
||||
SummarizeTaskJob,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport, redact_url
|
||||
from summary.core.prompt import (
|
||||
FORMAT_NEXT_STEPS,
|
||||
FORMAT_PLAN,
|
||||
@@ -63,8 +67,24 @@ metadata_manager = MetadataManager()
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _reveal(secret: SecretStr | None) -> str | None:
|
||||
"""Return the underlying value of an optional secret."""
|
||||
return secret.get_secret_value() if secret is not None else None
|
||||
|
||||
|
||||
class PrivateTask(Task):
|
||||
"""Hide task arguments in Celery logs and monitoring events."""
|
||||
|
||||
def apply_async(self, args=None, kwargs=None, **options):
|
||||
"""Publish with redacted display representations."""
|
||||
options.update(argsrepr="[Filtered]", kwargsrepr="[Filtered]")
|
||||
return super().apply_async(args=args, kwargs=kwargs, **options)
|
||||
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
task_cls=PrivateTask,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
@@ -76,10 +96,28 @@ celery = Celery(
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
|
||||
class CeleryLogPrivacyFilter(logging.Filter):
|
||||
"""Prevent Celery trace logs from serializing task results or exceptions."""
|
||||
|
||||
def filter(self, record):
|
||||
"""Keep task identity and state without upstream values."""
|
||||
if record.levelno < logging.INFO:
|
||||
return True
|
||||
data = getattr(record, "data", None)
|
||||
if isinstance(data, dict):
|
||||
record.msg = "Task %s[%s] finished or retried (details omitted)"
|
||||
record.args = (data.get("name", "unknown"), data.get("id", "unknown"))
|
||||
record.data = {}
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
return True
|
||||
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_celery_sentry(**_kwargs):
|
||||
"""Initialize Sentry in the Celery worker."""
|
||||
init_sentry()
|
||||
logging.getLogger("celery.app.trace").addFilter(CeleryLogPrivacyFilter())
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
@@ -162,9 +200,9 @@ def transcribe_audio(
|
||||
try:
|
||||
res.raise_for_status()
|
||||
except requests.exceptions.HTTPError:
|
||||
logger.exception("WhisperX transcription failed")
|
||||
logger.error("WhisperX transcription failed")
|
||||
# We reraise the error so that it can be retried by celery
|
||||
raise
|
||||
raise requests.exceptions.HTTPError("WhisperX request failed") from None
|
||||
|
||||
transcription_json: dict[str, Any] = res.json()
|
||||
# We remove the "usage" key from the transcription_json dictionary
|
||||
@@ -175,13 +213,16 @@ def transcribe_audio(
|
||||
# We force the use of the Transcription model here
|
||||
# to avoid changing too much code for now.
|
||||
# Note that it should be WhisperXResponse instead.
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
try:
|
||||
transcription = Transcription.model_validate(
|
||||
# We add a dummy "text" to make the model validate,
|
||||
# Some API responses lack the "text" key.
|
||||
{"text": "", **transcription_json},
|
||||
extra="allow",
|
||||
strict=False,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
# Logging
|
||||
transcription_duration = round(time.time() - transcription_start_time, 2)
|
||||
@@ -199,9 +240,9 @@ def transcribe_audio(
|
||||
if raises:
|
||||
raise e
|
||||
redacted_cloud_storage_url = (
|
||||
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||
redact_url(cloud_storage_url) if cloud_storage_url else None
|
||||
)
|
||||
logger.exception(
|
||||
logger.error(
|
||||
("Unexpected error while preparing file %s "),
|
||||
redacted_cloud_storage_url,
|
||||
)
|
||||
@@ -230,7 +271,7 @@ def resolve_speaker_identities_and_apply_to(
|
||||
logger.debug("Running resolve_speaker_identities")
|
||||
try:
|
||||
metadata = file_service.read_cloud_storage_json(
|
||||
recording_metadata.cloud_storage_url
|
||||
recording_metadata.cloud_storage_url.get_secret_value()
|
||||
)
|
||||
speaker_mapping = resolve_speaker_identities(
|
||||
metadata,
|
||||
@@ -241,21 +282,18 @@ def resolve_speaker_identities_and_apply_to(
|
||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||
return WhisperXResponse.model_validate(new_transcription)
|
||||
|
||||
except FileServiceException as exc:
|
||||
except FileServiceException:
|
||||
logger.error(
|
||||
"Error reading metadata for task %s; skipping speaker assignment."
|
||||
" Error: %s",
|
||||
"Error reading metadata for task %s; skipping speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
except Exception:
|
||||
logger.error(
|
||||
"resolve_speaker_identities failed for task %s; skipping"
|
||||
" speaker assignment. Error: %s",
|
||||
" speaker assignment.",
|
||||
task_id,
|
||||
exc,
|
||||
)
|
||||
return transcription
|
||||
|
||||
@@ -302,6 +340,17 @@ def format_actions(llm_output: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_llm_json(value: str) -> dict:
|
||||
"""Parse an LLM response without exposing it in decoding errors."""
|
||||
try:
|
||||
result = json.loads(value)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
raise LLMException("Invalid LLM response") from None
|
||||
|
||||
|
||||
def summarize_transcription_internals(
|
||||
*, distinct_id: str, transcript: str, session_id: str
|
||||
) -> str:
|
||||
@@ -342,13 +391,13 @@ def summarize_transcription_internals(
|
||||
)
|
||||
logger.info("Plan generated")
|
||||
|
||||
res = json.loads(parts)
|
||||
res = parse_llm_json(parts)
|
||||
parts = res.get("titles", [])
|
||||
logger.info("Parts to summarize: %s", parts)
|
||||
logger.info("Parts to summarize: %s", len(parts))
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
for i, part in enumerate(parts):
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
logger.info("Summarizing part: %s", part)
|
||||
logger.info("Summarizing part %s", i + 1)
|
||||
parts_summarized.append(
|
||||
llm_service.call(PROMPT_SYSTEM_PART, prompt_user_part, name="part")
|
||||
)
|
||||
@@ -364,7 +413,7 @@ def summarize_transcription_internals(
|
||||
response_format=FORMAT_NEXT_STEPS,
|
||||
)
|
||||
|
||||
next_steps = format_actions(json.loads(next_steps))
|
||||
next_steps = format_actions(parse_llm_json(next_steps))
|
||||
|
||||
logger.info("Next steps generated")
|
||||
|
||||
@@ -437,9 +486,11 @@ def call_webhook_v2_task(
|
||||
tenant_id: str,
|
||||
):
|
||||
"""Calls a webhook asynchrously (retry handled by celery)."""
|
||||
call_webhook_v2(
|
||||
payload=webhook_payload_adapter.validate_python(payload), tenant_id=tenant_id
|
||||
)
|
||||
try:
|
||||
validated_payload = webhook_payload_adapter.validate_python(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
call_webhook_v2(payload=validated_payload, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@celery.task(
|
||||
@@ -465,7 +516,10 @@ def process_audio_transcribe_v2_task(
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||
"""
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
logger.info(
|
||||
"Transcribing for object received | Owner: %s",
|
||||
payload.user_sub,
|
||||
@@ -477,7 +531,7 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res = WhisperXResponse(
|
||||
**transcribe_audio( # type: ignore
|
||||
task_id=job_id,
|
||||
cloud_storage_url=payload.cloud_storage_url,
|
||||
cloud_storage_url=payload.cloud_storage_url.get_secret_value(),
|
||||
language=payload.language,
|
||||
raises=True,
|
||||
).model_dump()
|
||||
@@ -514,8 +568,8 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_res.model_dump(),
|
||||
payload.context_language,
|
||||
payload.language,
|
||||
payload.push_to_docs_config.download_link,
|
||||
payload.push_to_docs_config.form_link,
|
||||
_reveal(payload.push_to_docs_config.download_link),
|
||||
_reveal(payload.push_to_docs_config.form_link),
|
||||
)
|
||||
|
||||
create_document_in_lasuite_docs(
|
||||
@@ -556,18 +610,22 @@ def process_audio_transcribe_v2_task(
|
||||
transcription_data_url=file_service.get_transcript_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = TranscribeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||
@@ -627,7 +685,10 @@ def summarize_v2_task(
|
||||
1. Run summary internals
|
||||
2. Sends the final summary via webhook.
|
||||
"""
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
summary = summarize_transcription_internals(
|
||||
distinct_id=payload.user_sub,
|
||||
transcript=payload.content,
|
||||
@@ -652,18 +713,22 @@ def summarize_v2_task(
|
||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||
)
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
args=[dump_for_transport(success_payload), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
return dump_for_transport(success_payload)
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||
try:
|
||||
payload = SummarizeTaskJob.model_validate(args[0])
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
metadata_manager.create(task_id, payload)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from requests import Session
|
||||
from requests import RequestException, Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
@@ -43,6 +43,8 @@ def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||
response = session.post(url, json=data, timeout=(20, 3 * 60))
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except RequestException:
|
||||
raise RequestException("Docs delivery failed") from None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Validation errors suitable for logging and transport."""
|
||||
|
||||
from typing import get_args
|
||||
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import ErrorType, PydanticCustomError
|
||||
|
||||
_BUILTIN_ERROR_TYPES = frozenset(get_args(ErrorType))
|
||||
|
||||
|
||||
def clean_validation_errors(errors: list[dict]) -> list[dict]:
|
||||
"""Mask SecretStr values, including those nested in validation input."""
|
||||
|
||||
def clean(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return "[redacted]"
|
||||
if isinstance(value, Exception):
|
||||
# Do not retain validator exception objects, whose args may hold secrets.
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: clean(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [clean(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(clean(item) for item in value)
|
||||
return value
|
||||
|
||||
return clean(errors)
|
||||
|
||||
|
||||
class CleanedValidationError(ValidationError):
|
||||
"""A new Pydantic exception with secret inputs replaced by a marker."""
|
||||
|
||||
def __init__(self, error, *args, secret_input=False, **kwargs):
|
||||
"""State is initialized by Pydantic during __new__."""
|
||||
|
||||
def __new__(cls, error, *args, secret_input=False, **kwargs):
|
||||
"""Accept an original error, or Pydantic's native construction arguments."""
|
||||
if not isinstance(error, ValidationError):
|
||||
# Pydantic uses this path when constructing or unpickling the error.
|
||||
return super().__new__(cls, error, *args, **kwargs)
|
||||
|
||||
details = clean_validation_errors(error.errors(include_url=False))
|
||||
for detail in details:
|
||||
if secret_input:
|
||||
# An adapter may have received the unwrapped value of a SecretStr.
|
||||
detail["input"] = "[redacted]"
|
||||
return cls._from_details(error.title, details)
|
||||
|
||||
@classmethod
|
||||
def _from_details(cls, title, details):
|
||||
"""Reconstruct builtin and custom Pydantic error codes."""
|
||||
for detail in details:
|
||||
if detail["type"] not in _BUILTIN_ERROR_TYPES:
|
||||
detail["type"] = PydanticCustomError(
|
||||
detail["type"], detail["msg"], detail.get("ctx")
|
||||
)
|
||||
return cls.from_exception_data(title, details, hide_input=True)
|
||||
|
||||
def __reduce__(self):
|
||||
"""Preserve custom error codes when Celery pickles the exception."""
|
||||
return self._from_details, (self.title, self.errors(include_url=False))
|
||||
@@ -17,6 +17,7 @@ import requests
|
||||
from botocore.config import Config
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
settings = get_settings()
|
||||
@@ -314,7 +315,7 @@ class FileService:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
"Download recording from URL | cloud_storage_url: %s",
|
||||
cloud_storage_url,
|
||||
redact_url(cloud_storage_url),
|
||||
)
|
||||
|
||||
if not cloud_storage_url:
|
||||
@@ -352,10 +353,10 @@ class FileService:
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
return local_path
|
||||
|
||||
except requests.RequestException as e:
|
||||
except requests.RequestException:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while downloading object from cloud_storage_url."
|
||||
) from e
|
||||
) from None
|
||||
|
||||
def _validate_duration(self, duration_seconds: float) -> None:
|
||||
"""Validate audio file duration against configured maximum."""
|
||||
@@ -376,7 +377,7 @@ class FileService:
|
||||
|
||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||
"""Read and parse a JSON file from a url."""
|
||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||
logger.info("Reading JSON: %s", redact_url(cloud_storage_url))
|
||||
res = requests.get(cloud_storage_url, timeout=(10, 100))
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
@@ -140,6 +140,6 @@ class LLMService:
|
||||
response = self._client.chat.completions.create(**params)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
except Exception:
|
||||
logger.error("LLM call failed")
|
||||
raise LLMException("LLM call failed") from None
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
EmailStr,
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.types import Url
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -13,6 +20,8 @@ settings = get_settings()
|
||||
class SharedV2TaskCreation(BaseModel):
|
||||
"""Model that holds basic information for task creation."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||
user_email: EmailStr | None = Field(
|
||||
default=None,
|
||||
@@ -24,7 +33,9 @@ class SharedV2TaskCreation(BaseModel):
|
||||
class RecordingMetadata(BaseModel):
|
||||
"""Model for recording metadata."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignment.",
|
||||
)
|
||||
@@ -35,6 +46,8 @@ class RecordingMetadata(BaseModel):
|
||||
class PushToDocsBaseConfig(BaseModel):
|
||||
"""Model containing information for pushing transcript and summaries to docs."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
user_email: EmailStr = Field(
|
||||
title="User Email", description="The user's email, future owner of the docs."
|
||||
)
|
||||
@@ -44,12 +57,12 @@ class PushToDocsBaseConfig(BaseModel):
|
||||
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
|
||||
"""Model for push to docs information for transcripts."""
|
||||
|
||||
download_link: Url | None = Field(
|
||||
download_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Download Link",
|
||||
description="The link to download the recording.",
|
||||
)
|
||||
form_link: Url | None = Field(
|
||||
form_link: SecretUrl | None = Field(
|
||||
default=None,
|
||||
title="Form Link",
|
||||
description="The link to fill out a form for the recording.",
|
||||
@@ -69,7 +82,7 @@ class PushToDocsSummaryConfig(PushToDocsBaseConfig):
|
||||
class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
||||
"""Model for creating a transcribe task (used for API request)."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
cloud_storage_url: SecretUrl = Field(
|
||||
title="Cloud storage URL",
|
||||
description="The URL of the audio file to transcribe.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Privacy helpers for observability."""
|
||||
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
|
||||
def dump_for_transport(model: BaseModel) -> dict:
|
||||
"""Reveal secrets explicitly for task messages, HTTP bodies and API responses."""
|
||||
|
||||
def reveal(value):
|
||||
if isinstance(value, SecretStr):
|
||||
return value.get_secret_value()
|
||||
if isinstance(value, dict):
|
||||
return {key: reveal(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [reveal(item) for item in value]
|
||||
return value
|
||||
|
||||
return reveal(model.model_dump())
|
||||
|
||||
|
||||
def redact_url(url):
|
||||
"""Keep the source identifiable without logging query or user credentials."""
|
||||
if not isinstance(url, str):
|
||||
return "[redacted]"
|
||||
# Remove query and fragment before parsing, including for malformed URLs.
|
||||
base, has_query, _ = url.partition("?")
|
||||
base = base.partition("#")[0]
|
||||
try:
|
||||
parts = urlsplit(base)
|
||||
except ValueError:
|
||||
return "[redacted]"
|
||||
authority = parts.netloc.rsplit("@", 1)[-1]
|
||||
redacted = urlunsplit(parts._replace(netloc=authority))
|
||||
return redacted + ("?[redacted]" if has_query else "")
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Sentry configuration."""
|
||||
|
||||
import re
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import redact_url
|
||||
|
||||
# Exact names (case-insensitive) of variables and dict keys to redact.
|
||||
SENSITIVE_DATA_DENYLIST = [
|
||||
@@ -90,5 +93,45 @@ def init_sentry() -> None:
|
||||
# Task creation requests carry the content to summarize.
|
||||
max_request_body_size="never",
|
||||
include_local_variables=True,
|
||||
before_send=before_send,
|
||||
before_send_transaction=before_send,
|
||||
before_breadcrumb=before_breadcrumb,
|
||||
event_scrubber=build_event_scrubber(),
|
||||
)
|
||||
|
||||
|
||||
def _scrub_strings(value):
|
||||
"""Remove URL credentials, queries and fragments throughout an event."""
|
||||
if isinstance(value, str):
|
||||
return re.sub(
|
||||
r"https?://[^\s\"'<>]+",
|
||||
lambda m: redact_url(m.group()),
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {key: _scrub_strings(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_scrub_strings(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def before_send(event, hint):
|
||||
"""Keep exception types and stacks, but discard untrusted message text."""
|
||||
del hint
|
||||
event.pop("message", None)
|
||||
event.pop("logentry", None)
|
||||
request = event.get("request", {})
|
||||
request.pop("data", None)
|
||||
request.pop("query_string", None)
|
||||
for exception in event.get("exception", {}).get("values", []):
|
||||
exception["value"] = "[Filtered]"
|
||||
return _scrub_strings(event)
|
||||
|
||||
|
||||
def before_breadcrumb(crumb, hint):
|
||||
"""Drop arbitrary breadcrumb text and scrub URLs in structured data."""
|
||||
del hint
|
||||
crumb.pop("message", None)
|
||||
crumb.pop("data", None)
|
||||
return _scrub_strings(crumb)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
||||
|
||||
from summary.core.types import SecretUrl
|
||||
|
||||
|
||||
class WordSegment(BaseModel):
|
||||
@@ -65,6 +67,8 @@ class WhisperXResponse(BaseModel):
|
||||
class BaseWebhook(BaseModel):
|
||||
"""Base webhook payload."""
|
||||
|
||||
model_config = ConfigDict(hide_input_in_errors=True)
|
||||
|
||||
job_id: str = Field(
|
||||
title="Job ID",
|
||||
description="The ID of the job document in the receiver system.",
|
||||
@@ -76,7 +80,7 @@ class TranscribeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
transcription_data_url: str = Field(
|
||||
transcription_data_url: SecretUrl = Field(
|
||||
title="Transcript", description="URL to the raw transcription data."
|
||||
)
|
||||
|
||||
@@ -121,7 +125,7 @@ class SummarizeWebhookSuccessPayload(BaseWebhook):
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
summary_data_url: str = Field(
|
||||
summary_data_url: SecretUrl = Field(
|
||||
title="Summary", description="URL to the raw summary data."
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BeforeValidator,
|
||||
HttpUrl,
|
||||
SecretStr,
|
||||
TypeAdapter,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
http_url_adapter = TypeAdapter(HttpUrl)
|
||||
Url = Annotated[
|
||||
str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))
|
||||
]
|
||||
|
||||
|
||||
def _validate_secret_url(value: SecretStr) -> SecretStr:
|
||||
"""Validate and normalize a URL without echoing it in validator messages."""
|
||||
try:
|
||||
return SecretStr(
|
||||
str(http_url_adapter.validate_python(value.get_secret_value()))
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise CleanedValidationError(exc, secret_input=True) from None
|
||||
|
||||
|
||||
SecretUrl = Annotated[SecretStr, AfterValidator(_validate_secret_url)]
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import requests
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
WebhookPayloads,
|
||||
)
|
||||
@@ -29,16 +30,19 @@ def call_webhook_v2(
|
||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||
logger.debug("Request payload: %s", payload.model_dump_json(indent=2))
|
||||
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=payload.model_dump(),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.post(
|
||||
tenant.webhook_url,
|
||||
json=dump_for_transport(payload),
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||
"User-Agent": settings.app_external_user_agent,
|
||||
},
|
||||
timeout=(10, 20),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException:
|
||||
raise requests.RequestException("Webhook delivery failed") from None
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Application."""
|
||||
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.exceptions import clean_validation_errors
|
||||
from summary.core.sentry import init_sentry
|
||||
|
||||
settings = get_settings()
|
||||
@@ -19,3 +23,13 @@ app = FastAPI(
|
||||
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(dockerflow_router)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||
"""Keep validation details while masking SecretStr input values."""
|
||||
del request
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=jsonable_encoder({"detail": clean_validation_errors(exc.errors())}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
|
||||
class TestTasksV2:
|
||||
"""Tests for the /v2/async-jobs-jobs endpoints."""
|
||||
@@ -174,3 +178,163 @@ class TestTasksV2:
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Forbidden"}
|
||||
|
||||
|
||||
def test_validation_preserves_ordinary_input(client):
|
||||
"""Validation errors keep input values that are not SecretStr."""
|
||||
canary = "private-meeting-content"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/summarize",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={"user_sub": "user", "content": {"unexpected": canary}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "string_type",
|
||||
"loc": ["body", "content"],
|
||||
"msg": "Input should be a valid string",
|
||||
"input": {"unexpected": canary},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_validation_preserves_context(client):
|
||||
"""Validator errors retain location, message and serializable context."""
|
||||
canary = "private-invalid-url-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": f"invalid-url?signature={canary}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary not in response.text
|
||||
assert response.json()["detail"] == [
|
||||
{
|
||||
"type": "url_parsing",
|
||||
"loc": ["body", "cloud_storage_url"],
|
||||
"msg": "Input should be a valid URL, relative URL without a base",
|
||||
"input": "[redacted]",
|
||||
"ctx": {"error": "relative URL without a base"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks_v2.process_audio_transcribe_v2_task.apply_async",
|
||||
return_value=MagicMock(id="transcribe-job"),
|
||||
)
|
||||
def test_signed_urls_are_unwrapped_for_task_submission(mock_apply_async, client):
|
||||
"""API input secrets reach Celery intact, including nested metadata URLs."""
|
||||
signed_url = "https://storage.example.com/file?signature=deterministic-signature"
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": signed_url,
|
||||
"metadata": {
|
||||
"cloud_storage_url": signed_url,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = mock_apply_async.call_args.kwargs["args"][0]
|
||||
assert payload["cloud_storage_url"] == signed_url
|
||||
assert payload["metadata"]["cloud_storage_url"] == signed_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcript", "transcription_data_url"),
|
||||
("summarize", "summary", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_success_status_returns_usable_signed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Success responses explicitly reveal result URLs for authorized clients."""
|
||||
job_type, payload_type, url_field = case
|
||||
signed_url = "https://storage.example.com/result?signature=deterministic-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={"job_id": "job", url_field: signed_url},
|
||||
)
|
||||
response = client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"job_id": "job",
|
||||
"type": payload_type,
|
||||
"status": "success",
|
||||
url_field: signed_url,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
("transcribe", "transcription_data_url"),
|
||||
("summarize", "summary_data_url"),
|
||||
],
|
||||
)
|
||||
@patch("summary.api.route.tasks_v2.celery")
|
||||
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||
def test_invalid_success_result_hides_revealed_url(
|
||||
mock_async_result, mock_celery, client, case
|
||||
):
|
||||
"""Missing result fields cannot retain a raw signed URL in error details."""
|
||||
job_type, url_field = case
|
||||
canary = "private-result-signature"
|
||||
mock_celery.backend.client.exists.return_value = True
|
||||
mock_async_result.return_value = MagicMock(
|
||||
status="SUCCESS",
|
||||
args=[{"tenant_id": "test-tenant"}],
|
||||
result={url_field: f"https://storage.example.com/result?signature={canary}"},
|
||||
)
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
client.get(
|
||||
f"/api/v2/async-jobs/{job_type}/job",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "missing"
|
||||
assert exc.value.errors()[0]["loc"] == ("job_id",)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert canary not in str(exc.value)
|
||||
assert canary not in exc.value.json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nested_error", [False, True])
|
||||
def test_missing_field_errors_preserve_raw_json_strings(client, nested_error):
|
||||
"""Raw JSON input is retained when it has not been converted to SecretStr."""
|
||||
canary = "private-validation-signature"
|
||||
signed_url = f"https://storage.example.com/recording?signature={canary}"
|
||||
payload = {"language": "en", "cloud_storage_url": signed_url}
|
||||
if nested_error:
|
||||
payload["user_sub"] = "user"
|
||||
payload["metadata"] = {"cloud_storage_url": signed_url}
|
||||
response = client.post(
|
||||
"/api/v2/async-jobs/transcribe",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert canary in response.text
|
||||
assert all(error["type"] == "missing" for error in response.json()["detail"])
|
||||
expected_input = payload["metadata"] if nested_error else payload
|
||||
assert all(error["input"] == expected_input for error in response.json()["detail"])
|
||||
|
||||
@@ -22,6 +22,12 @@ def get_settings_override():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_override():
|
||||
"""Provide isolated settings containing the test tenant."""
|
||||
return get_settings_override()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""Provide a FastAPI TestClient for tests."""
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Secret URLs stay masked until explicitly serialized for transport."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from kombu.serialization import dumps, loads
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.models import (
|
||||
PushToDocsTranscriptConfig,
|
||||
RecordingMetadata,
|
||||
TranscribeTaskApiRequest,
|
||||
TranscribeTaskJob,
|
||||
)
|
||||
from summary.core.privacy import dump_for_transport
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookSuccessPayload,
|
||||
TranscribeWebhookSuccessPayload,
|
||||
WhisperXResponse,
|
||||
)
|
||||
|
||||
CANARY = "private-signed-url-canary"
|
||||
SIGNED_URL = f"https://storage.example.com/recording.mp4?signature={CANARY}"
|
||||
METADATA = {
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"started_at": "2026-01-01T10:00:00Z",
|
||||
"ended_at": "2026-01-01T11:00:00Z",
|
||||
}
|
||||
DOCS_CONFIG = {
|
||||
"user_email": "user@example.com",
|
||||
"title": "Meeting",
|
||||
"download_link": SIGNED_URL,
|
||||
"form_link": SIGNED_URL,
|
||||
}
|
||||
MODEL_CASES = [
|
||||
(RecordingMetadata, METADATA, "cloud_storage_url"),
|
||||
(
|
||||
TranscribeTaskApiRequest,
|
||||
{"user_sub": "user", "language": "en", "cloud_storage_url": SIGNED_URL},
|
||||
"cloud_storage_url",
|
||||
),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "download_link"),
|
||||
(PushToDocsTranscriptConfig, DOCS_CONFIG, "form_link"),
|
||||
(
|
||||
TranscribeWebhookSuccessPayload,
|
||||
{"job_id": "job", "transcription_data_url": SIGNED_URL},
|
||||
"transcription_data_url",
|
||||
),
|
||||
(
|
||||
SummarizeWebhookSuccessPayload,
|
||||
{"job_id": "job", "summary_data_url": SIGNED_URL},
|
||||
"summary_data_url",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_signed_urls_are_masked_by_default(model, data, field):
|
||||
"""Reprs, ordinary dumps and JSON output mask all signed URL fields."""
|
||||
value = model.model_validate(data)
|
||||
secret = getattr(value, field)
|
||||
assert isinstance(secret, SecretStr)
|
||||
assert secret.get_secret_value() == SIGNED_URL
|
||||
assert CANARY not in repr(value)
|
||||
assert CANARY not in str(value.model_dump())
|
||||
assert CANARY not in value.model_dump_json()
|
||||
assert dump_for_transport(value)[field] == SIGNED_URL
|
||||
assert getattr(model.model_validate(value.model_dump()), field) == secret
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES)
|
||||
def test_missing_fields_do_not_echo_signed_urls(model, data, field):
|
||||
"""Root validation errors omit raw input even before secrets are wrapped."""
|
||||
required_field = (
|
||||
"job_id"
|
||||
if "job_id" in data
|
||||
else next(
|
||||
key for key in data if key not in {field, "download_link", "form_link"}
|
||||
)
|
||||
)
|
||||
invalid_data = {key: value for key, value in data.items() if key != required_field}
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate(invalid_data)
|
||||
assert CANARY not in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "data", "field"), MODEL_CASES[:4])
|
||||
def test_invalid_signed_urls_have_sanitized_validation_messages(model, data, field):
|
||||
"""URL validation still rejects invalid URLs without quoting their value."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate({**data, field: f"not-a-url?signature={CANARY}"})
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in str(
|
||||
exc.value.errors(include_input=False, include_context=False)
|
||||
)
|
||||
assert exc.value.errors()[0]["type"] == "url_parsing"
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
|
||||
|
||||
def test_nested_task_urls_survive_broker_serialization():
|
||||
"""Nested secrets round-trip through Celery's JSON serializer as real URLs."""
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
language="en",
|
||||
cloud_storage_url=SIGNED_URL,
|
||||
tenant_id="tenant",
|
||||
received_at="2026-01-01T10:00:00Z",
|
||||
metadata=METADATA,
|
||||
push_to_docs_config=DOCS_CONFIG,
|
||||
)
|
||||
assert CANARY not in repr(payload)
|
||||
assert CANARY not in payload.model_dump_json()
|
||||
content_type, encoding, body = dumps(dump_for_transport(payload), serializer="json")
|
||||
restored = TranscribeTaskJob.model_validate(loads(body, content_type, encoding))
|
||||
assert restored.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.metadata.cloud_storage_url.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.download_link.get_secret_value() == SIGNED_URL
|
||||
assert restored.push_to_docs_config.form_link.get_secret_value() == SIGNED_URL
|
||||
|
||||
|
||||
def test_transcription_worker_uses_real_urls(monkeypatch):
|
||||
"""The worker unwraps input URLs and publishes usable signed result URLs."""
|
||||
transcription = WhisperXResponse(segments=())
|
||||
transcribe = Mock(return_value=transcription)
|
||||
monkeypatch.setattr(celery_worker, "transcribe_audio", transcribe)
|
||||
monkeypatch.setattr(celery_worker, "_should_push_to_docs", Mock(return_value=True))
|
||||
monkeypatch.setattr(
|
||||
celery_worker, "_should_auto_create_summary", Mock(return_value=False)
|
||||
)
|
||||
formatter = Mock(return_value="Formatted transcript")
|
||||
monkeypatch.setattr(celery_worker, "format_transcript", formatter)
|
||||
monkeypatch.setattr(celery_worker, "create_document_in_lasuite_docs", Mock())
|
||||
storage = Mock()
|
||||
storage.get_transcript_signed_url.return_value = SIGNED_URL
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.call_webhook_v2_task, "apply_async", publish)
|
||||
task = celery_worker.process_audio_transcribe_v2_task
|
||||
monkeypatch.setattr(task.request, "id", "job")
|
||||
|
||||
result = task.run(
|
||||
{
|
||||
"user_sub": "user",
|
||||
"language": "en",
|
||||
"cloud_storage_url": SIGNED_URL,
|
||||
"tenant_id": "tenant",
|
||||
"received_at": "2026-01-01T10:00:00Z",
|
||||
"push_to_docs_config": DOCS_CONFIG,
|
||||
}
|
||||
)
|
||||
assert transcribe.call_args.kwargs["cloud_storage_url"] == SIGNED_URL
|
||||
assert formatter.call_args.args[3:] == (SIGNED_URL, SIGNED_URL)
|
||||
assert result["transcription_data_url"] == SIGNED_URL
|
||||
assert publish.call_args.kwargs["args"][0] == result
|
||||
|
||||
|
||||
def test_recording_metadata_url_is_unwrapped(monkeypatch):
|
||||
"""Speaker assignment fetches metadata using the real URL."""
|
||||
storage = Mock()
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
resolver = Mock()
|
||||
resolver.return_value.apply_to.return_value = {"segments": []}
|
||||
monkeypatch.setattr(celery_worker, "resolve_speaker_identities", resolver)
|
||||
celery_worker.resolve_speaker_identities_and_apply_to(
|
||||
transcription=WhisperXResponse(segments=()),
|
||||
recording_metadata=RecordingMetadata.model_validate(METADATA),
|
||||
task_id="job",
|
||||
)
|
||||
storage.read_cloud_storage_json.assert_called_once_with(SIGNED_URL)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Regression tests for observability privacy boundaries."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from summary.core import analytics, celery_worker, webhook_service
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.privacy import redact_url
|
||||
from summary.core.sentry import before_breadcrumb, before_send
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload
|
||||
|
||||
CANARY = "sensitive-canary"
|
||||
URL = f"https://user:password@example.com/file?signature={CANARY}#fragment"
|
||||
|
||||
|
||||
def test_url_redaction():
|
||||
"""Credentials and signed query strings are removed."""
|
||||
assert redact_url(URL) == "https://example.com/file?[redacted]"
|
||||
|
||||
|
||||
def test_sentry_scrubs_events_and_breadcrumbs():
|
||||
"""Untrusted exception messages and breadcrumb data never leave in events."""
|
||||
event = {
|
||||
"request": {"url": URL, "data": CANARY, "query_string": CANARY},
|
||||
"exception": {
|
||||
"values": [
|
||||
{
|
||||
"type": "ValueError",
|
||||
"value": CANARY,
|
||||
"stacktrace": {"frames": [{"vars": {"job_id": "job-1"}}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
"extra": {"url": URL},
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert CANARY not in str(result)
|
||||
assert result["exception"]["values"][0]["type"] == "ValueError"
|
||||
assert result["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"] == {
|
||||
"job_id": "job-1"
|
||||
}
|
||||
assert before_breadcrumb({"message": CANARY, "data": {"url": URL}}, {}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["http", "HTTP", "hTtP", "https", "HTTPS", "hTtPs"])
|
||||
def test_sentry_url_scrubbing_is_case_insensitive(scheme):
|
||||
"""Scheme casing cannot bypass redaction of URLs nested in event strings."""
|
||||
event = {
|
||||
"extra": {
|
||||
"nested": [
|
||||
{
|
||||
"detail": (
|
||||
f"prefix <{scheme}://user:password@example.com/Path"
|
||||
f"?signature={CANARY}#fragment> suffix"
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
result = before_send(event, {})
|
||||
assert result["extra"]["nested"][0]["detail"] == (
|
||||
f"prefix <{scheme.lower()}://example.com/Path?[redacted]> suffix"
|
||||
)
|
||||
assert CANARY not in str(result)
|
||||
|
||||
|
||||
def test_llm_exception_does_not_include_upstream_message(caplog):
|
||||
"""LLM failures retain their category without logging upstream text."""
|
||||
observability = Mock(is_enabled=False)
|
||||
observability.get_openai_client.return_value.chat.completions.create.side_effect = (
|
||||
ValueError(CANARY)
|
||||
)
|
||||
with caplog.at_level(logging.DEBUG), pytest.raises(LLMException) as exc:
|
||||
LLMService(observability).call("system", CANARY, "test")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert CANARY not in caplog.text
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO])
|
||||
def test_webhook_content_is_only_logged_at_debug(
|
||||
monkeypatch, caplog, settings_override, log_level
|
||||
):
|
||||
"""DEBUG retains response content; INFO and above only contain metadata."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"id": "document-123", "content": CANARY}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with caplog.at_level(log_level):
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert (
|
||||
webhook_service.requests.post.call_args.kwargs["json"]["summary_data_url"]
|
||||
== URL
|
||||
)
|
||||
assert all(
|
||||
CANARY not in record.getMessage()
|
||||
for record in caplog.records
|
||||
if record.levelno >= logging.INFO
|
||||
)
|
||||
if log_level == logging.DEBUG:
|
||||
assert any(
|
||||
record.levelno == logging.DEBUG and CANARY in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
else:
|
||||
assert CANARY not in caplog.text
|
||||
assert "Document document-123 submitted (HTTP 200)" in caplog.text
|
||||
|
||||
|
||||
def test_webhook_failure_keeps_retry_category(monkeypatch, settings_override):
|
||||
"""Webhook exceptions remain retryable without upstream text."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
monkeypatch.setattr(
|
||||
webhook_service.requests,
|
||||
"post",
|
||||
Mock(side_effect=requests.RequestException(URL)),
|
||||
)
|
||||
payload = SummarizeWebhookSuccessPayload(job_id="job", summary_data_url=URL)
|
||||
with pytest.raises(requests.RequestException) as exc:
|
||||
webhook_service.call_webhook_v2(tenant_id="test-tenant", payload=payload)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
def test_analytics_failure_does_not_chain_payload(monkeypatch):
|
||||
"""Analytics errors do not expose arbitrary client exception text."""
|
||||
client = analytics.Analytics()
|
||||
client._client = Mock()
|
||||
client._client.capture.side_effect = ValueError(CANARY)
|
||||
with pytest.raises(analytics.AnalyticsException) as exc:
|
||||
client.capture("event", "user")
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_level", [logging.DEBUG, logging.INFO, logging.ERROR])
|
||||
def test_celery_log_filter_respects_log_level(log_level):
|
||||
"""Celery trace values remain available in DEBUG only."""
|
||||
record = logging.LogRecord(
|
||||
"celery.app.trace", log_level, "", 0, "%s", (CANARY,), None
|
||||
)
|
||||
record.data = {"name": "task", "id": "job", "return_value": URL}
|
||||
assert celery_worker.CeleryLogPrivacyFilter().filter(record)
|
||||
if log_level == logging.DEBUG:
|
||||
assert CANARY in record.getMessage()
|
||||
assert record.data["return_value"] == URL
|
||||
else:
|
||||
assert CANARY not in record.getMessage()
|
||||
assert record.data == {}
|
||||
|
||||
|
||||
def test_task_arguments_have_private_display_representations(monkeypatch):
|
||||
"""The actual task arguments are delivered but their event repr is hidden."""
|
||||
publish = Mock()
|
||||
monkeypatch.setattr(celery_worker.Task, "apply_async", publish)
|
||||
task = celery_worker.PrivateTask()
|
||||
task.apply_async(args=[CANARY], kwargs={"url": URL})
|
||||
assert publish.call_args.kwargs["args"] == [CANARY]
|
||||
assert publish.call_args.kwargs["argsrepr"] == "[Filtered]"
|
||||
assert publish.call_args.kwargs["kwargsrepr"] == "[Filtered]"
|
||||
|
||||
|
||||
def test_analytics_source_url_removes_signature():
|
||||
"""Task analytics keeps the source path without credentials or signature."""
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
from summary.core.models import TranscribeTaskJob # noqa: PLC0415
|
||||
|
||||
manager = analytics.MetadataManager()
|
||||
manager._is_disabled = False
|
||||
manager.has_task_id = Mock(return_value=False)
|
||||
manager._save_metadata = Mock()
|
||||
payload = TranscribeTaskJob(
|
||||
user_sub="user",
|
||||
tenant_id="tenant",
|
||||
cloud_storage_url=URL,
|
||||
language="en",
|
||||
received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
manager.create("job", payload)
|
||||
metadata = manager._save_metadata.call_args.args[1]
|
||||
assert metadata["source_url"] == "https://example.com/file?[redacted]"
|
||||
assert CANARY not in str(metadata)
|
||||
|
||||
|
||||
def test_invalid_llm_json_is_sanitized():
|
||||
"""Invalid structured LLM output retains a retryable LLM category."""
|
||||
with pytest.raises(LLMException) as exc:
|
||||
celery_worker.parse_llm_json(CANARY)
|
||||
assert CANARY not in str(exc.value)
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
(None, "[redacted]"),
|
||||
("https://[invalid?secret=value", "[redacted]"),
|
||||
("https://example.com/path#fragment", "https://example.com/path"),
|
||||
("https://example.com/path?", "https://example.com/path?[redacted]"),
|
||||
],
|
||||
)
|
||||
def test_redact_url_edge_cases(url, expected):
|
||||
"""Malformed and non-string URLs do not expose credentials or query data."""
|
||||
assert redact_url(url) == expected
|
||||
@@ -1,27 +1,32 @@
|
||||
"""Tests for the Sentry configuration.
|
||||
|
||||
Each test raises an error from code handling meeting content and inspects the
|
||||
event Sentry would send: the content must be redacted, while harmless local
|
||||
variables are kept for debugging.
|
||||
event Sentry would send: sensitive content is redacted and useful locals remain.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.stub import Stubber
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
from sentry_sdk.transport import Transport
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core import file_service, webhook_service
|
||||
from summary.core import sentry as sentry_module
|
||||
from summary.core.file_service import FileService
|
||||
from summary.core.file_service import FileService, FileServiceException
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
from summary.core.shared_models import SummarizeWebhookSuccessPayload, WhisperXResponse
|
||||
|
||||
CANARY = "CANARY-MEETING-CONTENT"
|
||||
|
||||
@@ -51,8 +56,14 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
transport=transport,
|
||||
send_default_pii=False,
|
||||
include_local_variables=True,
|
||||
before_send=sentry_module.before_send,
|
||||
before_breadcrumb=sentry_module.before_breadcrumb,
|
||||
event_scrubber=sentry_module.build_event_scrubber(),
|
||||
default_integrations=False,
|
||||
integrations=[
|
||||
LoggingIntegration(level=logging.DEBUG, event_level=None),
|
||||
StdlibIntegration(),
|
||||
],
|
||||
)
|
||||
|
||||
def flush() -> list[str]:
|
||||
@@ -63,6 +74,125 @@ def sentry_events() -> Iterator[SentryEvents]:
|
||||
sentry_sdk.init() # Disable Sentry for the following tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_recording_url() -> Iterator[str]:
|
||||
"""Serve a deterministic 404 through the real instrumented HTTP client."""
|
||||
|
||||
class MissingRecordingHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
with ThreadingHTTPServer(("127.0.0.1", 0), MissingRecordingHandler) as server:
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield (
|
||||
f"http://127.0.0.1:{server.server_port}/recordings/recording-123.mp4"
|
||||
f"?X-Amz-Credential={CANARY}-credential"
|
||||
f"&X-Amz-Signature={CANARY}-signature"
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_sentry_download_404_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str, caplog
|
||||
) -> None:
|
||||
"""A failed download excludes signed URLs from logs, locals and breadcrumbs."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
try:
|
||||
FileService()._download_from_cloud_storage_url(missing_recording_url)
|
||||
except FileServiceException as exc:
|
||||
assert exc.__cause__ is None
|
||||
assert exc.__suppress_context__
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("the download should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert CANARY not in caplog.text
|
||||
assert "/recordings/recording-123.mp4?[redacted]" in caplog.text
|
||||
parsed = json.loads(event)
|
||||
assert [item["type"] for item in parsed["exception"]["values"]] == [
|
||||
"FileServiceException"
|
||||
]
|
||||
download_vars = next(
|
||||
frame_vars for frame_vars in _local_vars(event) if "extension" in frame_vars
|
||||
)
|
||||
assert download_vars["extension"] == "'.mp4'"
|
||||
assert download_vars["cloud_storage_url"] == "[Filtered]"
|
||||
breadcrumbs = parsed["breadcrumbs"]["values"]
|
||||
assert any(crumb.get("type") == "http" for crumb in breadcrumbs)
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.file_service" for crumb in breadcrumbs
|
||||
)
|
||||
assert all("message" not in crumb and "data" not in crumb for crumb in breadcrumbs)
|
||||
|
||||
|
||||
def test_sentry_chained_http_error_excludes_signed_url(
|
||||
sentry_events: SentryEvents, missing_recording_url: str
|
||||
) -> None:
|
||||
"""Sentry also filters signed URLs in explicitly chained HTTP errors."""
|
||||
try:
|
||||
try:
|
||||
response = requests.get(missing_recording_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise FileServiceException("Download failed") from exc
|
||||
except FileServiceException:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
exceptions = json.loads(event)["exception"]["values"]
|
||||
assert [item["type"] for item in exceptions] == [
|
||||
"HTTPError",
|
||||
"FileServiceException",
|
||||
]
|
||||
assert all(item["value"] == "[Filtered]" for item in exceptions)
|
||||
|
||||
|
||||
def test_sentry_excludes_webhook_debug_content(
|
||||
sentry_events: SentryEvents, monkeypatch, settings_override, caplog
|
||||
) -> None:
|
||||
"""Raw DEBUG responses stay in application logs and out of Sentry events."""
|
||||
monkeypatch.setattr(webhook_service, "settings", settings_override)
|
||||
signed_url = f"https://storage.example.com/result?signature={CANARY}"
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {
|
||||
"id": "document-123",
|
||||
"content": CANARY,
|
||||
"url": signed_url,
|
||||
}
|
||||
monkeypatch.setattr(webhook_service.requests, "post", Mock(return_value=response))
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
webhook_service.call_webhook_v2(
|
||||
tenant_id="test-tenant",
|
||||
payload=SummarizeWebhookSuccessPayload(
|
||||
job_id="job", summary_data_url=signed_url
|
||||
),
|
||||
)
|
||||
try:
|
||||
raise RuntimeError("A later failure")
|
||||
except RuntimeError:
|
||||
sentry_sdk.capture_exception()
|
||||
|
||||
assert CANARY in caplog.text
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
assert any(
|
||||
crumb.get("category") == "summary.core.webhook_service"
|
||||
and crumb.get("level") == "debug"
|
||||
for crumb in json.loads(event)["breadcrumbs"]["values"]
|
||||
)
|
||||
|
||||
|
||||
def _transcript() -> WhisperXResponse:
|
||||
return WhisperXResponse.model_validate(
|
||||
{
|
||||
@@ -121,7 +251,7 @@ def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
|
||||
def test_sentry_store_transcript_failure_redacts_transcript(
|
||||
sentry_events: SentryEvents, s3_stubber: Stubber
|
||||
) -> None:
|
||||
"""A failed S3 upload does not send the transcript, but keeps the job id."""
|
||||
"""A failed S3 upload redacts the transcript while retaining useful locals."""
|
||||
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Formatted validation errors hide input values, including after pickling."""
|
||||
|
||||
import pickle
|
||||
import traceback
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from summary.core.exceptions import CleanedValidationError
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
def test_formatted_errors_hide_ordinary_input_after_pickle_roundtrip():
|
||||
"""Formatting hides raw input even when diagnostics intentionally retain it."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[{"type": "int_parsing", "loc": ("field",), "input": CANARY}],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
restored = pickle.loads(pickle.dumps(cleaned)) # noqa: S301 -- own test exception
|
||||
for error in (cleaned, restored):
|
||||
assert error.errors()[0]["input"] == CANARY
|
||||
assert CANARY not in str(error)
|
||||
assert CANARY not in repr(error)
|
||||
assert CANARY not in "".join(traceback.format_exception(error))
|
||||
assert error.errors()[0]["type"] == "int_parsing"
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Validation cleanup preserves diagnostics without retaining SecretStr values."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pickle
|
||||
import traceback
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import Retry
|
||||
from celery.utils.serialization import get_pickleable_exception
|
||||
from fastapi import Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
from summary.core import celery_worker
|
||||
from summary.core.exceptions import CleanedValidationError, clean_validation_errors
|
||||
from summary.main import validation_error_handler
|
||||
|
||||
CANARY = "private-validation-canary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom", [False, True])
|
||||
def test_cleaned_validation_error_preserves_details_and_pickles(custom):
|
||||
"""Builtin and custom validation errors retain their type, location and message."""
|
||||
error_type = (
|
||||
PydanticCustomError(
|
||||
"custom_validation", "Invalid value: {reason}", {"reason": "test"}
|
||||
)
|
||||
if custom
|
||||
else "int_type"
|
||||
)
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{"type": error_type, "loc": ("secret",), "input": SecretStr(CANARY)},
|
||||
{"type": "int_parsing", "loc": ("ordinary",), "input": "not-an-int"},
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
original_details = original.errors()
|
||||
details = cleaned.errors()
|
||||
assert isinstance(cleaned, ValidationError)
|
||||
assert cleaned.title == original.title
|
||||
assert cleaned.error_count() == original.error_count()
|
||||
assert details[0] == {**original_details[0], "input": "[redacted]"}
|
||||
assert details[1] == original_details[1]
|
||||
assert isinstance(original.errors()[0]["input"], SecretStr)
|
||||
assert CANARY not in str(cleaned)
|
||||
assert CANARY not in cleaned.json()
|
||||
serialized = pickle.dumps(cleaned)
|
||||
assert CANARY.encode() not in serialized
|
||||
restored = pickle.loads(serialized) # noqa: S301 -- own in-memory test exception
|
||||
assert isinstance(restored, CleanedValidationError)
|
||||
assert restored.errors() == details
|
||||
assert isinstance(get_pickleable_exception(cleaned), CleanedValidationError)
|
||||
|
||||
|
||||
def test_cleanup_masks_nested_secrets_and_preserves_plain_input():
|
||||
"""Missing-field inputs keep ordinary values while masking nested secrets."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"NestedModel",
|
||||
[
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ("required",),
|
||||
"input": {
|
||||
"ordinary": "keep me",
|
||||
"nested": [SecretStr(CANARY), {"url": SecretStr(CANARY)}],
|
||||
"tuple": (SecretStr(CANARY), 42),
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["input"] == {
|
||||
"ordinary": "keep me",
|
||||
"nested": ["[redacted]", {"url": "[redacted]"}],
|
||||
"tuple": ("[redacted]", 42),
|
||||
}
|
||||
assert CANARY not in cleaned.json()
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_cleanup_does_not_retain_validator_exception_objects():
|
||||
"""Validator context keeps its readable message without secret-bearing args."""
|
||||
original = ValidationError.from_exception_data(
|
||||
"TestModel",
|
||||
[
|
||||
{
|
||||
"type": "value_error",
|
||||
"loc": (),
|
||||
"input": SecretStr(CANARY),
|
||||
"ctx": {"error": ValueError(SecretStr(CANARY))},
|
||||
}
|
||||
],
|
||||
)
|
||||
cleaned = CleanedValidationError(original)
|
||||
assert cleaned.errors()[0]["ctx"] == {"error": "**********"}
|
||||
assert CANARY.encode() not in pickle.dumps(cleaned)
|
||||
|
||||
|
||||
def test_clean_validation_errors_keeps_all_other_fields():
|
||||
"""The API cleanup preserves diagnostics and nonsecret input."""
|
||||
details = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"url": "https://example.com/errors/test",
|
||||
"ctx": {"limit": 3},
|
||||
"input": "ordinary input",
|
||||
}
|
||||
]
|
||||
assert clean_validation_errors(details) == details
|
||||
assert (
|
||||
json.loads(json.dumps(clean_validation_errors(details)))[0]["input"]
|
||||
== "ordinary input"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"handler_name",
|
||||
[
|
||||
"call_webhook_v2_task",
|
||||
"process_audio_transcribe_v2_task",
|
||||
"summarize_v2_task",
|
||||
"task_started_transcript",
|
||||
"task_started_summary",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("secret_wrapped", [False, True])
|
||||
def test_task_validation_uses_cleaned_errors(handler_name, secret_wrapped):
|
||||
"""Task entry points and prerun signals consistently suppress the original error."""
|
||||
handler = getattr(celery_worker, handler_name)
|
||||
signed_url = f"https://storage.example.com/file?signature={CANARY}"
|
||||
value = SecretStr(signed_url) if secret_wrapped else signed_url
|
||||
payload = {"cloud_storage_url": value, "content": value}
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
if handler_name.startswith("task_started"):
|
||||
handler(task_id="job", args=[payload])
|
||||
elif handler_name == "call_webhook_v2_task":
|
||||
handler.run(
|
||||
payload={
|
||||
"type": "summary",
|
||||
"status": "success",
|
||||
"summary_data_url": value,
|
||||
},
|
||||
tenant_id="tenant",
|
||||
)
|
||||
elif handler_name == "summarize_v2_task":
|
||||
# Exercise validation without triggering the summary task's retries.
|
||||
handler._orig_run(payload=payload)
|
||||
else:
|
||||
handler.run(payload=payload)
|
||||
assert exc.value.__suppress_context__
|
||||
assert CANARY not in "".join(traceback.format_exception(exc.value))
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_upstream_transcription_validation_hides_raw_input(monkeypatch):
|
||||
"""Invalid ASR responses do not remain in structured validation errors."""
|
||||
storage = MagicMock()
|
||||
storage.prepare_audio_file.return_value.__enter__.return_value = (
|
||||
Mock(),
|
||||
{"duration": 1},
|
||||
)
|
||||
monkeypatch.setattr(celery_worker, "file_service", storage)
|
||||
monkeypatch.setattr(celery_worker, "metadata_manager", Mock())
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"text": {"content": CANARY}}
|
||||
monkeypatch.setattr(celery_worker.requests, "post", Mock(return_value=response))
|
||||
with pytest.raises(CleanedValidationError) as exc:
|
||||
celery_worker.transcribe_audio(
|
||||
task_id="job", language="en", cloud_storage_url="https://example.com/audio"
|
||||
)
|
||||
assert exc.value.errors()[0]["input"] == "[redacted]"
|
||||
assert CANARY not in exc.value.json()
|
||||
assert CANARY.encode() not in pickle.dumps(exc.value)
|
||||
|
||||
|
||||
def test_api_error_handler_uses_secret_aware_cleanup():
|
||||
"""API errors mask typed secrets while retaining other values and context."""
|
||||
errors = [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ("body", "field"),
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": SecretStr(CANARY), "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
response = asyncio.run(
|
||||
validation_error_handler(
|
||||
Request({"type": "http"}), RequestValidationError(errors)
|
||||
)
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert CANARY.encode() not in response.body
|
||||
assert json.loads(response.body)["detail"] == [
|
||||
{
|
||||
"type": "test",
|
||||
"loc": ["body", "field"],
|
||||
"msg": "Invalid value",
|
||||
"input": {"url": "[redacted]", "ordinary": "keep me"},
|
||||
"ctx": {"limit": 3},
|
||||
"url": "https://example.com/errors/test",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_summary_task_retries_with_the_cleaned_exception(monkeypatch):
|
||||
"""The summary task still retries validation failures using the safe exception."""
|
||||
retry = Mock(side_effect=Retry())
|
||||
monkeypatch.setattr(celery_worker.summarize_v2_task, "retry", retry)
|
||||
with pytest.raises(Retry):
|
||||
celery_worker.summarize_v2_task.run(payload={"received_at": SecretStr(CANARY)})
|
||||
error = retry.call_args.kwargs["exc"]
|
||||
assert isinstance(error, CleanedValidationError)
|
||||
assert CANARY.encode() not in pickle.dumps(error)
|
||||
Generated
+2
@@ -1492,6 +1492,7 @@ dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "dockerflow" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "kombu" },
|
||||
{ name = "langfuse" },
|
||||
{ name = "openai" },
|
||||
{ name = "posthog" },
|
||||
@@ -1517,6 +1518,7 @@ requires-dist = [
|
||||
{ name = "certifi", specifier = ">=2026.10.19" },
|
||||
{ name = "dockerflow", specifier = "==2026.3.4" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.105.0" },
|
||||
{ name = "kombu", specifier = "==5.6.2" },
|
||||
{ name = "langfuse", specifier = "==4.14.1" },
|
||||
{ name = "openai", specifier = "==3.37.0" },
|
||||
{ name = "posthog", specifier = "==7.75.11" },
|
||||
|
||||
Reference in New Issue
Block a user