Compare commits

...

106 Commits

Author SHA1 Message Date
Martin Guitteny 7e1fff5c8e ♻️(devexp) refactor minio webhook setup
Instead of relying on make commands to set-up the minio webhook,
use a compose service, as we did for the createbucket one.

Aligned with the dev stack, and run by default when starting
for the first time the stack.
2025-10-07 21:09:04 +02:00
lebaudantoine 9cb9998384 ⬆️(frontend) manually upgrade Alpine dependencies to fix libexpat vul
Manually update libexpat to 2.7.2-r0 in Alpine 3.21.3 base image
to address CVE-2025-59375 high-severity vulnerability until newer
Alpine base image becomes available, ensuring Trivy security scans pass.
2025-09-30 15:14:51 +02:00
lebaudantoine a3ca6f0113 📈(frontend) track more room events in PostHog for disconnections
Add additional room event tracking to PostHog analytics to better
understand and diagnose disconnection error patterns. Enhanced
telemetry will provide insights for improving connection stability.
2025-09-18 23:47:13 +02:00
lebaudantoine 1d9caeb17f 🐛(helm) fix broken worker assignment due to extra space
Remove incorrect whitespace in queue names that prevented Celery
workers from listening to proper queues. Workers were attempting to
connect to non-existent queues, breaking task distribution.
2025-09-18 18:27:10 +02:00
lebaudantoine 5caed6222b 🐛(summary) fix transcribe job queue assignment
Ensure transcribe jobs are properly assigned to their specific queue
instead of using default queue. This prevents job routing issues and
ensures proper task distribution across workers.
2025-09-18 18:27:10 +02:00
lebaudantoine 46fdbc0430 (helm) configure MinIO webhook with Kubernetes job for recordings
Implement automated MinIO webhook configuration using Kubernetes job
to enable recording feature functionality. This eliminates manual
setup requirements and ensures consistent webhook configuration
across deployments.
2025-09-18 18:27:10 +02:00
lebaudantoine 534f3b2d47 🐛(helm) fix MinIO webhook certificate after Tilt stack changes
Restore certificate mounting for MinIO webhook communication to
backend after migrating away from unmaintained Bitnami chart.
Mount certificate in proper volume to enable secure bucket-to-backend
webhook delivery.
2025-09-18 18:27:10 +02:00
lebaudantoine ebf7a1956e 🔧(helm) configure Celery workers for summary microservice in Helm
Add Celery summarize and transcribe worker configuration to Helm
charts for summary microservice. Create new deployment resources
and increment chart version to support distributed task processing.
2025-09-18 01:44:16 +02:00
lebaudantoine c2e6927978 📝(summary) add minimal README for dev experience
Basic README with developer setup info. Will be expanded
with more details in future commits.
2025-09-18 00:56:00 +02:00
lebaudantoine 1b4a144650 🔧(summary) add settings to disable summary feature entirely
Introduce FastAPI settings configuration option to completely disable
the summary feature. This improves developer experience by allowing
developers to skip summary-related setup when not needed for their
workflow.
2025-09-18 00:56:00 +02:00
lebaudantoine 7004b7e2c8 🔧(summary) introduce watch section to Docker Compose file
Add watch configuration to Docker Compose file enabling compose watch
mode for Docker Compose 2.22+. This enhances developer experience on
Visio by providing automatic file synchronization and hot reloading
during development on the celery workers.
2025-09-18 00:56:00 +02:00
Martin Guitteny 848893a79f 🐛(backend) fix Docker Compose stack for recording features
The recording feature and call to the summary service wasn't working
in the docker compose stack. It was a pain for new developper joining
the project to understand every piece of the stack.

Resolve storage webhook trigger issues by configuring proper environment
variables, settings, and MinIO setup to enhance developer experience
and eliminate manual configuration requirements.

Add new Makefile command to configure MinIO webhook via CLI since
webhook configuration cannot be declared as code. Update summary
microservice to reflect secure access false setting for MinIO bucket
consistency with Tilt stack configuration.
2025-09-18 00:56:00 +02:00
lebaudantoine 849f8ac08c (summary) introduce summary logic for meeting transcripts
Implement summarization functionality that processes completed meeting
transcripts to generate concise summaries.

First draft base on a simple recursive agentic scenario.
Observability and evaluation will be added in the next PRs.
2025-09-18 00:56:00 +02:00
lebaudantoine 9fd264ae0e 🔧(summary) specify dedicated transcription queue for Celery worker
Name the Celery queue used by transcription worker to prepare for
dedicated summarization queue separation, enabling faster transcript
delivery while isolating new agentic logic in separate worker processes.
2025-09-18 00:56:00 +02:00
lebaudantoine bfdf5548a0 🔧(backend) rename OpenAI settings to WhisperX to avoid confusion
Rename incorrectly named OpenAI configuration settings since
they're used to instantiate WhisperX client which is not OpenAI
compatible, preventing confusion about actual service dependencies.
2025-09-18 00:56:00 +02:00
lebaudantoine 0102b428f1 📦️(summary) vendor existing logic for agentic system transition
Vendoring dead code before introducing new agent-based
summarization architecture to maintain clean code.
2025-09-18 00:56:00 +02:00
lebaudantoine 91a8d85db3 🔧(summary) add PostHog configuration example to summary env file
Include PostHog analytics configuration example in the summary
environment file with default disabled state. This provides developers
with clear setup guidance while maintaining privacy-first defaults.
2025-09-18 00:56:00 +02:00
lebaudantoine e301c5deed (summary) wrap PostHog feature flag checks in analytics client
Encapsulate PostHog SDK feature flag functionality within analytics
client.
2025-09-18 00:56:00 +02:00
lebaudantoine 67b046c9ba ♻️(summary) integrate summary Docker compose into global dev tooling
Consolidate summary service into main development stack to centralize
development environment management and simplify service orchestration
with shared infrastructure like MinIO storage.
2025-09-18 00:56:00 +02:00
lebaudantoine 1b3b9ff858 (summary) add development stage to summary Docker image for hot reload
Introduce new Docker stage enabling hot reload during active API
development to eliminate rebuild cycles and improve developer workflow
efficiency.
2025-09-18 00:56:00 +02:00
lebaudantoine 64fca531fa 🔖(minor) bump release to 0.1.38
- bump LiveKit dependencies
- fix some regressions link to permissions
2025-09-18 00:43:47 +02:00
lebaudantoine e73b0777e3 📱(frontend) fix permission modal width on mobile screens
Adjust permission modal dimensions to properly fit mobile viewports
and prevent poor responsive user experience. Ensures modal content
remains accessible and readable across different screen sizes.
2025-09-18 00:35:50 +02:00
lebaudantoine 0489033e03 🚑️(frontend) fix mobile permission deadlock with disabled tracks
Resolve issue where users with disabled track preferences in local
storage wouldn't receive permission prompts in subsequent sessions,
causing app deadlock. Toggle tracks when permissions are disabled to
re-trigger permission requests.

This is a hotfix addressing critical user feedback. Permission handling
requires further testing and improvements based on gathered user
reports since release.
2025-09-18 00:35:50 +02:00
lebaudantoine 04710f5ecd 🐛(frontend) fix mic mute for non-admin users in participant list
Resolve regression where non-admin/anonymous users couldn't mute
their microphone from participant list after mute permissions refactoring.
Replace API call with local track mute for better performance and
proper permission handling.
2025-09-18 00:35:50 +02:00
lebaudantoine 4afa03d7c8 ⬆️(frontend) bump livekit-track-processor to 0.6.1
Update livekit-track-processor dependency from previous version to
0.6.1 to incorporate latest bug fixes and feature improvements.
2025-09-18 00:35:50 +02:00
lebaudantoine e57685ebe3 ⬆️(frontend) bump livekit-client to 2.15.7
Update livekit-client dependency from previous version to 2.15.7 to
incorporate latest bug fixes and feature improvements.
2025-09-18 00:35:50 +02:00
lebaudantoine 381b7c4eb7 ️(frontend) add missing aria-label to screenshare button
Add accessibility label to screenshare control button to ensure screen
readers can properly announce the button's function to users with
visual impairments.
2025-09-16 14:52:40 +02:00
lebaudantoine e0fe78e3fa 🔖(minor) bump release to 0.1.37
- revert dynacast / simulcast change
- fix safari audio output selector
2025-09-15 23:32:43 +02:00
lebaudantoine 2a85f45e69 🐛(frontend) prevent displaying audio output selector to Safari users
Hide audio output selector component for Safari browsers due to lack
of native support for audio output device selection APIs. This
prevents user confusion and improves browser compatibility.
2025-09-15 18:39:48 +02:00
lebaudantoine 8aa035ae00 ️(frontend) revert dynacast and adaptive streaming checks
Revert recent changes to dynacast and adaptive streaming functionality
to isolate potential causes of regression issues. Changes will be
reintroduced in future commits with improved error handling and
thorough investigation of root causes.
2025-09-15 15:54:47 +02:00
lebaudantoine d39d02d445 🔖(minor) bump release to 0.1.36 2025-09-10 22:05:39 +02:00
lebaudantoine e28e0024be ⬆️(backend) bump Django to 5.2.6 to fix severe security issue
Upgrade Django from previous version to 5.2.6 to address critical
security vulnerabilities:
https://www.djangoproject.com/weblog/2025/sep/03/security-releases/
2025-09-10 21:32:09 +02:00
lebaudantoine c492243ab1 🐛(frontend) fix controlbar mobile responsiveness after refactor
Restore proper controlbar layout and spacing on mobile screens that broke
during recent audio control component refactoring, ensuring consistent
user interface across all device sizes.
2025-09-10 21:32:09 +02:00
lebaudantoine 38e6adf811 ⬇️(frontend) downgrade livekit-js-sdk to troubleshoot production issues
Temporarily roll back LiveKit client SDK version to investigate and
resolve production stability problems that emerged after recent upgrade,
enabling system restoration while root cause analysis is performed.
2025-09-10 21:32:09 +02:00
lebaudantoine 88dbfae925 🔖(minor) bump release to 0.1.35
- fix crisp regression
2025-09-09 23:30:54 +02:00
Martin Guitteny bf7e17921d 📝(docs) fix path developping locally
Fix the path of the app in the doc to improve developer experience
2025-09-09 15:21:52 +02:00
lebaudantoine 3393858552 ⬆️(livekit) upgrade local LiveKit server to version 1.9.0
Update development environment LiveKit server from previous version
to 1.9.0 for latest features and bug fixes.

