Compare commits

...

24 Commits

Author SHA1 Message Date
lebaudantoine d91f343ba9 🔖(minor) bump release to 0.1.28
telephony frontend + backend
source map
enhance analytics on the summary microservice
2025-07-10 22:25:07 +02:00
lebaudantoine 4fc0744433 🐛(summary) fix minor Python typing issue
Correct type annotation to resolve static type checking warnings.
2025-07-10 18:13:32 +02:00
lebaudantoine 93e3f05348 🔧(backend) make PostHog event names configurable for transcript tracking
Add setting to customize PostHog event names when tracking transcript
results for flexible analytics configuration.
2025-07-10 18:13:32 +02:00
lebaudantoine cbabcb877b 🐛(summary) add audio duration limit to prevent long-running jobs
Set default 1h30 limit for audio processing to prevent Whisper from
running excessively long on large recordings. Improves resource
management and job completion times.
2025-07-10 18:13:32 +02:00
lebaudantoine 30cd6573ef 🔧(backend) refine Celery retry policy to HTTP errors only
Only retry jobs on HTTP errors (docs push failures) rather than all
errors. Skip retries for Whisper processing failures as they won't
succeed on retry.
2025-07-10 18:13:32 +02:00
lebaudantoine 921d69031e 🔊(summary) increase logging level to info for Celery worker insights
Change logging levels to provide more visibility into Celery worker
operations and task processing for better monitoring.
2025-07-10 18:13:32 +02:00
lebaudantoine 1716e11900 🔧(summary) configure OpenAI max retries via FastAPI settings
Set default OpenAI retries to 0 and add configurable setting to control
retry behavior for API requests in summary service.
2025-07-10 18:13:32 +02:00
lebaudantoine 6e55013b15 📈(summary) kickstart analytics tracking in summary microservice
Add product analytics to understand summary feature usage and behavior.
Track transcript and task metadata for insights without exposing sensitive
content or speaker data.

Hacky but functional PostHog usage - fully optional for self-hosting.
Extensive tracking approach works for current needs despite not being
PostHog's typical use case.
2025-07-10 18:13:32 +02:00
lebaudantoine 21bed40484 (summary) add mutagen library for audio file duration detection
Install mutagen to analyze audio file metadata and determine recording
length for summary processing.
2025-07-10 18:13:32 +02:00
lebaudantoine 5959e82bf0 (summary) install PostHog in summary microservice
Add PostHog analytics integration to summary service for error
tracking and usage monitoring capabilities.
2025-07-10 18:13:32 +02:00
lebaudantoine b5113580b3 📸(frontend) inject PostHog sourcemap ID in chunks during build
Add PostHog CLI step to inject proper IDs into chunks, enabling error
tracking to map exceptions back to original source code locations
via sourcemaps.
2025-07-08 15:56:26 +02:00
lebaudantoine 053e4bc7b9 🔧(frontend) add conditional sourcemap generation for error tracking
Enable sourcemaps via env variable to link Sentry/PostHog exceptions to
source code. Enable by default for DINUM frontend image to improve
debugging capabilities.
2025-07-08 15:56:26 +02:00
lebaudantoine 9e57c25a25 🌐(frontend) fix wrong plural in footer
Accessibility should be singular.
2025-07-08 15:30:21 +02:00
lebaudantoine dfbcbed485 🐛(tilt) enable Keycloak in DINUM Tilt stack configuration
Fix missing Keycloak service in tilt-dinum stack. Error went unnoticed
when switching from tilt-keycloak due to pods not being cleaned between
stack changes.
2025-07-08 14:39:01 +02:00
lebaudantoine 9c28e1b266 📝(readme) document BrowserStack free license usage in README
Add note about using BrowserStack's free open-source license for
cross-browser testing to clarify testing infrastructure.
2025-07-07 19:45:43 +02:00
lebaudantoine 2a586445b5 📝(readme) update feature status in README with recent developments
Reflect current implementation status of features that have been completed
or are in progress to keep documentation accurate.
2025-07-07 19:45:43 +02:00
lebaudantoine 75ffb7f5f7 (frontend) add phone number and pin display in info side panel
Create minimal UI to show telephony access details for testing SIP
functionality in staging deployment. Basic implementation for feature
validation.
2025-07-07 19:21:39 +02:00
lebaudantoine 6a1c85809d (frontend) add libphonenumber-js for phone number formatting
Install JavaScript implementation of Google's libphonenumber for proper
international phone number validation and formatting.
2025-07-07 19:21:39 +02:00
lebaudantoine 70d250cc9c 🔧(frontend) add telephony feature configuration from backend
Pass telephony service availability and settings to frontend to enable
conditional UI rendering based on SIP functionality status.
2025-07-07 19:21:39 +02:00
lebaudantoine 988e5aa256 (backend) add telephony service for automatic SIP dispatch rules
Implemented a service that automatically creates a SIP dispatch rule when
the first WebRTC participant joins a room and removes it when the room
becomes empty.

