Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe5b38d1a | |||
| bb9b1ac84b |
@@ -8,21 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||
- 🦺(backend) strengthen API validation for recording options #1063
|
||||
- ⚡️(frontend) optimize few performance caveats #1073
|
||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(migrations) use settings in migrations #1058
|
||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||
|
||||
## [1.9.0] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- 👷(docker) add arm64 platform support for image builds
|
||||
@@ -42,8 +27,6 @@ and this project adheres to
|
||||
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
||||
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
||||
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
||||
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.9.0"
|
||||
version = "1.8.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
from typing import Literal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from livekit.api import ParticipantPermission
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
@@ -205,27 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
|
||||
class RecordingOptions(BaseModel):
|
||||
"""Configuration options for recording.
|
||||
|
||||
Attributes:
|
||||
language: ISO 639-1 language code compatible with whisperX.
|
||||
When `None`, the transcription engine will attempt to
|
||||
auto-detect the spoken language.
|
||||
transcribe: Whether to transcribe the recorded audio.
|
||||
When `None`, falls back to the application default.
|
||||
original_mode: The original recording mode before any override.
|
||||
Must be one of the valid RecordingModeChoices values when provided.
|
||||
|
||||
"""
|
||||
|
||||
language: str | None = None
|
||||
transcribe: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
@@ -238,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = SchemaField(
|
||||
schema=RecordingOptions | None,
|
||||
options = serializers.JSONField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Recording options",
|
||||
default=dict,
|
||||
)
|
||||
|
||||
|
||||
@@ -287,28 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||
|
||||
Control what a participant is allowed to publish, subscribe, and do within a room.
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_subscribe: bool | None = None
|
||||
can_publish: bool | None = None
|
||||
can_publish_data: bool | None = None
|
||||
can_publish_sources: list[int] = Field(
|
||||
default_factory=list
|
||||
) # TrackSource enum values
|
||||
hidden: bool | None = None
|
||||
recorder: bool | None = None
|
||||
can_update_metadata: bool | None = None
|
||||
agent: bool | None = None
|
||||
can_subscribe_metrics: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant update data."""
|
||||
|
||||
@@ -320,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
allow_null=True,
|
||||
help_text="Participant attributes as JSON object",
|
||||
)
|
||||
permission = SchemaField(
|
||||
schema=ParticipantPermission | None,
|
||||
permission = serializers.DictField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Participant permissions",
|
||||
help_text="Participant permission as JSON object",
|
||||
)
|
||||
name = serializers.CharField(
|
||||
max_length=255,
|
||||
@@ -334,33 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
help_text="Display name for the participant",
|
||||
)
|
||||
|
||||
def validate_permission(self, permission):
|
||||
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
||||
|
||||
if permission is None:
|
||||
return None
|
||||
|
||||
suspicious_fields = [
|
||||
field
|
||||
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
||||
if getattr(permission, field) is not None
|
||||
]
|
||||
if suspicious_fields:
|
||||
raise SuspiciousOperation(
|
||||
f"Setting the following participant permissions is not allowed: "
|
||||
f"{', '.join(suspicious_fields)}."
|
||||
)
|
||||
if permission.can_subscribe_metrics is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"permission": {
|
||||
"can_subscribe_metrics": "This permission is not implemented."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return permission
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure at least one update field is provided."""
|
||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||
@@ -376,4 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
f"{', '.join(update_fields)}."
|
||||
)
|
||||
|
||||
if "permission" in attrs:
|
||||
try:
|
||||
ParticipantPermission(**attrs["permission"])
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(
|
||||
{"permission": f"Invalid permission: {str(e)}"}
|
||||
) from e
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -296,14 +296,12 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data.get("options")
|
||||
options = serializer.validated_data["options"]
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
room=room, mode=mode, options=options
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
@@ -609,15 +607,13 @@ class RoomViewSet(
|
||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
permission = serializer.validated_data.get("permission")
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
metadata=serializer.validated_data.get("metadata"),
|
||||
attributes=serializer.validated_data.get("attributes"),
|
||||
permission=permission.model_dump() if permission else None,
|
||||
permission=serializer.validated_data.get("permission"),
|
||||
name=serializer.validated_data.get("name"),
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
|
||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
||||
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='resourceaccess',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||
from django.conf import settings
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||
from django.conf import settings
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -8,7 +8,6 @@ import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
@@ -133,7 +132,11 @@ def test_update_participant_success(mock_livekit_client):
|
||||
1,
|
||||
2,
|
||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||
"hidden": False,
|
||||
"recorder": False,
|
||||
"can_update_metadata": True,
|
||||
"agent": False,
|
||||
"can_subscribe_metrics": False,
|
||||
},
|
||||
"name": "John Doe",
|
||||
}
|
||||
@@ -148,151 +151,6 @@ def test_update_participant_success(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permission_payload",
|
||||
[
|
||||
{}, # empty dict is valid
|
||||
{"can_subscribe": True},
|
||||
{"can_publish": True},
|
||||
{"can_publish_data": True},
|
||||
{"can_publish_sources": [1, 2]},
|
||||
{"can_update_metadata": True},
|
||||
],
|
||||
)
|
||||
def test_update_participant_permission_fields_are_optional(
|
||||
mock_livekit_client, permission_payload
|
||||
):
|
||||
"""Test that each required permission field can be passed individually."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": permission_payload,
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,permission_key",
|
||||
[
|
||||
(False, "hidden"),
|
||||
(True, "hidden"),
|
||||
(False, "recorder"),
|
||||
(True, "recorder"),
|
||||
(False, "agent"),
|
||||
(True, "agent"),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||
def test_update_participant_suspicious_permission(
|
||||
mock_suspicious, value, permission_key
|
||||
):
|
||||
"""Test update participant raises 400 when a restricted permission is set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_update_metadata": False,
|
||||
permission_key: value,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_suspicious.assert_called_once_with(
|
||||
f"Setting the following participant permissions is not allowed: {permission_key}."
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
||||
"""Test update participant raises 400 when multiple suspicious permissions are set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"hidden": True,
|
||||
"recorder": False,
|
||||
"can_update_metadata": False,
|
||||
"agent": True,
|
||||
"can_subscribe_metrics": False,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_suspicious.assert_called_once_with(
|
||||
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", (False, True))
|
||||
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
||||
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_update_metadata": False,
|
||||
"can_subscribe_metrics": value,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "can_subscribe_metrics" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_forbidden_without_access():
|
||||
"""Test update participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
@@ -368,17 +226,7 @@ def test_update_participant_invalid_permission():
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {
|
||||
"permission": [
|
||||
{
|
||||
"type": "extra_forbidden",
|
||||
"loc": ["invalid-attributes"],
|
||||
"msg": "Extra inputs are not permitted",
|
||||
"input": "True",
|
||||
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
||||
},
|
||||
]
|
||||
}
|
||||
assert "Invalid permission" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_wrong_metadata_attributes():
|
||||
|
||||
@@ -199,308 +199,3 @@ def test_start_recording_success(
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
|
||||
def test_start_recording_options_language_valid(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept a valid ISO 639-1 language code."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"language": value}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
|
||||
def test_start_recording_options_language_not_validated(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Invalid language codes are currently accepted — no format validation yet.
|
||||
|
||||
TODO: tighten this once language validation is introduced.
|
||||
"""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_start_recording_options_language_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept null language (triggers auto-detection)."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
|
||||
def test_start_recording_options_transcribe_valid_true(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic true values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"transcribe": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
|
||||
def test_start_recording_options_transcribe_valid_false(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic false values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"transcribe": False}
|
||||
|
||||
|
||||
def test_start_recording_options_transcribe_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept transcribe=null (falls back to application default)."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept options=null."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": None},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_omitted(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept a request with no options field at all."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_unknown_field_rejected(settings):
|
||||
"""Should reject unknown fields in options (extra='forbid')."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["foo", 12])
|
||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||
"""Should reject non-boolean transcribe values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
|
||||
def test_start_recording_options_original_mode_valid(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept valid recording mode choices for original_mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"original_mode": value}
|
||||
|
||||
|
||||
def test_start_recording_options_original_mode_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept original_mode=null."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_original_mode_omitted(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept a request with original_mode omitted."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
||||
def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
"""Should reject invalid recording mode values for original_mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -596,12 +596,6 @@ class Base(Configuration):
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
)
|
||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||
["hidden", "recorder", "agent"],
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.9.0"
|
||||
version = "1.8.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -39,7 +39,6 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.11",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
@@ -51,7 +50,6 @@ dependencies = [
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pydantic==2.12.4",
|
||||
"PyJWT==2.11.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.5",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -753,7 +753,7 @@ export const Join = ({
|
||||
try {
|
||||
saveVideoInputDeviceId(id)
|
||||
if (videoTrack) {
|
||||
await videoTrack.setDeviceId({ exact: id })
|
||||
await await videoTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch camera device', err)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
|
||||
@@ -74,17 +74,13 @@ export const useWaitingParticipants = () => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setListEnabled(false)
|
||||
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (const participant of waitingParticipants) {
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
}
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||
const FINAL_COUNTDOWN_SECONDS = 10
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
||||
|
||||
const shouldAnnounce =
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
|
||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
||||
|
||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
const participantNameStyles: CSSProperties = {
|
||||
paddingBottom: '0.1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const participantNameScreenShareStyles: CSSProperties = {
|
||||
...participantNameStyles,
|
||||
marginLeft: '0.4rem',
|
||||
}
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
@@ -30,14 +17,26 @@ export const ParticipantName = ({
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -183,7 +183,7 @@ export const ParticipantTile: (
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<span>
|
||||
<>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
@@ -197,7 +197,7 @@ export const ParticipantTile: (
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
@@ -210,12 +210,10 @@ export const ParticipantTile: (
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
@@ -231,9 +229,9 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<div className="shortcut-hint-wrapper">
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
+1
-16
@@ -1,6 +1,5 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -88,24 +87,10 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: deviceShortcut?.id,
|
||||
handler: async () => {
|
||||
const nextState = !enabled
|
||||
try {
|
||||
const didChange = await toggle(nextState)
|
||||
if (didChange === false) return
|
||||
|
||||
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
})
|
||||
announce(message, 'assertive')
|
||||
} catch {
|
||||
// no announce
|
||||
}
|
||||
},
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ToolsToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -16,11 +15,6 @@ export const ToolsToggle = ({
|
||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'recording',
|
||||
handler: toggleTools,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -12,7 +12,6 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
|
||||
@@ -22,8 +21,6 @@ export function DesktopControlBar({
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: () => {
|
||||
@@ -35,13 +32,6 @@ export function DesktopControlBar({
|
||||
firstButton?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'fullscreen',
|
||||
handler: toggleFullScreen,
|
||||
isDisabled: !isFullscreenAvailable,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
|
||||
@@ -31,9 +31,6 @@ import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useSettingsDialog } from '@/features/settings'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||
@@ -100,7 +97,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const { toggleSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
@@ -115,13 +111,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'open-shortcuts',
|
||||
handler: useCallback(() => {
|
||||
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
|
||||
}, [toggleSettingsDialog]),
|
||||
})
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
|
||||
@@ -4,10 +4,8 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
|
||||
export const flexibleRoomIdPattern =
|
||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||
|
||||
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
||||
|
||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
roomRegex.test(roomIdOrUrl) ||
|
||||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
|
||||
@@ -3,20 +3,15 @@ import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { H } from '@/primitives'
|
||||
|
||||
const tableStyle = css({
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
overflowY: 'auto',
|
||||
'& caption': {
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.75rem',
|
||||
textAlign: 'left',
|
||||
},
|
||||
'& th, & td': {
|
||||
padding: '0.65rem 0',
|
||||
textAlign: 'left',
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'& tbody tr': {
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
@@ -34,11 +29,12 @@ export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>{t('shortcuts.listLabel')}</H>
|
||||
<table className={tableStyle}>
|
||||
<caption>{t('shortcuts.listLabel')}</caption>
|
||||
<thead>
|
||||
<thead className="sr-only">
|
||||
<tr>
|
||||
<th scope="col">{t('shortcuts.columnAction')}</th>
|
||||
<th scope="col">{t('shortcuts.columnShortcut')}</th>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
@@ -22,8 +22,6 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
const EMPTY_PROPS = {}
|
||||
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||
@@ -61,7 +59,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const isCamEnabled = devicesIn?.length > 0
|
||||
|
||||
const disabledProps = isCamEnabled
|
||||
? EMPTY_PROPS
|
||||
? {}
|
||||
: {
|
||||
placeholder: t('permissionsRequired'),
|
||||
isDisabled: true,
|
||||
@@ -119,40 +117,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
}
|
||||
}, [videoDeviceId, videoElement])
|
||||
|
||||
const resolutionItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const videoQualityItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: VideoQuality.HIGH.toString(),
|
||||
label: t('resolution.subscribe.items.high'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.MEDIUM.toString(),
|
||||
label: t('resolution.subscribe.items.medium'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.LOW.toString(),
|
||||
label: t('resolution.subscribe.items.low'),
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<RowWrapper heading={t('camera.heading')}>
|
||||
@@ -214,7 +178,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.publish.label')}
|
||||
items={resolutionItems}
|
||||
items={[
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]}
|
||||
selectedKey={videoPublishResolution}
|
||||
onSelectionChange={async (key) => {
|
||||
await handleVideoResolutionChange(key as VideoResolution)
|
||||
@@ -229,7 +206,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.subscribe.label')}
|
||||
items={videoQualityItems}
|
||||
items={[
|
||||
{
|
||||
value: VideoQuality.HIGH.toString(),
|
||||
label: t('resolution.subscribe.items.high'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.MEDIUM.toString(),
|
||||
label: t('resolution.subscribe.items.medium'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.LOW.toString(),
|
||||
label: t('resolution.subscribe.items.low'),
|
||||
},
|
||||
]}
|
||||
selectedKey={videoSubscribeQuality?.toString()}
|
||||
onSelectionChange={(key) => {
|
||||
if (key == undefined) return
|
||||
|
||||
@@ -14,25 +14,7 @@ export const useSettingsDialog = () => {
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
|
||||
const closeSettingsDialog = () => {
|
||||
settingsStore.areSettingsOpen = false
|
||||
}
|
||||
|
||||
const toggleSettingsDialog = (
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
) => {
|
||||
if (areSettingsOpen) {
|
||||
closeSettingsDialog()
|
||||
} else {
|
||||
if (defaultSelectedTab)
|
||||
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openSettingsDialog,
|
||||
closeSettingsDialog,
|
||||
toggleSettingsDialog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Shortcut } from './types'
|
||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
@@ -30,11 +29,6 @@ export type ShortcutDescriptor = {
|
||||
}
|
||||
|
||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
{
|
||||
id: 'open-shortcuts',
|
||||
category: 'navigation',
|
||||
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
category: 'navigation',
|
||||
|
||||
@@ -25,9 +25,9 @@ export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
{visualLabel}
|
||||
</kbd>
|
||||
<div className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
<span>{visualLabel}</span>
|
||||
</div>
|
||||
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -31,9 +31,9 @@ export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<th scope="row" className={text({ variant: 'body' })}>
|
||||
<td className={text({ variant: 'body' })}>
|
||||
{t(`actions.${descriptor.id}`)}
|
||||
</th>
|
||||
</td>
|
||||
<td className={shortcutCellStyle}>
|
||||
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
|
||||
</td>
|
||||
|
||||
@@ -19,10 +19,7 @@ export const useKeyboardShortcuts = () => {
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
|
||||
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
|
||||
}
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
e.preventDefault()
|
||||
await shortcut()
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||
"disable": "Kamera deaktivieren",
|
||||
"enable": "Kamera aktivieren",
|
||||
"turnedOff": "Kamera deaktiviert",
|
||||
"turnedOn": "Kamera aktiviert",
|
||||
"label": "Kamera",
|
||||
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
|
||||
},
|
||||
@@ -32,8 +30,6 @@
|
||||
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
||||
"disable": "Mikrofon deaktivieren",
|
||||
"enable": "Mikrofon aktivieren",
|
||||
"turnedOff": "Mikrofon deaktiviert",
|
||||
"turnedOn": "Mikrofon aktiviert",
|
||||
"label": "Mikrofon"
|
||||
},
|
||||
"audiooutput": {
|
||||
@@ -590,7 +586,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
|
||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
"permissionsNeeded": "Select camera - permission needed",
|
||||
"disable": "Disable camera",
|
||||
"enable": "Enable camera",
|
||||
"turnedOff": "Camera turned off",
|
||||
"turnedOn": "Camera turned on",
|
||||
"label": "Camera",
|
||||
"placeholder": "Enable camera to see the preview"
|
||||
},
|
||||
@@ -32,8 +30,6 @@
|
||||
"permissionsNeeded": "Select microphone - permission needed",
|
||||
"disable": "Disable microphone",
|
||||
"enable": "Enable microphone",
|
||||
"turnedOff": "Microphone turned off",
|
||||
"turnedOn": "Microphone turned on",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
@@ -590,7 +586,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
|
||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||
"disable": "Désactiver la webcam",
|
||||
"enable": "Activer la webcam",
|
||||
"turnedOff": "Webcam désactivée",
|
||||
"turnedOn": "Webcam activée",
|
||||
"label": "Webcam",
|
||||
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
|
||||
},
|
||||
@@ -32,8 +30,6 @@
|
||||
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
||||
"disable": "Désactiver le micro",
|
||||
"enable": "Activer le micro",
|
||||
"turnedOff": "Micro désactivé",
|
||||
"turnedOn": "Micro activé",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
@@ -590,7 +586,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options pour {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
|
||||
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
||||
"disable": "Camera uitschakelen",
|
||||
"enable": "Camera inschakelen",
|
||||
"turnedOff": "Camera uitgeschakeld",
|
||||
"turnedOn": "Camera ingeschakeld",
|
||||
"label": "Camera",
|
||||
"placeholder": "Schakel de camera in om de preview te zien"
|
||||
},
|
||||
@@ -32,8 +30,6 @@
|
||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||
"disable": "Microfoon dempen",
|
||||
"enable": "Microfoon dempen opheffen",
|
||||
"turnedOff": "Microfoon uitgeschakeld",
|
||||
"turnedOn": "Microfoon ingeschakeld",
|
||||
"label": "Microfoon"
|
||||
},
|
||||
"audiooutput": {
|
||||
@@ -590,7 +586,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opties voor {{name}}",
|
||||
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
|
||||
"toolbarHint": "F2: naar de werkbalk onderaan.",
|
||||
"pin": {
|
||||
"enable": "Pinnen",
|
||||
"disable": "Losmaken"
|
||||
|
||||
@@ -11,8 +11,6 @@ import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
|
||||
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
|
||||
import { RecordingDownloadRoute } from '@/features/recording'
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
@@ -39,7 +37,7 @@ export const routes: Record<
|
||||
room: {
|
||||
name: 'room',
|
||||
to: (roomId: string) => `/${roomId.trim()}`,
|
||||
path: roomIdRegex,
|
||||
path: new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`),
|
||||
Component: RoomRoute,
|
||||
},
|
||||
feedback: {
|
||||
|
||||
@@ -151,37 +151,3 @@
|
||||
[data-lk-theme] .lk-participant-tile {
|
||||
box-shadow: var(--lk-box-shadow);
|
||||
}
|
||||
|
||||
/* Participant name ellipsis: truncate when overflowing */
|
||||
.lk-participant-metadata {
|
||||
width: 100%;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child .lk-participant-metadata-item,
|
||||
.lk-participant-metadata
|
||||
.lk-participant-metadata-item
|
||||
.lk-participant-name-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child .lk-participant-metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Shortcut hint: visible only on first grid tile when focused (CSS-based) */
|
||||
.shortcut-hint-wrapper {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.lk-grid-layout > *:first-child:focus-within .shortcut-hint-wrapper {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,6 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -141,10 +141,6 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -156,10 +156,6 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.16
|
||||
version: 0.0.15
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
{{- if .Values.ingressWebhook.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressWebhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressWebhook.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressWebhook.annotations "kubernetes.io/ingress.class" .Values.ingressWebhook.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-webhook
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressWebhook.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressWebhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressWebhook.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressWebhook.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- secretName: {{ .Values.ingressWebhook.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressWebhook.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- host: {{ .Values.ingressWebhook.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -50,31 +50,6 @@ ingress:
|
||||
## @param ingress.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
## @param ingressWebhook.enabled whether to enable the Ingress or not
|
||||
## @param ingressWebhook.className IngressClass to use for the Ingress
|
||||
## @param ingressWebhook.host Host for the Ingress
|
||||
## @param ingressWebhook.path Path to use for the Ingress
|
||||
ingressWebhook:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /api/v1.0/rooms/webhooks-livekit/
|
||||
## @param ingressWebhook.hosts Additional host to configure for the Ingress
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
## @param ingressWebhook.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressWebhook.tls.secretName Secret name for TLS config
|
||||
## @skip ingressWebhook.tls.additional
|
||||
## @extra ingressWebhook.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressWebhook.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
## @param ingressWebhook.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
|
||||
## @param ingressAdmin.enabled whether to enable the Ingress or not
|
||||
## @param ingressAdmin.className IngressClass to use for the Ingress
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.8.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.9.0"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -112,16 +112,19 @@ class MetadataManager:
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"start_time": time.time(),
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
}
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
initial_metadata = {
|
||||
**initial_metadata,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
"queuing_time": round(start_time - received_at, 2),
|
||||
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
|
||||
}
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
@@ -10,7 +10,9 @@ import openai
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from requests import exceptions
|
||||
from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
@@ -28,7 +30,6 @@ from summary.core.prompt import (
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.webhook_service import submit_content
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
@@ -55,17 +56,103 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
file_service = FileService(logger=logger)
|
||||
|
||||
|
||||
def transcribe_audio(task_id, filename, language):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
Downloads the audio from MinIO, sends it to WhisperX for transcription,
|
||||
and tracks metadata throughout the process.
|
||||
|
||||
Returns the transcription object, or None if the file could not be retrieved.
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
def post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task performs the following operations:
|
||||
1. Retrieves the audio file from MinIO storage
|
||||
2. Transcribes the audio using WhisperX model
|
||||
3. Sends the results via webhook
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
logger.info("Initiating WhisperX client")
|
||||
whisperx_client = openai.OpenAI(
|
||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||
@@ -75,7 +162,9 @@ def transcribe_audio(task_id, filename, language):
|
||||
|
||||
# Transcription
|
||||
try:
|
||||
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
|
||||
with (
|
||||
file_service.prepare_audio_file(filename) as (audio_file, metadata),
|
||||
):
|
||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||
|
||||
if language is None:
|
||||
@@ -106,32 +195,16 @@ def transcribe_audio(task_id, filename, language):
|
||||
|
||||
except FileServiceException:
|
||||
logger.exception("Unexpected error for filename: %s", filename)
|
||||
return None
|
||||
return
|
||||
|
||||
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||
return transcription
|
||||
|
||||
|
||||
def format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
):
|
||||
"""Format a transcription into readable content with a title.
|
||||
|
||||
Resolves the locale from context_language / language, then uses
|
||||
TranscriptFormatter to produce markdown content and a title.
|
||||
|
||||
Returns a (content, title) tuple.
|
||||
"""
|
||||
# For locale of context, use in decreasing priority context_language,
|
||||
# language (of meeting), default context language
|
||||
locale = get_locale(context_language, language)
|
||||
formatter = TranscriptFormatter(locale)
|
||||
|
||||
return formatter.format(
|
||||
content, title = formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_date=recording_date,
|
||||
@@ -139,90 +212,32 @@ def format_transcript(
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task orchestrates:
|
||||
1. Audio transcription via WhisperX
|
||||
2. Transcript formatting
|
||||
3. Webhook submission
|
||||
4. Conditional summarization queuing
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
"Webhook success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
transcription = transcribe_audio(task_id, filename, language)
|
||||
if transcription is None:
|
||||
return
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
)
|
||||
|
||||
submit_content(content, title, email, sub)
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# LLM Summarization
|
||||
@@ -291,11 +306,12 @@ def summarize_transcription(
|
||||
# a singleton client. This is a performance trade-off we accept to ensure per-user
|
||||
# privacy controls in observability traces.
|
||||
llm_observability = LLMObservability(
|
||||
logger=logger,
|
||||
user_has_tracing_consent=user_has_tracing_consent,
|
||||
session_id=self.request.id,
|
||||
user_id=owner_id,
|
||||
)
|
||||
llm_service = LLMService(llm_observability=llm_observability)
|
||||
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
|
||||
|
||||
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
|
||||
|
||||
@@ -338,9 +354,20 @@ def summarize_transcription(
|
||||
logger.info("Summary cleaned")
|
||||
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
summary_title = settings.summary_title_template.format(title=title)
|
||||
|
||||
submit_content(summary, summary_title, email, sub)
|
||||
data = {
|
||||
"title": settings.summary_title_template.format(title=title),
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
|
||||
llm_observability.flush()
|
||||
logger.debug("LLM observability flushed")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -16,9 +15,6 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileServiceException(Exception):
|
||||
"""Base exception for file service operations."""
|
||||
|
||||
@@ -28,8 +24,10 @@ class FileServiceException(Exception):
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, logger):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
self._logger = logger
|
||||
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
@@ -55,16 +53,16 @@ class FileService:
|
||||
The file is downloaded to a temporary location for local manipulation
|
||||
such as validation, conversion, or processing before being used.
|
||||
"""
|
||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
self._logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not remote_object_key:
|
||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
self._logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
raise ValueError("Invalid object_key")
|
||||
|
||||
extension = Path(remote_object_key).suffix.lower()
|
||||
|
||||
if extension not in self._allowed_extensions:
|
||||
logger.warning("Invalid file extension '%s'", extension)
|
||||
self._logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
@@ -83,8 +81,8 @@ class FileService:
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
self._logger.info("Recording successfully downloaded")
|
||||
self._logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
@@ -102,7 +100,7 @@ class FileService:
|
||||
file_metadata = mutagen.File(local_path).info
|
||||
duration = file_metadata.length
|
||||
|
||||
logger.info(
|
||||
self._logger.info(
|
||||
"Recording file duration: %.2f seconds",
|
||||
duration,
|
||||
)
|
||||
@@ -111,14 +109,14 @@ class FileService:
|
||||
error_msg = "Recording too long. Limit is %.2fs seconds" % (
|
||||
self._max_duration,
|
||||
)
|
||||
logger.error(error_msg)
|
||||
self._logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return duration
|
||||
|
||||
def _extract_audio_from_video(self, video_path: Path) -> Path:
|
||||
"""Extract audio from video file (e.g., MP4) and save as audio file."""
|
||||
logger.info("Extracting audio from video file: %s", video_path)
|
||||
self._logger.info("Extracting audio from video file: %s", video_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".m4a", delete=False, prefix="audio_extract_"
|
||||
@@ -142,16 +140,16 @@ class FileService:
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
|
||||
)
|
||||
|
||||
logger.info("Audio successfully extracted to: %s", output_path)
|
||||
self._logger.info("Audio successfully extracted to: %s", output_path)
|
||||
return output_path
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
self._logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
@@ -175,7 +173,7 @@ class FileService:
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in settings.recording_video_extensions:
|
||||
logger.info("Video file detected, extracting audio...")
|
||||
self._logger.info("Video file detected, extracting audio...")
|
||||
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
|
||||
processed_path = extracted_audio_path
|
||||
else:
|
||||
@@ -196,6 +194,8 @@ class FileService:
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
logger.debug("Temporary file removed: %s", path)
|
||||
self._logger.debug("Temporary file removed: %s", path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
self._logger.warning(
|
||||
"Failed to remove temporary file %s: %s", path, e
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""LLM service to encapsulate LLM's calls."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import openai
|
||||
@@ -11,9 +10,6 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMObservability:
|
||||
"""Manage observability and tracing for LLM calls using Langfuse.
|
||||
|
||||
@@ -25,11 +21,13 @@ class LLMObservability:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
user_has_tracing_consent: bool = False,
|
||||
):
|
||||
"""Initialize the LLMObservability client."""
|
||||
self._logger = logger
|
||||
self._observability_client: Optional[Langfuse] = None
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
@@ -77,7 +75,7 @@ class LLMObservability:
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
self._logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
return openai.OpenAI(**base_args)
|
||||
|
||||
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
|
||||
@@ -85,7 +83,7 @@ class LLMObservability:
|
||||
# is missing. Conditional import ensures Langfuse only initializes when enabled.
|
||||
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
|
||||
|
||||
logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
return langfuse_openai.OpenAI(**base_args)
|
||||
|
||||
def flush(self):
|
||||
@@ -101,10 +99,11 @@ class LLMException(Exception):
|
||||
class LLMService:
|
||||
"""Service for performing calls to the LLM configured in the settings."""
|
||||
|
||||
def __init__(self, llm_observability):
|
||||
def __init__(self, llm_observability, logger):
|
||||
"""Init the LLMService once."""
|
||||
self._client = llm_observability.get_openai_client()
|
||||
self._observability = llm_observability
|
||||
self._logger = logger
|
||||
|
||||
def call(
|
||||
self,
|
||||
@@ -141,5 +140,5 @@ class LLMService:
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
self._logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Service for delivering content to external destinations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
def _post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = _create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def submit_content(content, title, email, sub):
|
||||
"""Submit content to the configured webhook destination.
|
||||
|
||||
Builds the payload, sends it with retries, and logs the outcome.
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
logger.info(
|
||||
"Delivery success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
Reference in New Issue
Block a user