Ensures development environment stays current
with LiveKit production version.
2025-09-09 15:20:51 +02:00
lebaudantoine e7c12b4253 🐛(frontend) fix CSS rules that hide Crisp support button
Correct CSS specificity issues that were overriding and hiding the Crisp
chatbot button, ensuring support functionality remains accessible to
users.
2025-09-09 10:38:02 +02:00
lebaudantoine 45a69aaaf0 🚨(backend) fix Helmfile compatibility issue
Update Helmfile to resolve compatibility errors that occur
when running the tilt stack with latest helmfile version.
2025-09-09 10:04:58 +02:00
lebaudantoine 9565e7a5d2 🔖(minor) bump release to 0.1.34
- refactor pre-join screen
- handle permission errors
- add advanced admin controls over meeting
- display raised hand order
- add video settings
- add quick access video / microphone settings
- fix crisp regression
2025-09-08 22:27:12 +02:00
lebaudantoine a02cd26044 💄(frontend) replace switch text with icons for font-size independence
Replace text elements in switch components with proper icons to eliminate
dependency on font size variations and ensure consistent visual
appearance.

Improves component reliability across different font scaling scenarios
and provides clearer visual indicators that remain legible regardless of
user font preferences or system scaling settings.
2025-09-08 21:53:54 +02:00
lebaudantoine 34353b5cba 💄(frontend) narrow lobby column width on prejoin screen
Reduce lobby column width on prejoin screen for better visual balance
and improved layout proportions.
2025-09-08 21:53:54 +02:00
lebaudantoine b34a1c8695 🐛(frontend) fix favicon issues with comprehensive icon set for Safari
Add extensive list of favicon formats and sizes to resolve display
issues specifically affecting Safari browser, ensuring proper icon rendering
across all devices and platforms.
2025-09-08 21:53:54 +02:00
lebaudantoine 7c16852a56 (frontend) add quick action to open transcription menu
Implement quick access button to open the transcription menu directly,
providing faster navigation to transcript controls and settings.
2025-09-08 21:53:54 +02:00
lebaudantoine bcdc93f405 (frontend) add quick action to open recording menu
Implement quick access button to open the recording menu directly,
providing faster navigation to recording controls and settings.
2025-09-08 21:53:54 +02:00
lebaudantoine fa588ee147 🚸(frontend) display recording duration limit in recording panel
Add information to recording panel indicating maximum recording
duration when time limits are configured to prevent user confusion
and help with session planning.
2025-09-08 21:53:54 +02:00
lebaudantoine 95e4b77b7e 🐛(frontend) fix Crisp chatbot CSS override covering room actions
Fix regression where Crisp chatbot CSS changes caused their button to
cover useful room actions due to failing CSS override rules.

Updates CSS specificity and positioning to ensure Crisp chat button
doesn't interfere with room functionality while maintaining chatbot
accessibility for user support.
2025-09-08 17:22:09 +02:00
lebaudantoine 8044e3d6d8 ♻️(backend) replace Django permissions with feature flag decorator
Refactor feature flag mechanism from Django permission classes to custom
decorator that returns 404 Not Found when features are disabled instead
of exposing API structure through permission errors.

Improves security by preventing information disclosure about disabled
features and provides more appropriate response semantics. Custom
decorator approach is better suited for feature toggling than Django's
permission system which is designed for authorization.
2025-09-08 17:17:45 +02:00
lebaudantoine 58722cab00 🔧(backend) set explicit user subscription permissions for other tracks
Configure LiveKit token to explicitly allow users to subscribe to other
participants' video and audio tracks instead of relying on default
permissions.
2025-09-08 17:16:52 +02:00
Quentin BEY 5f75b085ec 🔧(ci) always run all git-lint steps
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.

thanks @lunika
2025-09-05 14:49:25 +02:00
lebaudantoine afd7139d39 (frontend) increase chat message limit to 2000 characters
Expand text area input character limit from default to 2000 characters
to allow users more space for detailed chat messages.

Provides better user experience for longer form communication within
chat interface while maintaining reasonable message size constraints.
2025-09-05 14:28:51 +02:00
lebaudantoine 67d61b8cf7 ♻️(frontend) decouple PaginationControl from usePagination hook
Replace ReturnType<typeof usePagination> prop type with explicit Props
interface defining only required fields (currentPage, totalPages,
onPageChange) to decouple component from internal hook implementation.

Eliminates unnecessary runtime dependency and improves component API
clarity by explicitly declaring required props instead of coupling to
hook internals, making the component more maintainable and reusable.
2025-09-05 13:57:46 +02:00
lebaudantoine 47cc88c820 🚸(frontend) disable pagination controls at first and last pages
Disable previous/next pagination controls when users are on the first or
last page respectively to prevent offering non-functional interactions.

Improves user experience by clearly indicating when navigation options
are unavailable and preventing confusion from inactive controls that
appear interactive.
2025-09-05 13:57:46 +02:00
lebaudantoine 0bf7755855 ️(frontend) mark pagination indicators as decorative for screen readers
Hide pagination dot indicators from screen readers using aria-hidden
to prevent noisy output when dots are purely visual decorations.

Improves accessibility by removing redundant announcements
while maintaining visual pagination cues for sighted users.
2025-09-05 13:57:46 +02:00
lebaudantoine 4e75a17916 🌐(frontend) internationalize pagination controls and add aria labels
Add internationalization support to pagination controls and include
aria labels for improved accessibility and screen reader support
across different languages.
2025-09-05 13:57:46 +02:00
lebaudantoine 73a9fb3a72 📦️(frontend) vendor GridLayout components
Vendor LiveKit layout components to internationalize them
2025-09-05 13:57:46 +02:00
lebaudantoine e86dc12bf9 📦️(frontend) vendor CarouselLayout components
Vendor LiveKit layout components to start enhancing them.
2025-09-05 13:57:46 +02:00
lebaudantoine ef2b0b64bb 🔖(helm) release new Helm chart 0.0.11 with subtitle features
Release updated Helm chart including subtitle functionality and LiveKit
agent framework integration for transcription services.
2025-09-04 19:34:39 +02:00
lebaudantoine 1f71bfc5d2 🎨(backend) use pylint error names instead of codes in disable comments
Replace pylint error codes with descriptive error names in disable comments
to make suppressed warnings explicit and improve code readability.
2025-09-04 11:26:48 +02:00
lebaudantoine 888fbbcd5f 🎨(backend) use object primary key instead of id attribute
Replace id attribute references with object primary key for better code
consistency and Django model conventions.

requested by @qbey
2025-09-04 11:26:48 +02:00
lebaudantoine 9b6b32c3d5 🐛(frontend) fix nested React components in participant list
Refactor participant list to avoid nested React component definitions
that lose state on parent re-renders and cause unnecessary recreation.

Moves component definitions outside render methods to prevent state loss
bugs and improve performance by eliminating redundant component
instantiation on each parent render cycle.
2025-09-04 11:26:48 +02:00
lebaudantoine 00a3c0a37c 🏷️(frontend) make toast props readonly for enhanced type safety
Refactor toast component props to use readonly TypeScript modifiers,
preventing accidental mutations and strengthening type safety.
2025-09-04 11:26:48 +02:00
lebaudantoine ff9487eb2f ️(frontend) add missing aria attribute to lower hand button
Add required aria accessibility attribute to the lower hand button for
proper screen reader support and compliance.
2025-09-04 11:26:48 +02:00
lebaudantoine 242e7cb148 🚸(frontend) add notifications when admin removes participant permissions
Send notification to participants when admin revokes their camera,
microphone, or screenshare permissions so they understand why their
media suddenly stopped.