Why? I don’t want a SIP participant to join an empty room.
The PIN code could be easily leaked, and there is currently no lobby
mechanism available for SIP participants.

A WebRTC participant is still required to create a room.
This behavior is inspired by a proprietary tool. The service uses LiveKit’s
webhook notification system to react to room lifecycle events. This is
a naive implementation that currently supports only a single SIP trunk and
will require refactoring to support multiple trunks. When no trunk is
specified, rules are created by default on a fallback trunk.

@rouja wrote a minimal Helm chart for LiveKit SIP with Asterisk, which
couldn’t be versioned yet due to embedded credentials. I deployed it
locally and successfully tested the integration with a remote
OVH SIP trunk.

One point to note: LiveKit lacks advanced filtering capabilities when
listing dispatch rules. Their recommendation is to fetch all rules and
filter them within your backend logic. I’ve opened a feature request asking
for at least the ability to filter dispatch rules by room, since filtering
by trunk is already supported, room-based filtering feels like a natural
addition.

Until there's an update, I prefer to keep the implementation simple.
It works well at our current scale, and can be refactored when higher load
or multi-trunk support becomes necessary.

While caching dispatch rule IDs could be a performance optimization,
I feel it would be premature and potentially error-prone due to the complexity
of invalidation. If performance becomes an issue, I’ll consider introducing
caching at that point. To handle the edge case where multiple dispatch rules
with different PIN codes are present, the service performs an extensive
cleanup during room creation to ensure SIP routing remains clean and
predictable. This edge case should not happen.