Improves user experience by providing clear feedback about permission
changes instead of leaving users confused about unexpected media
interruptions during meetings.
2025-09-04 11:26:48 +02:00
lebaudantoine 86a04ed718 🐛(frontend) prevent effects access when camera permissions denied
Block effects functionality when users lack camera publishing
permissions since they cannot open their camera and shouldn't be able
to start effects.
2025-09-04 11:26:48 +02:00
lebaudantoine 3d3242e148 🚸(frontend) disable device controls when user lacks room permissions
Update user experience by clearly marking device toggle and control
components as disabled when users have insufficient room permissions.

Prevents confusion by providing visual feedback that device controls are
unavailable, improving clarity about what  actions users can perform
in their current role.
2025-09-04 11:26:48 +02:00
lebaudantoine 7d1f15ef91 🐛(frontend) optimize mute-all to skip participants without mic tracks
Improve mute-all functionality by filtering out participants who are
already muted or lack microphone publishing permissions, and ignore
cases where microphone tracks are unavailable.

Prevents unnecessary mute operations on participants who cannot be
muted, prevent API errors.
2025-09-04 11:26:48 +02:00
lebaudantoine 00b58288cf 🐛(frontend) fix LiveKit mic indicator for admin-muted participants
Correct microphone muted indicator logic that only showed muted state
when participants explicitly muted themselves, missing cases where
admins prevented microphone publishing.

Now properly displays muted indicator for both self-muted and
admin-restricted microphone states, providing accurate visual feedback
for all muting scenarios.
2025-09-04 11:26:48 +02:00
lebaudantoine aeb5c3790c ♻️(frontend) refactor permission updates to preserve admin privileges
Enhance participant permission update logic to only affect users
without room privileges, ensuring admins can maintain their microphone
and camera access during bulk permission changes.

Prevents accidental disruption of admin functionality when applying
permission restrictions to regular participants, maintaining proper
role-based access control hierarchy.
2025-09-04 11:26:48 +02:00
lebaudantoine 3e69a2380f ♻️(frontend) sync publishing sources with Django backend settings
Replace hardcoded default publishing source constants with values from
Django backend settings to prevent desynchronization between frontend
and backend configurations.
2025-09-04 11:26:48 +02:00
lebaudantoine 57f63bf891 💄(frontend) add visual pin indicator for pinned participant tracks
Introduce pin icon to visually notify users when a participant has
their track pinned in the interface.

Provides clear visual feedback for pin status, helping users understand
which participants are currently highlighted or prioritized in the
meeting view.
2025-09-04 11:26:48 +02:00
lebaudantoine da86b30455 (frontend) add participant menu with pin/unpin and admin remove actions
Introduce participant menu in participant list to enable more
participant actions and interactions beyond current capabilities.

Initialize menu with universal pin/unpin track action available to all
users, plus admin-restricted participant removal action. Completes
admin action set by enabling room ejection functionality.

Menu designed for reuse when called from participant tile focus
components, providing consistent interaction patterns across different
contexts where participant management is needed.
2025-09-04 11:26:48 +02:00
lebaudantoine 6a76041a70 (frontend) add "mute all participants" feature for admins
Implement admin capability to mute everyone's microphone in large rooms
where participants forget to mute themselves and are hard to identify
quickly.

Feature requested by @arnaud-robin. Provides instant room-wide muting
without individual confirmation popups, enabling efficient moderation
in busy conference scenarios.
2025-09-04 11:26:48 +02:00
lebaudantoine 26bd947541 🔥(frontend) remove dead code
Clean up unused commented code elements that are no longer
referenced or needed in the codebase.
2025-09-04 11:26:48 +02:00
lebaudantoine a85a62eb1a 🔒️(frontend) hide admin actions from unprivileged users in UI
Update interface to hide admin-only actions like participant muting from
users without room admin privileges, reflecting backend permission
restrictions implemented in previous commits.
2025-09-04 11:26:48 +02:00
lebaudantoine 4793f2fa8f (frontend) display host status to meeting participants
Add host identification display for participants using boolean flag from
LiveKit token attributes. Currently passes simple boolean but will be
refactored to distinguish owner/admin/member roles and host/co-host
with different privileges.

Security note: attributes are not fully secure as participants can
update their own metadata, potentially faking admin status. However,
consequences are limited to user confusion without destructive
capabilities. Metadata updates currently needed for name changes and
hand raising functionality.

Plan to remove canUpdateOwnMetadata permission to strengthen security
while preserving essential user interaction capabilities.
2025-09-04 11:26:48 +02:00
lebaudantoine 1268346405 ♻️(backend) replace LiveKit token metadata with attributes
Switch from metadata to attributes when generating LiveKit tokens for
more convenient dict-like structure handling during token creation and
client-side reading.

Attributes provide better data structure flexibility compared to
metadata, simplifying both server-side token generation and client-side
data access patterns.
2025-09-04 11:26:48 +02:00
lebaudantoine f28da45b4c (frontend) add admin controls for real-time source publication limits
Introduce new admin panel actions allowing room owners to restrict
participant source publication (video/audio/screenshare) with immediate
real-time updates across all participants.

Provides granular room-wide media control for admins to manage
bandwidth, focus attention, or handle disruptive situations by
selectively enabling or disabling specific media types instantly.
2025-09-04 11:26:48 +02:00
lebaudantoine 740355d494 (frontend) add Switch Field component with custom layout handling
Introduce new Field variant using Switch input with different props
structure from other input components.

Displays description after switch component rather than mixed with
label due to layout requirements, preventing reuse of standard label
and description composition patterns.
2025-09-04 11:26:48 +02:00
lebaudantoine 34fde10854 (frontend) add client-side API call for participant removal
Implement frontend functionality to call backend endpoints for removing
participants from rooms through proper server-mediated requests.
2025-09-04 11:26:48 +02:00
lebaudantoine 84c820bc60 (frontend) add client-side API call for participant permission updates
Implement frontend functionality to call backend endpoints for updating
specific participant permissions through proper server-mediated
requests.
2025-09-04 11:26:48 +02:00
lebaudantoine 1539613bf8 ♻️(frontend) replace direct LiveKit calls with backend API endpoints
Refactor frontend to use backend-mediated API calls instead of direct
LiveKit client-side requests for participant management operations.

Removes hacky direct LiveKit API usage in favor of proper server-side
endpoints, improving security posture and following LiveKit's
recommended architecture for participant control functionality.
2025-09-04 11:26:48 +02:00
lebaudantoine 5f70840398 ♻️(backend) move LiveKit participant management to server-side API
Refactor client-side LiveKit API calls to server-side endpoints
following LiveKit documentation recommendations for participant
management operations.

Replaces hacky direct client calls with proper backend-mediated
requests, improving security and following official LiveKit
2025-09-04 11:26:48 +02:00
lebaudantoine 84e62246b7 (backend) add lobby cache clearing method for room and participant
Introduce new method on lobby system to clear lobby cache for specific
room and participant combinations.

Enables targeted cleanup of lobby state when participants leave or are
removed, improving cache management and preventing stale lobby entries.
2025-09-04 11:26:48 +02:00
lebaudantoine 6c633b1ecb ♻️(backend) sync lobby and LiveKit participant UUID generation
Refactor lobby system to use consistent UUID v4 across lobby
registration and LiveKit token participant identity instead of
generating separate UUIDs.

Maintains synchronized identifiers between lobby cache and LiveKit
participants, simplifying future participant removal operations by
using the same UUID reference across both systems.
2025-09-04 11:26:48 +02:00
lebaudantoine 0f76517957 💩(backend) pass room config and user role data to LiveKit token utility
Extend LiveKit token creation utility with additional room configuration
and user role parameters to properly adapt room_admin grants and
publish sources based on permission levels.

This creates technical debt in utility function design that should be
refactored into proper service architecture for token
generation operations in future iterations.
2025-09-04 11:26:48 +02:00
lebaudantoine fd7a78e80e ♻️(backend) factorize validation-only serializers to reduce duplication
Eliminates code duplication across validation serializers, improving
maintainability and ensuring consistent validation behavior throughout
the API layer.
2025-09-04 11:26:48 +02:00
lebaudantoine 206babb20f 🔧(backend) extract LiveKit publishing sources to Django settings
Move hardcoded LiveKit publishing sources from backend code to
configurable Django settings for better reusability and self-hosting
flexibility.

Enables self-hosters to customize LiveKit token generation sources
without code modifications, improving deployment configurability.
2025-09-04 11:26:48 +02:00
lebaudantoine 081c860e04 🚨(helm) fix whitespace in Kubernetes template directive braces
Add required whitespace between braces in template directives to
comply with Kubernetes rule S6893.

Improves template readability and follows Kubernetes best practices for
template formatting and maintainability.
2025-09-03 18:09:00 +02:00
lebaudantoine 83192f69e3 🔧(summary) align ruff Python target with Docker image version
Sync ruff's target Python version to match Docker image version
used for summary component to ensure runtime consistency.