In the 'delete_dispatch_rule' if deleting one rule fails, method would exit
without deleting the other rules. It's okay IMO for a first iteration.
If multiple dispatch rules are often found for room, I would enhance this part.
2025-07-07 19:21:39 +02:00
lebaudantoine d3178eff5d (backend) serialize room pin code for frontend access
Add pin code to API response to enable frontend display of room access
codes. UI implementation will follow in upcoming commits.
2025-07-07 19:21:39 +02:00
lebaudantoine 9c840a4e06 🐛(frontend) revert || to ?? operator for metadata parsing
Fix broken parsing when metadata contains empty strings. Nullish coalescing
operator (??) doesn't handle empty strings like logical OR (||) does.
2025-07-07 19:21:39 +02:00
renovate[bot] e6754b49e0 ⬆️(dependencies) update js dependencies 2025-07-07 15:14:15 +02:00
lebaudantoine ec586eaab4 🔥(backend) remove outdated GitHub workflow
Clean up CI/CD by deleting obsolete workflow file that is no longer
needed or maintained since we deploy project through private repo.
2025-07-07 11:02:50 +02:00
40 changed files with 3094 additions and 2490 deletions
-35
View File
@@ -1,35 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+3 -2
View File
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording (coming soon)
- Meeting recording
- Meeting transcription (currently in beta)
- Telephony integration (in development)
- Telephony integration
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
@@ -111,6 +111,7 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
## License
+7 -1
View File
@@ -11,15 +11,21 @@ RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
# ---- Front-end builder image ----
FROM frontend-deps AS meet-builder
WORKDIR /home/frontend
ENV VITE_APP_TITLE="Visio"
ENV VITE_BUILD_SOURCEMAP="true"
RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
+7
View File
@@ -42,6 +42,13 @@ def get_frontend_configuration(request):
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+2 -2
View File
@@ -107,8 +107,8 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level"]
read_only_fields = ["id", "slug"]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def to_representation(self, instance):
"""
+54 -3
View File
@@ -2,12 +2,18 @@
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
@@ -77,6 +83,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -108,10 +115,54 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError("Failed to process room finished event") from e
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
+124
View File
@@ -0,0 +1,124 @@
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
SIPDispatchRule,
SIPDispatchRuleDirect,
)
from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
return f"SIP_{str(room_id)}"
@async_to_sync
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.id), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.id)
)
lkapi = utils.create_livekit_client()
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
async def _list_dispatch_rules_ids(self, room_id):
"""List SIP dispatch rule IDs for a specific room.
Fetches all existing SIP dispatch rules and filters them by room name
since LiveKit API doesn't support server-side filtering by 'room_name'.
This approach is acceptable for moderate scale but may need refactoring
for high-volume scenarios.
Note:
Feature request for server-side filtering: livekit/sip#405
"""
lkapi = utils.create_livekit_client()
try:
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
list=ListSIPDispatchRuleRequest()
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
if not existing_rules or not existing_rules.items:
return []
rule_name = self._rule_name(room_id)
return [
existing_rule.sip_dispatch_rule_id
for existing_rule in existing_rules.items
if existing_rule.name == rule_name
]
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
if not rules_ids:
logger.info("No dispatch rules found for room %s", room_id)
return False
if len(rules_ids) > 1:
logger.error("Multiple dispatch rules found for room %s", room_id)
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -251,6 +257,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -295,6 +302,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -324,6 +332,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -372,6 +381,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -458,6 +468,7 @@ def test_api_rooms_retrieve_administrators(
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -143,7 +143,7 @@ def test_handled_event_type(
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "room_started"})
event_data = json.dumps({"event": "participant_joined"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
@@ -8,6 +8,7 @@ from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
@@ -17,6 +18,7 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
@@ -56,42 +58,149 @@ def test_initialization(
@mock.patch.object(LobbyService, "clear_room_cache")
def test_handle_room_finished(mock_clear_cache, service):
"""Should clear lobby cache when room is finished."""
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
def test_handle_room_finished_error(mock_clear_cache, service):
"""Should raise ActionFailedError when processing fails."""
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
expected_error = (
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(
uuid.UUID("00000000-0000-0000-0000-000000000000")
)
def test_handle_room_finished_invalid_room_name(service):
"""Should raise ActionFailedError when processing fails."""
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
"delete_dispatch_rule",
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
expected_error = f"Room with ID {mock_data.room.name} does not exist"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
@@ -0,0 +1,305 @@
"""
Test telephony service.
"""
# pylint: disable=W0212
from unittest import mock
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
ListSIPDispatchRuleResponse,
SIPDispatchRule,
SIPDispatchRuleInfo,
)
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
def create_mock_livekit_client():
"""Factory for creating LiveKit client mock."""
mock_api = mock.Mock()
mock_api.sip = mock.Mock()
mock_api.aclose = mock.AsyncMock()
return mock_api
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-3",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
assert "rule-3" in result
assert "rule-2" not in result
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
assert isinstance(list_request, ListSIPDispatchRuleRequest)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
assert delete_request.sip_dispatch_rule_id == "rule-1"
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
deleted_rule_ids = [
call_args[1]["delete"].sip_dispatch_rule_id
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
]
assert all(
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
)
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
call_count = 0
def delete_side_effect(*args, **kwargs):
nonlocal call_count
if call_count == 0:
call_count += 1
return None
raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
+10
View File
@@ -609,6 +609,16 @@ class Base(Configuration):
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
ROOM_TELEPHONY_PHONE_NUMBER = values.Value(
None,
environ_name="ROOM_TELEPHONY_PHONE_NUMBER",
environ_prefix=None,
)
ROOM_TELEPHONY_DEFAULT_COUNTRY = values.Value(
"US",
environ_name="ROOM_TELEPHONY_DEFAULT_COUNTRY",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.27"
version = "0.1.28"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1448 -1355
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.27",
"version": "0.1.28",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,49 +13,50 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.9.12",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.53.6",
"@react-aria/toast": "3.0.2",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.76.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "25.1.2",
"i18next-browser-languagedetector": "8.1.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.13.8",
"posthog-js": "1.240.6",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"libphonenumber-js": "1.12.9",
"react": "18.3.1",
"react-aria-components": "1.8.0",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.0"
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "0.53.6",
"@tanstack/eslint-plugin-query": "5.74.7",
"@tanstack/react-query-devtools": "5.76.0",
"@types/node": "22.15.17",
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.3",
"prettier": "3.5.3",
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "6.2.7",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
}
+5
View File
@@ -30,6 +30,11 @@ export interface ApiConfig {
available_modes?: RecordingMode[]
expiration_days?: number
}
telephony: {
enabled: boolean
phone_number?: string
default_country?: string
}
manifest_link?: string
}
@@ -14,6 +14,7 @@ export type ApiRoom = {
id: string
name: string
slug: string
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
@@ -3,9 +3,11 @@ import { useEffect, useState } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { Button, Div, Text } from '@/primitives'
import { Bold, Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
@@ -22,6 +24,8 @@ export const Info = () => {
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
return (
<Div
display="flex"
@@ -41,9 +45,29 @@ export const Info = () => {
>
{t('roomInformation.title')}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
</Text>
<div
className={css({
gap: '0.15rem',
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
</Text>
{telephony?.enabled && data?.pin_code && (
<>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.call')}</Bold> (
{telephony?.country}) {telephony?.internationalPhoneNumber}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.pinCode')}</Bold>{' '}
{formatPinCode(data?.pin_code)}
</Text>
</>
)}
</div>
<Button
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
@@ -9,7 +9,7 @@ type useRaisedHandProps = {
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata ?? '{}')
const parsedMetadata = JSON.parse(metadata || '{}')
const toggleRaisedHand = () => {
if (isLocal(participant)) {
@@ -0,0 +1,20 @@
import { useConfig } from '@/api/useConfig.ts'
import { useMemo } from 'react'
import { parseConfigPhoneNumber } from '../../utils/telephony'
export const useTelephony = () => {
const { data } = useConfig()
const parsedPhoneNumber = useMemo(() => {
return parseConfigPhoneNumber(
data?.telephony?.phone_number,
data?.telephony?.default_country
)
}, [data?.telephony])
return {
enabled: data?.telephony?.enabled && parsedPhoneNumber,
country: parsedPhoneNumber?.country,
internationalPhoneNumber: parsedPhoneNumber?.formatInternational(),
}
}
@@ -0,0 +1,23 @@
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js'
export const parseConfigPhoneNumber = (
rawPhoneNumber?: string,
defaultCountry?: string
) => {
if (!rawPhoneNumber || !defaultCountry) {
return null
}
try {
return parsePhoneNumberWithError(
rawPhoneNumber,
defaultCountry as CountryCode
)
} catch (error) {
console.warn('Invalid phone number format:', rawPhoneNumber, error)
return null
}
}
export function formatPinCode(pinCode?: string) {
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')} #`
}
+4
View File
@@ -213,6 +213,10 @@
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
},
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
}
},
+4
View File
@@ -213,6 +213,10 @@
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
},
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
}
},
+1 -1
View File
@@ -34,7 +34,7 @@
"datagouv": "data.gouv.fr",
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilités : non conforme",
"accessibility": "Accessibilité : non conforme",
"ariaLabel": "nouvelle fenêtre",
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
"code": "dépôt de code Open Source",
+4
View File
@@ -213,6 +213,10 @@
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
},
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
}
},
+4
View File
@@ -213,6 +213,10 @@
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
},
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
}
},
+3
View File
@@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
plugins: [react(), tsconfigPaths()],
build: {
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
},
server: {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST ?? 'localhost',
@@ -173,6 +173,7 @@ summary:
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
@@ -206,6 +207,7 @@ celery:
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
+1 -1
View File
@@ -36,7 +36,7 @@ releases:
autoGenerated: true
- name: keycloak
installed: {{ eq .Environment.Name "dev-keycloak" | toYaml }}
installed: {{ or (eq .Environment.Name "dev-keycloak") (eq .Environment.Name "dev-dinum") | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/keycloak
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.27",
"version": "0.1.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.27",
"version": "0.1.28",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.27",
"version": "0.1.28",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
@@ -14,7 +14,7 @@
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "22.15.0"
"node": "22.17.0"
},
"repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM",
+5 -5
View File
@@ -14,16 +14,16 @@
"react-dom": "18.3.1"
},
"devDependencies": {
"@eslint/js": "9.26.0",
"@eslint/js": "9.30.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.4.1",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.1.0",
"globals": "16.3.0",
"typescript": "5.8.3",
"typescript-eslint": "8.32.0",
"vite": "6.3.5"
"typescript-eslint": "8.35.1",
"vite": "7.0.2"
}
}
+9 -9
View File
@@ -31,22 +31,22 @@
"react-dom": "18.3.1"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "5.74.7",
"@types/node": "22.15.17",
"@tanstack/eslint-plugin-query": "5.81.2",
"@types/node": "22.16.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react-swc": "3.9.0",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react-swc": "3.10.2",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"eslint-plugin-jsx-a11y": "6.10.2",
"sass": "1.88.0",
"prettier": "3.5.3",
"sass": "1.89.2",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "5.4.19",
"vite-plugin-dts": "4.5.3"
"vite": "7.0.2",
"vite-plugin-dts": "4.5.4"
}
}
+559 -1016
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.27",
"version": "0.1.28",
"author": "",
"license": "ISC",
"description": "",
+3 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.27"
version = "0.1.28"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -10,7 +10,9 @@ dependencies = [
"celery==5.5.3",
"redis==5.2.1",
"minio==7.2.15",
"mutagen==1.47.0",
"openai==1.91.0",
"posthog==6.0.3",
"requests==2.32.4",
"sentry-sdk[fastapi, celery]==2.30.0",
]
+2 -1
View File
@@ -1,5 +1,6 @@
"""API routes related to application tasks."""
import time
from typing import Optional
from celery.result import AsyncResult
@@ -33,7 +34,7 @@ async def create_task(request: TaskCreation):
)
else:
task = process_audio_transcribe_summarize_v2.delay(
request.filename, request.email, request.sub
request.filename, request.email, request.sub, time.time()
)
return {"id": task.id, "message": "Task created"}
+197
View File
@@ -0,0 +1,197 @@
"""Analytics classes."""
import json
import time
from collections import Counter
from functools import lru_cache
import redis
from celery.utils.log import get_task_logger
from posthog import Posthog
from summary.core.config import get_settings
logger = get_task_logger(__name__)
settings = get_settings()
class AnalyticsException(Exception):
"""Exception raised when analytics operations fail."""
pass
class Analytics:
"""Analytics client wrapper for PostHog integration."""
def __init__(self):
"""Initialize a client if settings are configure."""
self._client = None
if settings.posthog_api_key and settings.posthog_enabled:
logger.info("Initialize analytics client")
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
@property
def is_disabled(self):
"""Check if analytics client is disabled or not configured."""
return not self._client
def capture(self, event_name, distinct_id, properties=None):
"""Track an event if analytics is enabled."""
if self.is_disabled:
return
try:
self._client.capture(
event_name, distinct_id=distinct_id, properties=properties
)
except Exception as e:
raise AnalyticsException("Failed to capture analytics event") from e
@lru_cache
def get_analytics():
"""Init Analytics client once."""
return Analytics()
class TasksTracker:
"""Tracks task execution metadata and analytics for background tasks."""
def __init__(self):
"""Initialize the task tracker with analytics client."""
self._redis = redis.from_url(settings.task_tracker_redis_url)
self._key_prefix = settings.task_tracker_prefix
self._analytics = get_analytics()
self._is_disabled = self._analytics.is_disabled
def _get_redis_key(self, task_id):
"""Generate Redis key for task metadata."""
return f"{self._key_prefix}{task_id}"
def _save_metadata(self, task_id, metadata):
"""Wip."""
self._redis.hset(self._get_redis_key(task_id), mapping=metadata)
def _get_metadata(self, task_id):
"""Wip."""
raw_metadata = self._redis.hgetall(self._get_redis_key(task_id))
return {k.decode("utf-8"): v.decode("utf-8") for k, v in raw_metadata.items()}
def has_task_id(self, task_id):
"""Check if task_id exists in tasks metadata cache."""
return self._redis.exists(self._get_redis_key(task_id))
def create(self, task_id, task_args):
"""Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id):
return
initial_metadata = {
"start_time": time.time(),
"asr_model": settings.openai_asr_model,
"retries": 0,
}
_required_args_count = 4
if len(task_args) != _required_args_count:
logger.error("Invalid number of arguments.")
return
filename, email, _, received_at = task_args
initial_metadata = {
**initial_metadata,
"filename": filename,
"email": email,
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
}
self._save_metadata(task_id, initial_metadata)
def retry(self, task_id):
"""Increment retry counter for a task."""
if self._is_disabled or not self.has_task_id(task_id):
return
metadata = self._get_metadata(task_id)
if "retries" in metadata:
metadata["retries"] = int(metadata["retries"]) + 1
else:
metadata["retries"] = 1
self._save_metadata(task_id, metadata)
def clear(self, task_id):
"""Remove task metadata from cache."""
if self._is_disabled or not self.has_task_id(task_id):
return
self._redis.delete(self._get_redis_key(task_id))
def track(self, task_id, data):
"""Update task metadata with additional data."""
if self._is_disabled or not self.has_task_id(task_id):
return
metadata = self._get_metadata(task_id)
self._save_metadata(task_id, {**metadata, **data})
def track_transcription_metadata(self, task_id, transcription):
"""Extract and track metadata from transcription results."""
if self._is_disabled or not self.has_task_id(task_id):
return
if not transcription or not transcription.segments:
self.track(task_id, {"transcription_empty": "true", "number_speakers": 0})
return
speakers = [
segment.get("speaker", "UNKNOWN_SPEAKER")
for segment in transcription.segments
]
speaker_counts = Counter(speakers)
segments_count = len(transcription.segments)
speaker_percentages = {
speaker: round((count / segments_count) * 100, 2)
for speaker, count in speaker_counts.items()
}
text_length = 0
for segment in transcription.segments:
text_length += len(segment.get("text", ""))
self.track(
task_id,
{
"transcription_empty": "false",
"speakers_count": len(set(speakers)),
"segments_count": segments_count,
"speakers_distribution": json.dumps(speaker_percentages),
"text_length": text_length,
},
)
def capture(self, task_id, event_name):
"""Capture analytics event with task metadata and clean up."""
if self._is_disabled or not self.has_task_id(task_id):
return
metadata = self._get_metadata(task_id)
if "start_time" in metadata:
metadata["execution_time"] = str(round(
time.time() - float(metadata["start_time"]), 2
))
del metadata["start_time"]
metadata["task_id"] = task_id
self.clear(task_id)
try:
self._analytics.capture(event_name, metadata.get("email"), metadata)
except AnalyticsException:
logger.exception("Failed to capture analytics event")
+81 -12
View File
@@ -3,6 +3,7 @@
import json
import os
import tempfile
import time
from pathlib import Path
import openai
@@ -10,14 +11,19 @@ import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
from minio import Minio
from requests import Session
from mutagen import File
from requests import Session, exceptions
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.analytics import TasksTracker, get_analytics
from summary.core.config import get_settings
from summary.core.prompt import get_instructions
settings = get_settings()
analytics = get_analytics()
tasks_tracker = TasksTracker()
logger = get_task_logger(__name__)
@@ -58,6 +64,12 @@ Quelques points que nous vous conseillons de vérifier :
"""
class AudioValidationError(Exception):
"""Custom exception for audio validation errors."""
pass
def save_audio_stream(audio_stream, chunk_size=32 * 1024):
"""Save an audio stream to a temporary OGG file."""
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
@@ -120,6 +132,25 @@ def post_with_retries(url, data):
session.close()
@signals.task_prerun.connect
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
task_args = args or []
tasks_tracker.create(task_id, task_args)
@signals.task_retry.connect
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
tasks_tracker.retry(request.id)
@signals.task_failure.connect
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
tasks_tracker.capture(task_id, settings.posthog_event_failure)
@celery.task(max_retries=settings.celery_max_retries)
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
"""Process an audio file by transcribing it and generating a summary.
@@ -149,18 +180,20 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
temp_file_path = save_audio_stream(audio_file_stream)
logger.debug("Recording successfully downloaded, filepath: %s", temp_file_path)
logger.debug("Initiating OpenAI client")
logger.info("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key, base_url=settings.openai_base_url
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
max_retries=settings.openai_max_retries,
)
try:
logger.debug("Querying transcription …")
logger.info("Querying transcription …")
with open(temp_file_path, "rb") as audio_file:
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
transcription = transcription.text
logger.debug("Transcription: \n %s", transcription)
@@ -194,8 +227,14 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
logger.debug("Response body: %s", response.text)
@celery.task(max_retries=settings.celery_max_retries)
def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
)
def process_audio_transcribe_summarize_v2(
self, filename: str, email: str, sub: str, received_at: float
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task performs the following operations:
@@ -207,6 +246,8 @@ def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
logger.info("Notification received")
logger.debug("filename: %s", filename)
task_id = self.request.id
minio_client = Minio(
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
@@ -221,20 +262,44 @@ def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
)
temp_file_path = save_audio_stream(audio_file_stream)
logger.debug("Recording successfully downloaded, filepath: %s", temp_file_path)
logger.debug("Initiating OpenAI client")
logger.info("Recording successfully downloaded")
logger.debug("Recording filepath: %s", temp_file_path)
audio_file = File(temp_file_path)
tasks_tracker.track(task_id, {"audio_length": audio_file.info.length})
if audio_file.info.length > settings.recording_max_duration:
error_msg = "Recording too long: %.2fs > %.2fs limit" % (
audio_file.info.length,
settings.recording_max_duration,
)
logger.error(error_msg)
raise AudioValidationError(error_msg)
logger.info("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key, base_url=settings.openai_base_url
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
max_retries=settings.openai_max_retries,
)
try:
logger.debug("Querying transcription …")
logger.info("Querying transcription …")
transcription_start_time = time.time()
with open(temp_file_path, "rb") as audio_file:
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
tasks_tracker.track(
task_id,
{
"transcription_time": round(
time.time() - transcription_start_time, 2
)
},
)
logger.info("Transcription received.")
logger.debug("Transcription: \n %s", transcription)
finally:
if os.path.exists(temp_file_path):
@@ -247,6 +312,8 @@ def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
else format_segments(transcription)
)
tasks_tracker.track_transcription_metadata(task_id, transcription)
data = {
"title": "Transcription",
"content": formatted_transcription,
@@ -262,4 +329,6 @@ def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
tasks_tracker.capture(task_id, settings.posthog_event_success)
# TODO - integrate summarize the transcript and create a new document.
+17 -2
View File
@@ -1,7 +1,7 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated, Optional
from typing import Annotated, Optional, List
from fastapi import Depends
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
app_api_v1_str: str = "/api/v1"
app_api_token: str
# Audio recordings
recording_max_duration: int = 5400 # 1h30
# Celery settings
celery_broker_url: str = "redis://redis/0"
celery_result_backend: str = "redis://redis/0"
@@ -33,10 +36,11 @@ class Settings(BaseSettings):
openai_base_url: str = "https://api.openai.com/v1"
openai_asr_model: str = "whisper-1"
openai_llm_model: str = "gpt-4o"
openai_max_retries: int = 0
# Webhook-related settings
webhook_max_retries: int = 2
webhook_status_forcelist: list[int] = [502, 503, 504]
webhook_status_forcelist: List[int] = [502, 503, 504]
webhook_backoff_factor: float = 0.1
webhook_api_token: str
webhook_url: str
@@ -45,6 +49,17 @@ class Settings(BaseSettings):
sentry_is_enabled: bool = False
sentry_dsn: Optional[str] = None
# Posthog (analytics)
posthog_enabled: bool = False
posthog_api_key: Optional[str] = None
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
posthog_event_failure: str = "transcript-failure"
posthog_event_success: str = "transcript-success"
# TaskTracker
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
@lru_cache
def get_settings():