Prevents syntax/feature mismatches, catches version-specific issues
before deployment, and ensures linting targets the actual runtime
environment for better deployment safety.
2025-09-03 18:09:00 +02:00
lebaudantoine 2ceb94a966 👷(summary) add CI job to lint Python summary sources
Implement CI job to lint summary Python sources and enforce merging
only linted code to maintain code quality standards.
2025-09-03 18:09:00 +02:00
lebaudantoine 3c13e287e6 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.
2025-09-03 18:09:00 +02:00
lebaudantoine eee17b6b58 👷(agents) add CI job to lint Python agent sources
Implement CI job to lint agent Python sources and enforce merging only
linted code to maintain code quality standards.
2025-09-03 18:09:00 +02:00
lebaudantoine 185d5c2c60 👷(agents) add meet-agents image build and push to CI docker hub
Implement CI build and push workflow for meet-agents Docker image,
following the same pattern established by the summary image.

Extends CI pipeline to include meet-agents image distribution through
dockerhub for consistent deployment infrastructure.
2025-09-03 18:09:00 +02:00
lebaudantoine 31ac4cd767 🐛(summary) fix hot reloading in tilt stack for summary image/pod
Remove default unprivileged Docker user that was incompatible with hot
reloading in tilt stack. Update tilt config to resolve path issues.

CI builds still use unprivileged user, making this change safe while
enabling proper development workflow with hot reloading functionality.
2025-09-03 18:09:00 +02:00
lebaudantoine a6aa77cb97 🔧(all) update numerique.gouv.fr references to new repo location
Replace outdated numerique.gouv.fr repository references with current
repository location for accurate documentation and links.

Maintenance cleanup unrelated to current PR but necessary to keep
references up-to-date. Better addressed now than deferred.
2025-09-03 18:09:00 +02:00
lebaudantoine 42b9a34c7a 🔥(backend) remove useless imports from backend code
Clean up unused imports in backend modules as minor maintenance work
not related to current PR.
2025-09-03 18:09:00 +02:00
lebaudantoine 9ff4b23ea7 (frontend) add subtitle control with transcription display
Kickstart frontend with first draft of subtitle control visible only
to users with appropriate feature flag enabled.

Opens new container at bottom of screen displaying transcription
segments organized by participant. Transcription segment handling was
heavily LLM-generated and will likely need refactoring and review to
simplify and enhance the implementation.

Initial implementation to begin testing subtitle functionality with
real transcription data from LiveKit agents.
2025-09-03 18:09:00 +02:00
lebaudantoine f48dd5cea1 (backend) add start-subtitle endpoint
Allow any user, anonymous or authenticated, to start subtitling
in a room only if they are an active participant of it.

Subtitling a room consists of starting the multi-user transcriber agent.
This agent forwards all participants' audio to an STT server and returns
transcription segments for any active voice to the room.

User roles in the backend room system cannot be used
to determine subtitle permissions.

The transcriber agent can be triggered multiple times but will only join a
room once. Unicity is managed by the agent itself.
Any user with a valid LiveKit token can initiate subtitles. Feature flag
logic is implemented on the frontend. The frontend ensures the "start
subtitle" action is only available to users who should see it. The backend
does not enforce feature flags in this version.

Authentication in our system does not imply access to a room. The only
valid proof of access is the LiveKit API token issued by the backend.
Security consideration: A LiveKit API token is valid for 6 hours and
cannot be revoked at the end of a meeting. It is important to verify
that the token was issued for the correct room.

Calls to the agent dispatch endpoint must be server-initiated. The backend
proxies these calls, as clients cannot securely contact the agent dispatch
endpoint directly (per LiveKit documentation).

Room ID is passed as a query parameter. There is currently no validation
ensuring that the room exists prior to agent dispatch.
TODO: implement validation or error handling for non-existent rooms.

The backend does not forward LiveKit tokens to the agent. Default API
rate limiting is applied to prevent abuse.
2025-09-03 18:09:00 +02:00
lebaudantoine 49ee46438b 🧱(backend) add Helm chart for LiveKit agent deployment
Create basic Helm chart for LiveKit agent framework deployment on
Kubernetes, inspired by meet-summary FastAPI server configuration.

Integrate chart into local tilt development stack and properly handle
certificate issues that typically occur when calling LiveKit server
with nip.io domain names.
2025-09-03 18:09:00 +02:00
lebaudantoine ea2e5e8609 (agents) initialize LiveKit agent from multi-user transcriber example
Create Python script based on LiveKit's multi-user transcriber example
with enhanced request_fnc handler that ensures job uniqueness by room.

A transcriber sends segments to every participant present in a room and
transcribes every participant's audio. We don't need several
transcribers in the same room. Made the worker hidden - by default it
uses auto dispatch and is visible as any other participant, but having
a transcriber participant would be weird since no other videoconference
tool treats this feature as a bot participant joining a call.

Job uniqueness is ensured using agent identity by forging a
deterministic identity for each transcriber by room. This makes sure
two transcribers would never be able to join the same room. It might be
a bit harsh, but our API calling to list participants before accepting
a new transcription job should already filter out situations where an
agent is triggered twice.

We chose explicit worker orchestration over auto-dispatch because we
want to keep control of this feature which will be challenging to
scale. LiveKit agent scaling is documented but we need to experiment in
real life situations with their Worker/Job mechanism.

Currently uses Deepgram since Arnaud's draft Kyutai plugin isn't ready
for production. This allows our ops team to advance on deploying and
monitoring agents. Deepgram was a random choice offering 200 hours
free, though it only works for English. ASR provider needs to be
refactored as a pluggable system selectable through environment
variables or settings.

Agent dispatch will be triggered via a new REST API endpoint to our
backend. This is quite a first naive version of a minimal dockerized
LiveKit agent to start playing with the framework.
2025-09-03 18:09:00 +02:00
lebaudantoine 6ca73c665b ️(frontend) use initial processor reference to avoid track recreation
Leverage reference to initial processor choice to prevent unnecessary
preview track recreation when updating processor options.

Improves performance by maintaining existing track instance during
processor updates instead of creating new tracks, eliminating visual
interruptions and reducing resource overhead.
2025-09-02 16:59:18 +02:00
lebaudantoine cfa68d0eb4 🐛(frontend) fix modal overflow when stopping processors in join screen
Fixes visual glitch where processor termination caused height increase
beyond modal boundaries, ensuring consistent modal dimensions
regardless of processor state changes.
2025-09-02 16:59:18 +02:00
lebaudantoine fac0c53123 ♻️(frontend) refactor processor wrapper to unified class architecture
Replace multiple processor wrappers with single unified class that
enables seamless transformer switching and option updates without
visual blinking artifacts.

Leverages LiveKit track processor v0.6.0 updateTransformerOptions fix
to provide smooth transitions between transformer types, eliminating
the recreation-based approach that caused flickering during effects
switching.
2025-09-02 16:59:18 +02:00
lebaudantoine a8f1ee0530 ♻️(frontend) simplify processor factory for transformer unification
Streamline processor factory logic to prepare for unified transformer
class refactoring.

Reduces complexity and establishes foundation for consolidated
transformer approach.
2025-09-02 16:59:18 +02:00
lebaudantoine 651d299068 ⬆️(frontend) upgrade LiveKit track processor to v0.6.0
Update LiveKit track processor to version 0.6.0 which includes fix for
updateTransformerOptions allowing seamless switching between transformer
types without visual artifacts.

Eliminates weird flickering behavior when users select different
transformer types by enabling proper transformer transitions instead of
recreation, improving user experience during effects switching.
2025-09-02 16:59:18 +02:00
193 changed files with 5315 additions and 1025 deletions
+48 -4
View File
@@ -31,7 +31,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -64,7 +67,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -98,7 +104,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -132,7 +141,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
@@ -145,12 +157,44 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
+44
View File
@@ -20,14 +20,18 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
build-mails:
@@ -92,6 +96,46 @@ jobs:
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
test-back:
runs-on: ubuntu-latest
needs: build-mails
+11 -1
View File
@@ -71,7 +71,8 @@ create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
env.d/development/kc_postgresql \
env.d/development/summary
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -116,9 +117,15 @@ run-backend: ## start only the backend application and all needed services
@$(WAIT_DB)
.PHONY: run-backend
run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-transcribe
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -246,6 +253,9 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
# -- Internationalization
env.d/development/crowdin:
+14 -2
View File
@@ -58,14 +58,26 @@ docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.', '../../docker', '../../.dockerignore'],
only=['.'],
target = 'production',
live_update=[
sync('../src/summary', '/home/summary'),
sync('../src/summary', '/app'),
]
)
clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
]
)
clean_old_images('localhost:5001/meet-agents')
# Copy the mkcert root CA certificate to our Docker build context
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
+77
View File
@@ -46,6 +46,21 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;"
app-dev:
build:
context: .
@@ -72,6 +87,7 @@ services:
- nginx
- livekit
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
@@ -221,3 +237,64 @@ services:
- ./docker/livekit/out:/out
depends_on:
- redis
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/summary
ports:
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
celery-summary-summarize:
container_name: celery-summary-summarize
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
+1 -1
View File
@@ -42,7 +42,7 @@ COPY ./docker/dinum-frontend/fonts/ \
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
USER nginx
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.8.0
FROM livekit/livekit-server:v1.9.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+1 -1
View File
@@ -142,4 +142,4 @@ Once the Kubernetes cluster is ready, start the application stack locally:
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
+5
View File
@@ -53,6 +53,11 @@ LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
+23
View File
@@ -0,0 +1,23 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.13-slim AS base
FROM base AS builder
WORKDIR /builder
COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS production
WORKDIR /app
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
+189
View File
@@ -0,0 +1,189 @@
"""Multi user transcription agent."""
import asyncio
import logging
import os
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import deepgram, silero
load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
)
self.participant_identity = participant_identity
class MultiUserTranscriber:
"""Manage transcription sessions for multiple room participants."""
def __init__(self, ctx: JobContext):
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
async def aclose(self):
"""Close all sessions and cleanup resources."""
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()]
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
if participant.identity in self._sessions:
return
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task))
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
)
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
await sess.drain()
await sess.aclose()
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
for participant in ctx.room.remote_participants.values():
transcriber.on_participant_connected(participant)
async def cleanup():
await transcriber.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_transcriber_job_request(job_req: JobRequest) -> None:
"""Accept job if no transcriber exists in room, otherwise reject."""
room_name = job_req.room.name
transcriber_id = f"{TRANSCRIBER_AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lkapi:
try:
response = await lkapi.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
transcriber_exists = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == transcriber_id
for p in response.participants
)
if transcriber_exists:
logger.info(f"Transcriber exists in {room_name} - rejecting")
await job_req.reject()
else:
logger.info(f"Accepting job for {room_name}")
await job_req.accept(identity=transcriber_id)
except Exception:
logger.exception(f"Error processing job for {room_name}")
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
request_fnc=handle_transcriber_job_request,
prewarm_fnc=prewarm,
agent_name=TRANSCRIBER_AGENT_NAME,
permissions=WorkerPermissions(hidden=True),
)
)
+51
View File
@@ -0,0 +1,51 @@
[project]
name = "agents"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
[tool.ruff.lint]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle error
"F", # Pyflakes
"I", # Isort
"ISC", # flake8-implicit-str-concat
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLR", # Pylint Refactor
"PLW", # Pylint Warning
"RUF100", # Ruff unused-noqa
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle warning
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
+2
View File
@@ -50,10 +50,12 @@ def get_frontend_configuration(request):
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+45
View File
@@ -0,0 +1,45 @@
"""Feature flag handler for the Meet core app."""
from functools import wraps
from django.conf import settings
from django.http import Http404
class FeatureFlag:
"""Check if features are enabled and return error responses."""
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
}
@classmethod
def flag_is_active(cls, flag_name):
"""Check if a feature flag is active."""
setting_name = cls.FLAGS.get(flag_name)
if setting_name is None:
return False
return getattr(settings, setting_name, False)
@classmethod
def require(cls, flag_name):
"""Decorator to check feature at the beginning of endpoint methods."""
if flag_name not in cls.FLAGS:
raise ValueError(f"Unknown feature flag: {flag_name}")
def decorator(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
if not cls.flag_is_active(flag_name):
raise Http404
return view_func(self, request, *args, **kwargs)
return wrapper
return decorator
+6 -19
View File
@@ -1,7 +1,5 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -101,21 +99,10 @@ class HasPrivilegesOnRoom(IsAuthenticated):
return obj.is_administrator_or_owner(request.user)
class IsRecordingEnabled(permissions.BasePermission):
"""Check if the recording feature is enabled."""
class HasLiveKitRoomAccess(permissions.BasePermission):
"""Check if authenticated user's LiveKit token is for the specific room."""
message = "Access denied, recording is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_ENABLE
class IsStorageEventEnabled(permissions.BasePermission):
"""Check if the storage event feature is enabled."""
message = "Access denied, storage event is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
def has_object_permission(self, request, view, obj):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
+90 -53
View File
@@ -1,9 +1,10 @@
"""Client serializers for the Meet core app."""
import uuid
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -134,6 +135,8 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
@@ -150,7 +153,11 @@ class RoomSerializer(serializers.ModelSerializer):
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id, user=request.user, username=username
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
output["is_administrable"] = is_admin_or_owner
@@ -179,7 +186,19 @@ class RecordingSerializer(serializers.ModelSerializer):
read_only_fields = fields
class StartRecordingSerializer(serializers.Serializer):
class BaseValidationOnlySerializer(serializers.Serializer):
"""Base serializer for validation-only operations."""
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
mode = serializers.ChoiceField(
@@ -192,75 +211,93 @@ class StartRecordingSerializer(serializers.Serializer):
},
)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(serializers.Serializer):
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
"""Validate participant entry decision data."""
participant_id = serializers.CharField(required=True)
participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True)
def validate_participant_id(self, value):
"""Validate that the participant_id is a valid UUID hex string."""
try:
uuid.UUID(hex=value, version=4)
except (ValueError, TypeError) as e:
raise serializers.ValidationError("Invalid UUID hex format") from e
return value
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
class CreationCallbackSerializer(BaseValidationOnlySerializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
)
class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant muting data."""
track_sid = serializers.CharField(
max_length=255, help_text="LiveKit track SID to mute"
)
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
metadata = serializers.DictField(
required=False, allow_null=True, help_text="Participant metadata as JSON object"
)
attributes = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
required=False,
allow_blank=True,
allow_null=True,
help_text="Display name for the participant",
)
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
has_update = any(
field in attrs and attrs[field] is not None and attrs[field] != ""
for field in update_fields
)
if not has_update:
raise serializers.ValidationError(
f"At least one of the following fields must be provided: "
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
+141 -4
View File
@@ -50,9 +50,16 @@ from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -287,9 +294,9 @@ class RoomViewSet(
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Start recording a room."""
@@ -332,9 +339,9 @@ class RoomViewSet(
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
@@ -417,7 +424,8 @@ class RoomViewSet(
try:
lobby_service.handle_participant_entry(
room_id=room.id,
**serializer.validated_data,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
)
return drf_response.Response({"message": "Participant was updated."})
@@ -530,6 +538,135 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-subtitle",
permission_classes=[
permissions.HasLiveKitRoomAccess,
],
authentication_classes=[LiveKitTokenAuthentication],
)
@FeatureFlag.require("subtitle")
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
"""Start realtime transcription for the room.
Requires valid LiveKit token for room authorization.
Anonymous users can start subtitles if they have room access tokens.
"""
room = self.get_object()
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
return drf_response.Response(
{"error": f"Subtitles failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
room = self.get_object()
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant",
url_name="update-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def update_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Update participant attributes, permissions, or metadata."""
room = self.get_object()
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="remove-participant",
url_name="remove-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def remove_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Remove a participant from the room."""
room = self.get_object()
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().remove(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -596,8 +733,8 @@ class RecordingViewSet(
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
permission_classes=[permissions.IsStorageEventEnabled],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
@@ -0,0 +1,42 @@
"""Authentication using LiveKit token for the Meet core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from livekit.api import TokenVerifier
from rest_framework import authentication, exceptions
UserModel = get_user_model()
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
token = request.data.get("token")
if not token:
return None # No authentication attempted
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
claims = verifier.verify(token)
user_id = claims.identity
if not user_id:
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
return (user, claims)
except Exception as e:
raise exceptions.AuthenticationFailed(
f"Invalid LiveKit token: {str(e)}"
) from e
@@ -4,7 +4,6 @@ import logging
import secrets
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
+1 -1
View File
@@ -1,6 +1,6 @@
"""LiveKit Events Service"""
# pylint: disable=E1101
# pylint: disable=no-member
import uuid
from enum import Enum
+17 -3
View File
@@ -89,7 +89,7 @@ class LobbyService:
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
@@ -143,6 +143,8 @@ class LobbyService:
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
if self.can_bypass_lobby(room=room, user=request.user):
if participant is None:
participant = LobbyParticipant(
@@ -155,10 +157,13 @@ class LobbyService:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=str(room.id),
room_id=room_id,
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -173,10 +178,13 @@ class LobbyService:
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=str(room.id),
room_id=room_id,
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -334,3 +342,9 @@ class LobbyService:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
@@ -0,0 +1,128 @@
"""Participants management service for LiveKit rooms."""
# pylint: disable=too-many-arguments,no-name-in-module,too-many-positional-arguments
# ruff: noqa:PLR0913
import json
import uuid
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
MuteRoomTrackRequest,
RoomParticipantIdentity,
TwirpError,
UpdateParticipantRequest,
)
from core import utils
from .lobby import LobbyService
logger = getLogger(__name__)
class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantsManagement:
"""Service for managing participants."""
@async_to_sync
async def mute(self, room_name: str, identity: str, track_sid: str):
"""Mute a specific audio or video track for a participant in a room."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.mute_published_track(
MuteRoomTrackRequest(
room=room_name,
identity=identity,
track_sid=track_sid,
muted=True,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not mute participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
room_id=uuid.UUID(room_name), participant_id=identity
)
except (ValueError, TypeError) as exc:
logger.warning(
"participants_management.remove: room_name '%s' is not a UUID; "
"skipping lobby cache clear",
room_name,
exc_info=exc,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.room.remove_participant(
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not remove participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def update(
self,
room_name: str,
identity: str,
metadata: Optional[Dict] = None,
attributes: Optional[Dict] = None,
permission: Optional[Dict] = None,
name: Optional[str] = None,
):
"""Update participant properties such as metadata, attributes, permissions, or name."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_participant(
UpdateParticipantRequest(
room=room_name,
identity=identity,
metadata=json.dumps(metadata),
permission=permission,
attributes=attributes,
name=name,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not update participant") from e
finally:
await lkapi.aclose()
+47
View File
@@ -0,0 +1,47 @@
"""Service for managing subtitle agents in LiveKit rooms."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
from core import utils
logger = getLogger(__name__)
class SubtitleException(Exception):
"""Exception raised when subtitle operations fail."""
class SubtitleService:
"""Service for managing subtitle agents in LiveKit rooms."""
@async_to_sync
async def start_subtitle(self, room):
"""Start subtitle agent for the specified room."""
lkapi = utils.create_livekit_client()
try:
# Transcriber agent prevents duplicate subtitle agents per room
# No error is raised if agent already exists
await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.ROOM_SUBTITLE_AGENT_NAME, room=str(room.id)
)
)
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise SubtitleException("Failed to create subtitle agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_subtitle(self, room) -> None:
"""Stop subtitle agent for the specified room."""
raise NotImplementedError("Subtitle agent stopping not yet implemented")
+2 -2
View File
@@ -38,12 +38,12 @@ class TelephonyService:
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.id), pin=str(room.pin_code)
room_name=str(room.pk), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.id)
rule=direct_rule, name=self._rule_name(room.pk)
)
lkapi = utils.create_livekit_client()
@@ -2,7 +2,7 @@
Test event authentication.
"""
# pylint: disable=E1128
# pylint: disable=assignment-from-no-return
from django.test import RequestFactory
@@ -2,7 +2,7 @@
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
import datetime
import smtplib
@@ -2,7 +2,7 @@
Test event parsers.
"""
# pylint: disable=W0212,W0621,W0613
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest import mock
@@ -2,7 +2,7 @@
Test RecordingEventsService service.
"""
# pylint: disable=W0621
# pylint: disable=redefined-outer-name
from unittest import mock
@@ -2,7 +2,7 @@
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
@@ -77,7 +77,8 @@ def test_save_recording_permission_needed(settings, client):
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=W0212,W0621,W0613
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -1,6 +1,6 @@
"""Test WorkerServiceMediator class."""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
from unittest.mock import Mock
@@ -2,7 +2,7 @@
Test worker service classes.
"""
# pylint: disable=W0212,W0621,W0613,E1101
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
from unittest.mock import AsyncMock, Mock, patch
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
import json
import random
@@ -132,9 +132,9 @@ def test_request_entry_with_existing_participants(settings):
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -259,7 +259,7 @@ def test_request_entry_authenticated_user_public_room(settings):
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162fe7d1421b90e702bfbfbf8def",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
@@ -276,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
@@ -302,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings):
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -312,7 +312,7 @@ def test_request_entry_waiting_participant_public_room(settings):
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
with (
mock.patch.object(utils, "notify_participants", return_value=None),
@@ -330,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def"
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -381,7 +381,7 @@ def test_allow_participant_to_enter_anonymous():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 401
@@ -396,7 +396,7 @@ def test_allow_participant_to_enter_non_owner():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 403
@@ -414,7 +414,7 @@ def test_allow_participant_to_enter_public_room():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -437,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def",
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"status": "waiting",
"username": "foo",
"color": "123",
@@ -449,7 +449,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{
"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def",
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"allow_entry": allow_entry,
},
)
@@ -458,7 +458,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
assert participant_data.get("status") == updated_status
@@ -476,13 +476,13 @@ def test_allow_participant_to_enter_participant_not_found(settings):
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def"
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True},
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
@@ -572,9 +572,9 @@ def test_list_waiting_participants_success(settings):
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def",
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -597,7 +597,7 @@ def test_list_waiting_participants_success(settings):
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162fe7d1421b90e702bfbfbf8def",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -0,0 +1,417 @@
"""
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.urls import reverse
import pytest
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
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()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-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.mute_published_track.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_mute_participant_invalid_payload():
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
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()),
"metadata": {"role": "presenter"},
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
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()
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_participant_invalid_payload():
"""Test update participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
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 "Must be a valid UUID." in str(response.data)
def test_update_participant_no_update_fields():
"""Test update participant with no update fields provided."""
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())
# No metadata, attributes, permission, or name
}
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 "At least one of the following fields must be provided" in str(response.data)
def test_update_participant_invalid_permission():
"""Test update participant with wrong permission object."""
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": {"invalid-attributes": True},
}
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 "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
"""Test update participant with wrong metadata or attributes provided."""
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()),
"metadata": "wrong string",
"attributes": "wrong string",
}
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 "metadata" in response.data or "attributes" in response.data
def test_update_participant_unexpected_twirp_error(mock_livekit_client):
"""Test update participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant"}
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_success_lobby_cache(mock_livekit_client):
"""Test successful participant removal.
The lobby cache cleanup is crucial for security - without it, removed
participants could potentially re-enter the room using their cached
lobby session.
"""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
payload = {"participant_identity": participant_identity}
url = reverse("rooms-remove-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.remove_participant.assert_called_once()
# called twice: once for Lobby, once for ParticipantManagement
mock_livekit_client.aclose.assert_called()
# Verify lobby cache was cleared - participant should no longer exist
participant = LobbyService()._get_participant(room.id, participant_identity)
assert participant is None
def test_remove_participant_success(mock_livekit_client):
"""Test successful participant removal."""
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())}
url = reverse("rooms-remove-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.remove_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_forbidden_without_access():
"""Test remove participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_remove_participant_invalid_payload():
"""Test remove participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_missing_identity():
"""Test remove participant with missing participant_identity."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {} # Missing participant_identity
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
"""Test remove participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to remove participant"}
mock_livekit_client.aclose.assert_called_once()
@@ -235,7 +235,10 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
which they are not related, provided the room is public.
They should not see related users.
"""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["mock-source"]},
)
user = UserFactory()
client = APIClient()
@@ -262,7 +265,13 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
}
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -307,7 +316,13 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
}
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
participant_id=None,
)
@@ -353,7 +368,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["mock-source"]},
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -386,7 +403,13 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
}
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -473,5 +496,11 @@ def test_api_rooms_retrieve_administrators(
}
mock_token.assert_called_once_with(
room=expected_name, user=user, username=None, color=None
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
participant_id=None,
)
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
@@ -55,8 +55,9 @@ def test_start_recording_anonymous():
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator():
def test_start_recording_non_owner_and_non_administrator(settings):
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -88,8 +89,8 @@ def test_start_recording_recording_disabled(settings):
{"mode": "screen_recording"},
)
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert Recording.objects.count() == 0
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: stop recording.
"""
# pylint: disable=W0621,W0613
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
@@ -54,8 +54,9 @@ def test_stop_recording_anonymous():
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_non_owner_and_non_administrator():
def test_stop_recording_non_owner_and_non_administrator(settings):
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
@@ -84,8 +85,8 @@ def test_stop_recording_recording_disabled(settings):
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
# Verify no recording exists
assert Recording.objects.count() == 0
@@ -0,0 +1,221 @@
"""
Test rooms API endpoints in the Meet core app: start subtitle.
"""
# pylint: disable=W0621
import uuid
from unittest import mock
from django.conf import settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_room_id() -> str:
"""Mock room's id."""
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
@pytest.fixture
def mock_livekit_token(mock_room_id):
"""Mock LiveKit JWT token."""
video_grants = VideoGrants(
room=mock_room_id,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(str(uuid.uuid4()))
)
return token.to_jwt()
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_missing_token_anonymous(settings):
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_missing_token_authenticated(settings):
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_invalid_token():
"""Test that malformed or invalid LiveKit tokens are rejected."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_start_subtitle_valid_token(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test successful subtitle initiation with valid token and enabled feature."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "multi-user-transcriber"
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
def test_start_subtitle_twirp_error(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test handling of LiveKit service errors during subtitle initiation."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 500
assert response.json() == {
"error": f"Subtitles failed to start for room {room.slug}"
}
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
"""Test that tokens are validated against the correct room ID."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
@@ -2,7 +2,7 @@
Test LiveKit webhook endpoint on the rooms API.
"""
# pylint: disable=R0913,W0621,R0917,W0613
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
import base64
import hashlib
import json
+43 -3
View File
@@ -144,10 +144,9 @@ def test_get_or_create_participant_id_from_cookie(lobby_service):
assert participant_id == "existing-id"
@mock.patch("uuid.uuid4")
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
mock_uuid4.return_value = mock.Mock(hex="generated-id")
request = mock.Mock()
request.COOKIES = {}
@@ -266,6 +265,9 @@ def test_request_entry_public_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -302,6 +304,9 @@ def test_request_entry_trusted_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -394,6 +399,9 @@ def test_request_entry_accepted_participant(
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -442,7 +450,7 @@ def test_enter_success(
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.id), notification_data={"type": "participantWaiting"}
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
@@ -831,3 +839,35 @@ def test_clear_room_empty(settings, lobby_service):
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_participant_cache(lobby_service):
"""Test clearing a specific participant entry from cache."""
room_id = uuid.uuid4()
participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
assert cache.get(cache_key) is not None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_clear_participant_cache_nonexistent(lobby_service):
"""Test clearing a participant that doesn't exist in cache."""
room_id = uuid.uuid4()
participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
@@ -0,0 +1,48 @@
"""
Test subtitle service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.subtitle import SubtitleService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_settings(mock_livekit_client, settings):
"""Test that start_subtitle uses the configured agent name from Django settings."""
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
room = RoomFactory(name="my room")
SubtitleService().start_subtitle(room)
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "fake-subtitle-agent-name"
assert call_args.room == str(room.id)
def test_stop_subtitle_not_implemented():
"""Test that stop_subtitle raises NotImplementedError."""
room = RoomFactory(name="my room")
with pytest.raises(
NotImplementedError, match="Subtitle agent stopping not yet implemented"
):
SubtitleService().stop_subtitle(room)
+54 -14
View File
@@ -2,12 +2,13 @@
Utils functions used in the core app
"""
# ruff: noqa:S311
# pylint: disable=R0913, R0917
# ruff: noqa:S311, PLR0913
import hashlib
import json
import random
from typing import Optional
from typing import List, Optional
from uuid import uuid4
from django.conf import settings
@@ -49,7 +50,13 @@ def generate_color(identity: str) -> str:
def generate_token(
room: str, user, username: Optional[str] = None, color: Optional[str] = None
room: str,
user,
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -60,25 +67,34 @@ def generate_token(
If none, a default value will be used.
color (Optional[str]): The color to be displayed in the room.
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
str: The LiveKit JWT access token.
"""
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
)
if user.is_anonymous:
identity = str(uuid4())
identity = participant_id or str(uuid4())
default_username = "Anonymous"
else:
identity = str(user.sub)
@@ -95,14 +111,22 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_metadata(json.dumps({"color": color}))
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
return token.to_jwt()
def generate_livekit_config(
room_id: str, user, username: str, color: Optional[str] = None
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -110,15 +134,31 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
dict: LiveKit configuration with URL, room and access token
"""
sources = None
if configuration is not None:
sources = configuration.get("can_publish_sources", None)
return {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room_id,
"token": generate_token(
room=room_id, user=user, username=username, color=color
room=room_id,
user=user,
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
),
}
+20
View File
@@ -495,6 +495,16 @@ class Base(Configuration):
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_DEFAULT_SOURCES = values.ListValue(
[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
environ_name="LIVEKIT_DEFAULT_SOURCES",
environ_prefix=None,
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
@@ -644,6 +654,16 @@ class Base(Configuration):
environ_prefix=None,
)
# Subtitles settings
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
)
ROOM_SUBTITLE_AGENT_NAME = values.Value(
"multi-user-transcriber",
environ_name="ROOM_SUBTITLE_AGENT_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+6 -6
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.33"
version = "0.1.38"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -38,7 +38,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.3",
"django==5.2.6",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -60,10 +60,10 @@ dependencies = [
]
[project.urls]
"Bug Tracker" = "https://github.com/numerique-gouv/meet/issues/new"
"Changelog" = "https://github.com/numerique-gouv/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/meet"
"Repository" = "https://github.com/numerique-gouv/meet"
"Bug Tracker" = "https://github.com/suitenumerique/meet/issues/new"
"Changelog" = "https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
[project.optional-dependencies]
dev = [
+1 -1
View File
@@ -38,7 +38,7 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
USER nginx
+4 -1
View File
@@ -2,7 +2,10 @@
<html lang="en" data-lk-theme="visio-light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+10 -10
View File
@@ -1,16 +1,16 @@
{
"name": "meet",
"version": "0.1.33",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.33",
"version": "0.1.38",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
@@ -26,7 +26,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -1281,9 +1281,9 @@
}
},
"node_modules/@livekit/track-processors": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.5.7.tgz",
"integrity": "sha512-/2SkuVAF+YiPNtOi9zQJz/yH1WGaK53XZ3PaESpLOiEYUBsYky13BrriXCXUf6kwn5R5+7ZsYWc2k3XSsAuLtg==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.6.1.tgz",
"integrity": "sha512-t9JMDvMUlaaURDDRZFQEkRYR4q2qROPOOIs3aZXQVL6v/QYgJ0tPg/QfbvHC8b6mYPwcaJgVz3KTk5XQ07fEMg==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
@@ -7460,9 +7460,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
"version": "2.15.7",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.33",
"version": "0.1.38",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -15,7 +15,7 @@
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
@@ -31,7 +31,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
+1
View File
@@ -124,6 +124,7 @@ const config: Config = {
...pandaPreset.theme.tokens.colors,
primaryDark: {
50: { value: '#161622' },
75: { value: '#222234' },
100: { value: '#2D2D46' },
200: { value: '#43436A' },
300: { value: '#5A5A8F' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+4
View File
@@ -31,6 +31,9 @@ export interface ApiConfig {
expiration_days?: number
max_duration?: number
}
subtitle: {
enabled: boolean
}
telephony: {
enabled: boolean
phone_number?: string
@@ -41,6 +44,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
}
+6
View File
@@ -16,6 +16,12 @@ const avatar = cva({
},
variants: {
context: {
subtitles: {
width: '40px',
height: '40px',
fontSize: '1.3rem',
lineHeight: '1rem',
},
list: {
width: '32px',
height: '32px',
@@ -3,4 +3,5 @@ export enum FeatureFlags {
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
noiseReduction = 'noise-reduction',
subtitles = 'subtitles',
}
@@ -104,6 +104,19 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break
toastQueue.add(
{
participant,
type: notification.type,
removedSources: removedSources,
},
{ timeout: NotificationDuration.ALERT }
)
break
}
default:
return
}
@@ -4,5 +4,6 @@ export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
removedSources?: string[]
}
}
@@ -13,4 +13,5 @@ export enum NotificationType {
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
}
@@ -38,7 +38,7 @@ export interface ToastProps {
state: ToastState<ToastData>
}
export function Toast({ state, ...props }: ToastProps) {
export function Toast({ state, ...props }: Readonly<ToastProps>) {
const ref = useRef(null)
const { toastProps, contentProps, closeButtonProps } = useToast(
props,
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
export function ToastAnyRecording({ state, ...props }: ToastProps) {
export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -19,7 +19,7 @@ const ClickableToast = styled(RACButton, {
},
})
export function ToastJoined({ state, ...props }: ToastProps) {
export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
export function ToastLowerHand({ state, ...props }: ToastProps) {
export function ToastLowerHand({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -9,7 +9,10 @@ import { Button as RACButton } from 'react-aria-components'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export function ToastMessageReceived({ state, ...props }: ToastProps) {
export function ToastMessageReceived({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps } = useToast(props, state, ref)
@@ -5,7 +5,7 @@ import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
export function ToastMuted({ state, ...props }: ToastProps) {
export function ToastMuted({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -0,0 +1,52 @@
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
export function ToastPermissionsRemoved({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', {
keyPrefix: 'permissionsRemoved',
})
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const key = useMemo(() => {
const sources = props.toast.content.removedSources
if (!Array.isArray(sources) || sources.length === 0) {
return undefined
}
if (sources.length === 1) {
return sources[0]
}
if (sources.length === 2 && sources.includes('screen_share')) {
return 'screen_share'
}
return undefined
}, [props.toast.content.removedSources])
if (!participant || !key) return null
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(key)}
</HStack>
</StyledToastContainer>
)
}
@@ -9,7 +9,7 @@ import { RiCloseLine, RiHand } from '@remixicon/react'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { css } from '@/styled-system/css'
export function ToastRaised({ state, ...props }: ToastProps) {
export function ToastRaised({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
@@ -9,7 +9,10 @@ import { useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { RecordingMode } from '@/features/recording'
export function ToastRecordingSaving({ state, ...props }: ToastProps) {
export function ToastRecordingSaving({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'recordingSave' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
@@ -11,6 +11,7 @@ import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -30,6 +31,11 @@ const renderToast = (
case NotificationType.ParticipantMuted:
return <ToastMuted key={toast.key} toast={toast} state={state} />
case NotificationType.PermissionsRemoved:
return (
<ToastPermissionsRemoved key={toast.key} toast={toast} state={state} />
)
case NotificationType.MessageReceived:
return (
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
@@ -23,6 +23,8 @@ import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -189,7 +191,7 @@ export const ScreenRecordingSidePanel = () => {
</H>
<Text
variant="note"
wrap={'pretty'}
wrap="balance"
centered
className={css({
textStyle: 'sm',
@@ -198,7 +200,18 @@ export const ScreenRecordingSidePanel = () => {
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('start.linkMore')}
@@ -25,6 +25,8 @@ import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -263,7 +265,7 @@ export const TranscriptSidePanel = () => {
</H>
<Text
variant="note"
wrap={'pretty'}
wrap="balance"
centered
className={css({
textStyle: 'sm',
@@ -272,7 +274,18 @@ export const TranscriptSidePanel = () => {
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
@@ -19,6 +19,6 @@ export type ApiRoom = {
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean
[key: string]: string | number | boolean | string[]
}
}
@@ -0,0 +1,27 @@
import { Participant } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi.ts'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return await fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
attributes: newAttributes,
}),
})
}
return { lowerHandParticipant }
}
@@ -1,5 +1,5 @@
import { Participant } from 'livekit-client'
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant'
import { useLowerHandParticipant } from './lowerHandParticipant'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
@@ -1,12 +1,11 @@
import { Participant, Track } from 'livekit-client'
import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomData } from '../livekit/hooks/useRoomData'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
export const useMuteParticipant = () => {
const data = useRoomData()
@@ -14,34 +13,25 @@ export const useMuteParticipant = () => {
const { notifyParticipants } = useNotifyParticipants()
const muteParticipant = async (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
if (!trackSid) {
throw new Error('Missing audio track')
return
}
try {
const response = await fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/MutePublishedTrack'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
muted: true,
track_sid: trackSid,
}),
}
)
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
await notifyParticipants({
type: NotificationType.ParticipantMuted,
@@ -0,0 +1,19 @@
import { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
const muteParticipants = (participants: Array<Participant>) => {
try {
const promises = participants.map((participant) =>
muteParticipant(participant)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while muting participants :', error)
throw new Error('An error occurred while muting participants.')
}
}
return { muteParticipants }
}
@@ -5,7 +5,7 @@ import { ApiError } from '@/api/ApiError'
export type PatchRoomParams = {
roomId: string
room: Pick<ApiRoom, 'configuration' | 'access_level'>
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
}
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
@@ -0,0 +1,21 @@
import { Participant } from 'livekit-client'
import { useRoomData } from '../livekit/hooks/useRoomData'
import { fetchApi } from '@/api/fetchApi'
export const useRemoveParticipant = () => {
const data = useRoomData()
const removeParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
return fetchApi(`rooms/${data.id}/remove-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
}),
})
}
return { removeParticipant }
}
@@ -0,0 +1,41 @@
import { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import Source = Track.Source
export const useParticipantPermissions = () => {
const data = useRoomData()
const updateParticipantPermissions = async (
participant: Participant,
sources: Array<Source>
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const newPermissions = {
can_subscribe: participant.permissions?.canSubscribe,
can_publish_data: participant.permissions?.canPublishData,
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources.map((source) => source.toUpperCase()),
}
try {
return fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
permission: newPermissions,
}),
})
} catch (error) {
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { updateParticipantPermissions }
}
@@ -0,0 +1,23 @@
import { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
const { updateParticipantPermissions } = useParticipantPermissions()
const updateParticipantsPermissions = (
participants: Array<Participant>,
sources: Array<Source>
) => {
try {
const promises = participants.map((participant) =>
updateParticipantPermissions(participant, sources)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while updating permissions :', error)
throw new Error('An error occurred while updating permissions.')
}
}
return { updateParticipantsPermissions }
}
@@ -10,8 +10,6 @@ import {
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
@@ -87,13 +85,10 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -116,8 +111,6 @@ export const Conference = ({
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -146,10 +146,9 @@ export const Join = ({
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
),
},
},
onError
@@ -475,7 +474,7 @@ export const Join = ({
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: 0 },
gap: { base: '1rem', sm: '2rem', lg: '2rem' },
lg: {
flexDirection: 'row',
},
@@ -639,7 +638,7 @@ export const Join = ({
<Button
size="sm"
variant="tertiary"
onPress={openPermissionsDialog}
onPress={() => openPermissionsDialog('videoinput')}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
@@ -772,7 +771,7 @@ export const Join = ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 448px',
flex: '0 0 360px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
@@ -37,6 +37,22 @@ export const Permissions = () => {
injectIconIntoTranslation(t('body.openMenu.others'))
useEffect(() => {
if (
permissions.isPermissionDialogOpen &&
permissions.isMicrophoneGranted &&
permissions.requestOrigin == 'audioinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
permissions.requestOrigin == 'videoinput'
) {
closePermissionsDialog()
}
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
@@ -64,13 +80,17 @@ export const Permissions = () => {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
minWidth: '290px',
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
@@ -1,5 +0,0 @@
export const buildServerApiUrl = (origin: string, path: string) => {
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/${sanitizedPath}`
}
@@ -1,21 +0,0 @@
import { ApiError } from '@/api/ApiError'
export const fetchServerApi = async <T = Record<string, unknown>>(
url: string,
token: string,
options?: RequestInit
): Promise<T> => {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options?.headers,
},
})
const result = await response.json()
if (!response.ok) {
throw new ApiError(response.status, result)
}
return result
}
@@ -1,37 +0,0 @@
import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/UpdateParticipant'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
attributes: newAttributes,
permission: participant.permissions,
}),
}
)
}
return { lowerHandParticipant }
}
@@ -9,6 +9,7 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -28,6 +29,15 @@ export const Admin = () => {
enabled: false,
})
const {
toggleMicrophone,
toggleCamera,
toggleScreenShare,
isMicrophoneEnabled,
isCameraEnabled,
isScreenShareEnabled,
} = usePublishSourcesManager()
return (
<Div
display="flex"
@@ -47,68 +57,155 @@ export const Admin = () => {
>
{t('description')}
</Text>
<RACSeparator
<div
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
display: 'flex',
flexDirection: 'column',
})}
>
{t('access.title')}
</H>
<Text
variant="note"
wrap="balance"
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('moderation.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('moderation.description')}
</Text>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Field
type="switch"
label={t('moderation.microphone.label')}
description={t('moderation.microphone.description')}
isSelected={isMicrophoneEnabled}
onChange={toggleMicrophone}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.camera.label')}
description={t('moderation.camera.description')}
isSelected={isCameraEnabled}
onChange={toggleCamera}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.screenshare.label')}
description={t('moderation.screenshare.description')}
isSelected={isScreenShareEnabled}
onChange={toggleScreenShare}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
className={css({
textStyle: 'sm',
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
})}
margin={'md'}
>
{t('access.description')}
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({ roomId, room: { access_level: value as ApiAccessLevel } })
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('access.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('access.description')}
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
</div>
</Div>
)
}
@@ -14,7 +14,7 @@ export const MutedMicIndicator = ({
source: Source.Microphone,
})
if (!isMuted) {
if (!isMuted && participant.isMicrophoneEnabled) {
return null
}
@@ -0,0 +1,24 @@
import { Menu as RACMenu } from 'react-aria-components'
import { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { PinMenuItem } from './PinMenuItem'
import { RemoveMenuItem } from './RemoveMenuItem'
export const ParticipantMenu = ({
participant,
}: {
participant: Participant
}) => {
const isAdminOrOwner = useIsAdminOrOwner()
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
return (
<RACMenu
style={{
minWidth: '75px',
}}
>
<PinMenuItem participant={participant} />
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
</RACMenu>
)
}
@@ -0,0 +1,30 @@
import { Button, Menu } from '@/primitives'
import { RiMore2Fill } from '@remixicon/react'
import { ParticipantMenu } from './ParticipantMenu'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
export const ParticipantMenuButton = ({
participant,
}: {
participant: Participant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner) return null
return (
<Menu>
<Button
square
variant="tertiaryText"
size="sm"
aria-label={t('moreOptions')}
tooltip={t('moreOptions')}
>
<RiMore2Fill />
</Button>
<ParticipantMenu participant={participant} />
</Menu>
)
}
@@ -0,0 +1,37 @@
import { Participant } from 'livekit-client'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { toggle, inFocus } = useFocusToggleParticipant(participant)
return (
<MenuItem
aria-label={t(`${inFocus ? 'unpin' : 'pin'}.ariaLabel`, {
name: participant.name,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggle}
>
<HStack gap={0.25}>
{inFocus ? (
<>
<RiUnpinLine size={20} aria-hidden />
{t('unpin.label')}
</>
) : (
<>
<RiPushpin2Line size={20} aria-hidden />
{t('pin.label')}
</>
)}
</HStack>
</MenuItem>
)
}

Some files were not shown because too many files have changed in this diff Show More