Compare commits

..

4 Commits

518 changed files with 11403 additions and 38950 deletions
+10 -13
View File
@@ -150,14 +150,13 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -224,6 +223,8 @@ jobs:
DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
@@ -305,8 +306,7 @@ jobs:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
APP_API_TOKEN: "test-api-token"
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
@@ -318,16 +318,13 @@ jobs:
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@v6
with:
-3
View File
@@ -83,6 +83,3 @@ docker/livekit/out
# LiveKit CA configuration
docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
-119
View File
@@ -8,128 +8,9 @@ and this project adheres to
## [Unreleased]
## [1.18.0] - 2026-06-03
### Added
- 🔧(backend) backport logging configuration from docs
- 🧑‍💻(backend) add management command to merge duplicate users
- 👷(helm) add Kubernetes job for duplicate user merge command
### Fixed
- 🐛(backend) prevent duplicate pending users on concurrent requests
- 🔒️(backend) prevent file change post checks #1377
## [1.17.0] - 2026-05-31
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
- ✨(backend) add core.recording.event.parsers.S3Parser
- ✨(summary) extended support for all video / audio files #1358
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
- 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349
- ✨(backend) allow room configuration and access level via external api #1260
- ♻️(backend) prefix Swagger routes with /api
### Fixed
- 🩹(backend) fix swagger and redoc documentation URLs
## [1.16.0] - 2026-05-13
### Added
- 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed
- ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30
### Added
- ✨(backend) add metadata collection of VAD, connection and chat events
- ✨(backend) introduce add-ons authentication backend
- 💬(backend) clarify french transcription audio download link text #1299
- 🚧(addons) introduce initial Microsoft Outlook add-in support (alpha)
- 🔧(backend) add setting to toggle application token exchange mechanism
- ✨(backend) support add-ons authentication in external viewset
### Fixed
- 🐛(summary) support webm #1290
- ⬆️(backend) bump django-lasuite to v0.0.26
- 🩹(frontend) use a more standard (quality) rating scale
- 🩹(frontend) fix access control for screen recording feature flag
- 🩹(frontend) fix reconnect loop caused by connectionObserverStore updates
## [1.14.0] - 2026-04-16
### Added
- 🔒️(helm) Add pod and container securityContext #1197
- ✨(summary) add routes v2 for async STT and summary tasks #1171
- ✅(backend) add unit tests for JwtTokenService #1232
### Changed
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
- ♻️(backend) use Authorization header for LiveKit token authentication
- 🥅(backend) refine Twirp error handling for participant operations
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
- 🔒(backend) rely on backend to allow participant update their metadata
- 🐛(summary) fix failure webhook notification #1233
- 🐛(summary) relax whisperX payload format #1233
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
## [1.13.0] - 2026-03-31
### Changed
- ⬆️(dependencies) update python dependencies
- ♿️(frontend) add explicit region for call controls #1216
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
### Fixed
+15 -30
View File
@@ -73,9 +73,7 @@ create-env-files: \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary \
env.d/development/kube-secret \
env.d/development/multi_user_transcriber
env.d/development/summary
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -96,7 +94,6 @@ bootstrap: \
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build
build-backend: ## build the app-dev container
@@ -108,10 +105,6 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -132,24 +125,16 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
@$(COMPOSE) up --force-recreate -d metadata-collector-dev
.PHONY: run-agent-metadata-collector
run-sip: ## start the SIP gateway and the browser-based softphone (Janus + nginx)
@$(COMPOSE) up --force-recreate -d sip
@$(COMPOSE) up --force-recreate -d sip-web-janus sip-web
.PHONY: run-sip
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(MAKE) run-sip
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -286,12 +271,6 @@ env.d/development/kc_postgresql:
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
# -- Internationalization
env.d/development/crowdin:
@@ -379,9 +358,15 @@ frontend-i18n-generate: \
# -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind
build-k8s-cluster: \
env.d/development/kube-secret \
./bin/start-kind.sh
./bin/start-kind.sh
.PHONY: build-k8s-cluster
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
+55 -21
View File
@@ -2,7 +2,6 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -12,11 +11,11 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</a>
</p>
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
<p align="center">
@@ -28,25 +27,65 @@
## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- End-to-end encryption with passphrase-in-link key distribution
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
#### How it works
- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key.
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share.
- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies.
#### Opt-in by user
End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption" — from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it.
#### Pause / resume for recording and transcription
While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription:
1. A confirmation dialog warns that encryption will be paused.
2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt.
3. Each receiver disables E2EE locally and republishes its tracks unencrypted.
4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them.
5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase.
The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip.
#### Phone / SIP participants
Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again.
#### Configuration
```env
ENCRYPTION_ENABLED=true
```
Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -63,7 +102,6 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy)
- [Open source](#open-source)
## Get started
## Docs
@@ -82,15 +120,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| Url | Org | Access |
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
@@ -100,7 +138,6 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -109,7 +146,6 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -121,14 +157,13 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a>
## Credits
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
@@ -137,4 +172,3 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+8 -25
View File
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -23,8 +23,8 @@ docker_build(
live_update=[
sync('../src/backend', '/app'),
run(
'uv sync --locked --no-dev',
trigger=['../src/backend/uv.lock', '../src/backend/pyproject.toml']
'pip install -r /app/requirements.txt',
trigger=['./api/requirements.txt']
)
]
)
@@ -34,11 +34,10 @@ docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './src/addons', './docker', './.dockerignore'],
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
live_update=[
sync('../src/frontend', '/home/frontend'),
sync('../src/addons', '/home/addons'),
]
)
clean_old_images('localhost:5001/meet-frontend-dinum')
@@ -96,34 +95,18 @@ docker_build(
)
clean_old_images('localhost:5001/meet-livekit')
load('ext://secret', 'secret_yaml_generic')
k8s_yaml(secret_yaml_generic(
name="secret-dev",
from_env_file="../env.d/development/kube-secret"
))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
# Trigger once on launch
k8s_resource(
'meet-backend-createsuperuser',
resource_deps=['meet-backend-migrate'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
k8s_resource(
'meet-backend-migrate',
resource_deps=['meet-backend'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
+1
View File
@@ -47,3 +47,4 @@ mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
-7
View File
@@ -101,12 +101,6 @@ update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Run uv lock in backend
print_info "Running uv lock in backend..."
cd "src/backend"
uv lock
cd -
# Update summary pyproject.toml
update_python_version "summary"
@@ -155,7 +149,6 @@ echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
+48 -30
View File
@@ -168,8 +168,7 @@ services:
- "3000:8080"
dockerize:
image: jwilder/dockerize
platform: linux/x86_64
image: powerman/dockerize:0.19.0
crowdin:
image: crowdin/cli:4.0.0
@@ -246,40 +245,59 @@ services:
depends_on:
- redis
metadata-collector-dev:
# SIP gateway. Built from the sibling livekit-sip checkout so any dev
# cloning both repos gets a working `docker compose up`. TODO: replace
# the build: block with `image: livekit/sip` once upstream ships an
# image with the team's video bridging.
sip:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
context: ../livekit-sip
dockerfile: build/sip/Dockerfile
ports:
- "5060:5060/udp"
- "5060:5060/tcp"
- "10000-10020:10000-10020/udp"
environment:
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- AWS_S3_ENDPOINT_URL=minio:9000
- AWS_S3_ACCESS_KEY_ID=meet
- AWS_S3_SECRET_ACCESS_KEY=password
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
- /app/.venv
SIP_CONFIG_BODY: |
api_key: 'devkey'
api_secret: 'secret'
ws_url: 'ws://livekit:7880'
redis:
address: 'redis:6379'
sip_port: 5060
rtp_port: 10000-10020
use_external_ip: false
logging:
level: debug
depends_on:
- livekit
- minio
develop:
watch:
- action: rebuild
path: ./src/agents
- redis
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
# Janus WebRTC gateway with the SIP plugin. Bridges a browser SIP demo
# tab to the SIP gateway above. ICE-TCP enabled (janus.jcfg) so media
# works under Lima port-forwarding without vmnet.
sip-web-janus:
build: ./docker/janus
ports:
- "10100-10120:10100-10120/tcp"
volumes:
- ./src/agents:/app
- /app/.venv
- ./docker/janus/conf/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
- ./docker/janus/conf/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg:ro
- ./docker/janus/conf/janus.plugin.sip.jcfg:/usr/local/etc/janus/janus.plugin.sip.jcfg:ro
depends_on:
- sip
# Serves docker/janus/web/{index.html,janus.js} and proxies /janus to
# the Janus container so the demo is single-origin.
sip-web:
image: nginx:alpine
ports:
- "8088:80"
volumes:
- ./docker/janus/web:/usr/share/nginx/html:ro
- ./docker/janus/conf/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- sip-web-janus
redis-summary:
image: redis
+20 -3
View File
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": "true",
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": "true",
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": "true",
@@ -845,6 +845,23 @@
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
}
],
"clientScopes": [
+8 -28
View File
@@ -38,32 +38,16 @@ COPY ./docker/dinum-frontend/assets/ \
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Addons builder image ----
FROM node:20-alpine AS addons-builder
WORKDIR /home/addons/outlook
COPY ./src/addons/outlook/package.json ./package.json
COPY ./src/addons/outlook/package-lock.json ./package-lock.json
RUN npm ci
COPY ./src/addons/outlook/ .
RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
USER nginx
@@ -75,11 +59,7 @@ COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY --from=addons-builder \
/home/addons/outlook/dist \
/usr/share/nginx/html/addons/outlook
COPY ./docker/dinum-frontend/nginx/default.conf /etc/nginx/conf.d
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
-82
View File
@@ -1,82 +0,0 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /addons/outlook/manifest.xml {
alias /usr/share/nginx/html/addons/outlook/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location = /addons/outlook/assets/ {
return 404;
}
location ~* ^/addons/outlook/assets/(.+\.(?:css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot))/?$ {
root /usr/share/nginx/html;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable" always;
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin" always;
}
location = /addons/outlook/ {
return 404;
}
location ~ ^/addons/outlook(/.*)?$ {
alias /usr/share/nginx/html/addons/outlook$1;
error_page 404 =200 /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache" always;
add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
+58
View File
@@ -0,0 +1,58 @@
# Build Janus from source so ICE-TCP support is present (the canyan image
# was compiled against libnice 0.1.16, which lacks the runtime ICE-TCP
# capability Janus 1.1.x feature-detects at startup). Debian 13 ships
# libnice 0.1.21+ which has ICE-TCP. Also: builds natively on arm64, so
# no QEMU emulation overhead.
FROM debian:13-slim AS builder
ARG JANUS_VERSION=v1.1.4
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates git pkg-config \
autoconf automake libtool gengetopt \
libssl-dev libsrtp2-dev libglib2.0-dev libopus-dev libogg-dev \
libcurl4-openssl-dev libconfig-dev libnice-dev \
libmicrohttpd-dev libjansson-dev libsofia-sip-ua-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --depth 1 --branch ${JANUS_VERSION} \
https://github.com/meetecho/janus-gateway.git janus
WORKDIR /src/janus
RUN ./autogen.sh && \
./configure \
--prefix=/opt/janus \
--disable-rabbitmq --disable-mqtt --disable-nanomsg --disable-unix-sockets \
--disable-data-channels \
--disable-all-plugins \
--enable-plugin-sip \
--disable-all-handlers \
--disable-all-transports --enable-rest && \
make -j$(nproc) && \
make install && \
make configs
FROM debian:13-slim
# Debian 13 (trixie) renamed several libs in the time_t-64 transition:
# libglib2.0-0t64, libcurl4t64. libconfig9 → libconfig11.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 libsrtp2-1 libglib2.0-0t64 libopus0 libogg0 \
libcurl4t64 libconfig11 libnice10 libmicrohttpd12 libjansson4 \
libsofia-sip-ua0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/janus /opt/janus
# Make paths match canyan's so the rest of the compose mounts (configs, demos)
# Just-Work without changes. The configs we mount in docker/janus/conf/ assume
# /usr/local/etc/janus and /usr/local/lib/janus.
RUN ln -s /opt/janus/bin/janus /usr/local/bin/janus && \
ln -s /opt/janus/etc/janus /usr/local/etc/janus && \
ln -s /opt/janus/lib/janus /usr/local/lib/janus && \
ln -s /opt/janus/share/janus /usr/local/share/janus
EXPOSE 8088 10100-10120/tcp 10100-10120/udp
CMD ["/usr/local/bin/janus"]
+42
View File
@@ -0,0 +1,42 @@
// Minimal Janus config for browser-↔-SIP demo. Trimmed from the upstream
// sample to the bits we actually need.
general: {
configs_folder = "/usr/local/etc/janus"
plugins_folder = "/usr/local/lib/janus/plugins"
transports_folder = "/usr/local/lib/janus/transports"
events_folder = "/usr/local/lib/janus/events"
log_to_stdout = true
debug_level = 4
server_name = "meet-sip-demo"
}
media: {
// Janus ↔ Browser media (WebRTC over TCP — Lima only forwards TCP).
rtp_port_range = "10100-10120"
ipv6 = false
}
nat: {
// The whole reason this works without vmnet — Janus offers TCP ICE
// candidates the Mac browser can actually connect to.
ice_tcp = true
// libnice quirk: ICE-TCP needs ICE Lite mode on the server side or
// the connectivity check state machine deadlocks. Janus warns about
// this at startup if Lite is off while TCP is on.
ice_lite = true
// Janus advertises this IP in ICE candidates. From the Mac browser's
// perspective, the Janus daemon lives at 127.0.0.1:<port> (Lima
// forwards Mac:127.0.0.1:<port> → VM → docker port-map → container).
nat_1_1_mapping = "127.0.0.1"
keep_private_host = true
}
plugins: {
// Only keep the SIP plugin. Everything else trimmed for boot time.
disable = "libjanus_audiobridge.so,libjanus_videoroom.so,libjanus_streaming.so,libjanus_textroom.so,libjanus_recordplay.so,libjanus_voicemail.so,libjanus_echotest.so,libjanus_videocall.so,libjanus_nosip.so,libjanus_duktape.so,libjanus_lua.so"
}
transports: {
// Only HTTP. WS is overkill for the demo and adds another port.
disable = "libjanus_websockets.so,libjanus_mqtt.so,libjanus_nanomsg.so,libjanus_rabbitmq.so,libjanus_pfunix.so"
}
+8
View File
@@ -0,0 +1,8 @@
// SIP plugin defaults. RTP range for the SIP leg (Janus ↔ livekit/sip)
// is kept separate from WebRTC range — both legs are inside the docker
// bridge network, so any UDP range works.
general: {
local_ip = "0.0.0.0"
rtp_port_range = "20000-20100"
events = true
}
@@ -0,0 +1,24 @@
// HTTP transport: bind 0.0.0.0:8088 inside the container, served to the
// browser through the nginx sidecar that also hosts the demo HTML.
general: {
json = "indented"
base_path = "/janus"
http = true
port = 8088
https = false
mhd_connection_limit = 1020
}
admin: {
admin_base_path = "/admin"
admin_http = false
admin_https = false
}
certificates: {
}
cors: {
// Same-origin via nginx proxy; demo page and API share a host.
# allow_origin = "*"
}
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name _;
# Docker's embedded DNS (127.0.0.11). Without this, nginx resolves
# upstreams once at startup and breaks the moment a peer container is
# recreated with a new bridge IP. The `valid=10s` plus a variable in
# proxy_pass forces per-request re-resolution.
resolver 127.0.0.11 valid=10s ipv6=off;
# Serve the Janus demo HTML/JS extracted from the canyan image.
root /usr/share/nginx/html;
index index.html;
# Janus HTTP API — same origin as the demo pages so the default
# settings.js URL ("http://<host>:8088/janus") just works. Regex so
# /janus and /janus/<session>/... proxy, but /janus.js still hits
# the static file root.
location ~ ^/janus(/|$) {
# Variable upstream + the resolver above = re-DNS on each request.
# Plain `proxy_pass http://sip-web-janus:8088` would cache the IP
# for the lifetime of the worker process.
set $janus_upstream sip-web-janus;
proxy_pass http://$janus_upstream:8088;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ =404;
}
}
+405
View File
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<!--
Minimal SIP-via-Janus demo. Replaces the upstream siptest.html bundle with
a single self-contained page. Everything is auto-driven: page load creates
the Janus session, attaches to the SIP plugin, registers as guest, places
the call. The user just needs to type the room PIN on the dialpad.
Co-located only with janus.js (vendor lib, ~113KB) — no settings.js, no
navbar/footer, no shared CSS, no logos. nginx serves these two files.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>SIP Demo</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: #1b1b1f; color: #e8e8e8;
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; max-width: 720px; margin: 0 auto; padding: 16px; gap: 12px; }
h1 { margin: 0; font-size: 18px; font-weight: 600; }
#status { padding: 8px 12px; border-radius: 6px; background: #2c2c33; font-family: ui-monospace, monospace; font-size: 13px; }
#video-wrap { position: relative; flex: 1; background: #000; border-radius: 8px; overflow: hidden; min-height: 240px; }
#remoteVideo { width: 100%; height: 100%; object-fit: contain; }
#noVideo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #555; font-size: 14px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
button { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #4a90e2; color: #fff; cursor: pointer; }
button:hover:not(:disabled) { background: #357ec0; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: #dc3545; }
button.danger:hover:not(:disabled) { background: #b62733; }
button.success { background: #28a745; }
button.success:hover:not(:disabled) { background: #1f8035; }
.dialpad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.dialpad button { padding: 14px 0; font-size: 16px; background: #3a3a44; }
.dialpad button:hover:not(:disabled) { background: #4a4a55; }
#pin-row input { flex: 1; padding: 8px 12px; border: 1px solid #444; border-radius: 6px; background: #2c2c33; color: #fff; font: inherit; font-family: ui-monospace, monospace; letter-spacing: 0.2em; }
#log { font-family: ui-monospace, monospace; font-size: 11px; max-height: 120px; overflow-y: auto; background: #15151a; padding: 8px; border-radius: 6px; color: #888; }
.log-line { white-space: pre-wrap; word-break: break-word; }
.log-err { color: #ff7373; }
.disabled { opacity: 0.35; pointer-events: none; transition: opacity 0.2s; }
</style>
</head>
<body>
<div id="app">
<h1>SIP demo (browser → Janus → livekit-sip → room)</h1>
<div id="status">Loading…</div>
<div id="video-wrap">
<video id="remoteVideo" autoplay playsinline muted></video>
<audio id="remoteAudio" autoplay></audio>
<div id="noVideo">No remote video yet</div>
</div>
<div id="pin-row" class="row" style="display:none">
<input type="text" id="pin" placeholder="Enter PIN then press # on the pad" inputmode="numeric" pattern="[0-9]*">
<button id="sendPin" class="success">Send PIN</button>
</div>
<div id="dialpad" class="dialpad" style="display:none">
<button data-d="1">1</button><button data-d="2">2</button><button data-d="3">3</button><button data-d="A">A</button>
<button data-d="4">4</button><button data-d="5">5</button><button data-d="6">6</button><button data-d="B">B</button>
<button data-d="7">7</button><button data-d="8">8</button><button data-d="9">9</button><button data-d="C">C</button>
<button data-d="*">*</button><button data-d="0">0</button><button data-d="#">#</button><button data-d="D">D</button>
</div>
<div id="controls" class="row">
<button id="muteBtn" class="danger">🔇 Mic muted</button>
<button id="cameraBtn" class="success">📷 Camera on</button>
<button id="speakerBtn" class="success">🔊 Speaker on</button>
<button id="hangupBtn" class="danger" disabled>Hangup</button>
<button id="rejoinBtn" style="display:none">Re-join</button>
</div>
<details><summary style="cursor:pointer; opacity:0.7; font-size:12px">debug log</summary>
<div id="log"></div>
</details>
</div>
<!-- webrtc-adapter is required by janus.js (cross-browser WebRTC API
normalizer). The upstream siptest pulls it from CDN; we do the same.
Without it Janus.init() silently fails. -->
<script src="https://cdn.jsdelivr.net/npm/webrtc-adapter@9.0.1/out/adapter.min.js"></script>
<script src="janus.js"></script>
<script>
// ============================================================================
// Configuration (hard-coded; this is a dev demo)
// ============================================================================
const JANUS_SERVER = location.origin + "/janus"; // nginx proxies to janus
const SIP_PROXY = "sip:sip:5060"; // docker service name
const SIP_IDENTITY = "sip:demo@example.invalid"; // no registrar, guest
const SIP_PEER = "sip:phone@sip:5060"; // user-part irrelevant
// (Direct+PIN rule)
const DISPLAY_NAME = "test-user";
const ICE_SERVERS = []; // ICE-TCP via janus.jcfg
// ============================================================================
// UI helpers
// ============================================================================
const $ = id => document.getElementById(id);
function setStatus(text) { $("status").textContent = text; log(text); }
function log(msg, isErr = false) {
const line = document.createElement("div");
line.className = "log-line" + (isErr ? " log-err" : "");
line.textContent = "[" + new Date().toLocaleTimeString() + "] " + msg;
$("log").appendChild(line);
$("log").scrollTop = $("log").scrollHeight;
}
// ============================================================================
// Janus state
// ============================================================================
let janus = null;
let sipcall = null;
let inCall = false;
// ============================================================================
// Init: create Janus → session → SIP handle → register → call
// ============================================================================
Janus.init({
debug: false,
callback: () => {
if (!Janus.isWebrtcSupported()) {
setStatus("WebRTC not supported in this browser.");
return;
}
createSession();
}
});
function createSession() {
setStatus("Connecting to Janus…");
janus = new Janus({
server: JANUS_SERVER,
iceServers: ICE_SERVERS,
success: () => {
setStatus("Connected. Attaching SIP plugin…");
attachSip();
},
error: (err) => setStatus("Janus error: " + err),
destroyed: () => setStatus("Janus session destroyed."),
});
}
function attachSip() {
janus.attach({
plugin: "janus.plugin.sip",
opaqueId: "sip-demo-" + Janus.randomString(8),
success: (handle) => {
sipcall = handle;
window.sipcall = handle; // for ad-hoc console debugging
registerAsGuest();
},
error: (err) => setStatus("Plugin attach error: " + err),
onmessage: handleMessage,
onlocaltrack: (track, on) => {
// We don't render local preview — Meet tab is already showing it.
},
onremotetrack: (track, mid, on, meta) => {
if (!on) return;
const stream = new MediaStream([track]);
if (track.kind === "audio") {
$("remoteAudio").srcObject = stream;
} else if (track.kind === "video") {
$("remoteVideo").srcObject = stream;
$("noVideo").style.display = "none";
}
},
oncleanup: () => { setStatus("Call cleaned up."); endCallUi(); },
});
}
function registerAsGuest() {
setStatus("Registering as guest…");
sipcall.send({
message: {
request: "register",
type: "guest",
proxy: SIP_PROXY,
username: SIP_IDENTITY,
display_name: DISPLAY_NAME,
},
});
}
function placeCall() {
setStatus("Calling " + SIP_PEER + "…");
const tracks = [
{ type: "audio", capture: true, recv: true },
{ type: "video", capture: true, recv: true },
];
sipcall.createOffer({
tracks: tracks,
success: (jsep) => {
sipcall.send({
message: { request: "call", uri: SIP_PEER, autoaccept_reinvites: false },
jsep: jsep,
});
},
error: (err) => setStatus("createOffer failed: " + err),
});
}
// ============================================================================
// Plugin event router
// ============================================================================
function handleMessage(msg, jsep) {
const result = msg && msg.result;
const event = result && result.event;
if (event) log("event: " + event);
if (event === "registered") {
setStatus("Registered. Placing call…");
// Small delay to settle the registration
setTimeout(placeCall, 100);
}
else if (event === "calling") {
setStatus("Calling…");
}
else if (event === "accepted") {
setStatus("Call accepted. You should hear the IVR — enter PIN on the dialpad.");
startCallUi();
if (jsep) sipcall.handleRemoteJsep({ jsep: jsep });
}
else if (event === "hangup") {
setStatus("Call ended: " + (result.reason || result.code || ""));
endCallUi();
}
else if (event === "missed_call") {
setStatus("Missed call.");
endCallUi();
}
else if (event === "registration_failed") {
setStatus("Registration failed: " + result.code + " " + result.reason, true);
}
else if (msg && msg.error) {
setStatus("Plugin error: " + msg.error, true);
}
if (jsep && event !== "accepted") {
sipcall.handleRemoteJsep({ jsep: jsep });
}
}
// ============================================================================
// In-call UI: mute, hangup, dialpad, PIN entry
// ============================================================================
function startCallUi() {
inCall = true;
$("pin-row").style.display = "flex";
$("pin-row").classList.remove("disabled");
$("dialpad").style.display = "grid";
$("dialpad").classList.remove("disabled");
$("hangupBtn").disabled = false;
$("rejoinBtn").style.display = "none";
// Auto-mute mic so two tabs on the same Mac don't feedback-howl.
if (sipcall.isAudioMuted && !sipcall.isAudioMuted()) {
sipcall.muteAudio();
}
updateMuteButton();
updateCameraButton();
updateSpeakerButton();
}
function endCallUi() {
inCall = false;
$("pin-row").style.display = "none";
$("pin-row").classList.remove("disabled");
$("dialpad").style.display = "none";
$("dialpad").classList.remove("disabled");
$("hangupBtn").disabled = true;
$("rejoinBtn").style.display = "inline-block";
$("remoteVideo").srcObject = null;
$("remoteAudio").srcObject = null;
$("noVideo").style.display = "flex";
}
function updateMuteButton() {
const b = $("muteBtn");
if (!sipcall || !sipcall.isAudioMuted) return;
if (sipcall.isAudioMuted()) {
b.textContent = "🔇 Mic muted";
b.className = "danger";
} else {
b.textContent = "🎤 Mic on";
b.className = "success";
}
}
function updateCameraButton() {
const b = $("cameraBtn");
if (!sipcall || !sipcall.isVideoMuted) return;
if (sipcall.isVideoMuted()) {
b.textContent = "📷 Camera off";
b.className = "danger";
} else {
b.textContent = "📷 Camera on";
b.className = "success";
}
}
function updateSpeakerButton() {
const b = $("speakerBtn");
const audio = $("remoteAudio");
if (audio.muted) {
b.textContent = "🔇 Speaker off";
b.className = "danger";
} else {
b.textContent = "🔊 Speaker on";
b.className = "success";
}
}
$("muteBtn").addEventListener("click", () => {
if (!sipcall) return;
if (sipcall.isAudioMuted()) sipcall.unmuteAudio();
else sipcall.muteAudio();
setTimeout(updateMuteButton, 50);
});
$("cameraBtn").addEventListener("click", () => {
if (!sipcall || !sipcall.muteVideo) return;
if (sipcall.isVideoMuted()) sipcall.unmuteVideo();
else sipcall.muteVideo();
setTimeout(updateCameraButton, 50);
});
$("speakerBtn").addEventListener("click", () => {
const audio = $("remoteAudio");
audio.muted = !audio.muted;
updateSpeakerButton();
});
$("hangupBtn").addEventListener("click", () => {
if (!sipcall) return;
sipcall.send({ message: { request: "hangup" } });
sipcall.hangup();
});
$("rejoinBtn").addEventListener("click", () => location.reload());
// ============================================================================
// DTMF — try the in-band RTP-event path first (RFC 4733 via the handle's
// dtmf() method), which is what livekit-sip's IVR actually listens for.
// Fall back to SIP INFO if RTP DTMF isn't available (older Janus, no
// telephone-event in SDP, etc.).
// ============================================================================
function sendDtmf(digit) {
if (!sipcall || !inCall) return;
log("DTMF → " + digit);
let sent = false;
try {
if (typeof sipcall.dtmf === "function") {
sipcall.dtmf({
dtmf: { tones: String(digit), duration: 200, gap: 70 },
success: () => {},
error: (err) => log("dtmf() error: " + err, true),
});
sent = true;
}
} catch (e) {
log("dtmf() threw: " + e, true);
}
if (!sent) {
// Fallback: SIP INFO method
sipcall.send({
message: { request: "dtmf_info", digit: String(digit) },
});
}
}
document.querySelectorAll(".dialpad button").forEach(b => {
b.addEventListener("click", () => sendDtmf(b.dataset.d));
});
$("sendPin").addEventListener("click", () => {
const pin = $("pin").value.replace(/[^\d#*]/g, "");
if (!pin) return;
const tones = (pin.endsWith("#") ? pin : pin + "#");
log("DTMF batch → " + tones);
// Send all tones in ONE call. Per-digit looping with setTimeout races
// the WebRTC DTMF sender's internal queue and drops digits (we lost
// the trailing "0" before "#" doing it digit-by-digit). The browser's
// RTCDTMFSender paces them itself given duration/gap.
try {
sipcall.dtmf({
dtmf: { tones: tones, duration: 400, gap: 100 },
success: () => log("DTMF batch sent"),
error: (err) => log("dtmf() error: " + err, true),
});
} catch (e) {
log("dtmf() threw, falling back to SIP INFO per digit: " + e, true);
for (const ch of tones) {
sipcall.send({ message: { request: "dtmf_info", digit: String(ch) } });
}
}
$("pin").value = "";
// PIN + dialpad are no longer needed once you've submitted. Gateway
// either bridges you in (correct PIN) or hangs up with wrong-pin
// (the hangup event re-resets everything via endCallUi).
$("pin-row").classList.add("disabled");
$("dialpad").classList.add("disabled");
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-58
View File
@@ -100,13 +100,6 @@ sequenceDiagram
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
@@ -148,54 +141,3 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+12 -66
View File
@@ -185,7 +185,6 @@ paths:
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
configuration: {}
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
@@ -199,6 +198,10 @@ paths:
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
@@ -215,17 +218,8 @@ paths:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (use all defaults)
value: { }
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
@@ -275,7 +269,6 @@ paths:
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
configuration: {}
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
@@ -351,56 +344,8 @@ components:
RoomCreate:
type: object
description: |
Optional fields for room creation. All fields have secure defaults if omitted.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
@@ -417,7 +362,10 @@ components:
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
@@ -445,8 +393,6 @@ components:
type: string
description: Default country code
example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError:
type: object
+70 -69
View File
@@ -48,7 +48,7 @@ paths:
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the user has access will be returned.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
@@ -108,7 +108,6 @@ paths:
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
configuration: { }
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
@@ -121,9 +120,13 @@ paths:
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- user is set as owner
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
@@ -138,17 +141,8 @@ paths:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (use all defaults)
value: { }
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
@@ -198,7 +192,6 @@ paths:
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
configuration: { }
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
@@ -217,58 +210,65 @@ components:
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: |
Optional fields for room creation. All fields have secure defaults if omitted.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
@@ -285,7 +285,10 @@ components:
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
@@ -313,8 +316,6 @@ components:
type: string
description: Default country code
example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError:
type: object
-15
View File
@@ -68,24 +68,9 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony
ROOM_TELEPHONY_ENABLED=True
# Metadata
METADATA_COLLECTOR_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
-4
View File
@@ -1,4 +0,0 @@
WHISPERX_BASE_URL=https://configure-your-url.com
WHISPERX_API_KEY=<key>
LLM_BASE_URL=https://configure-your-url.com
LLM_API_KEY=<key>
@@ -1,9 +0,0 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai
ENABLE_SILERO_VAD=False
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
-8
View File
@@ -1,8 +0,0 @@
{
"plugins": [
"office-addins"
],
"extends": [
"plugin:office-addins/recommended"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": false
}
}
],
]
}
-190
View File
@@ -1,190 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion __APP_NAME__ à vos emails et événements Outlook."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://lasuite.crisp.help/fr/category/visio-15sakkg/"/>
<AppDomains>
<AppDomain>https://localhost:3000/</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>250</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Add.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Settings.16x16"/>
<bt:Image size="32" resid="Settings.32x32"/>
<bt:Image size="80" resid="Settings.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/assets/settings-16.png"/>
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/assets/settings-32.png"/>
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/assets/settings-80.png"/>
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/assets/add-16.png"/>
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/assets/add-32.png"/>
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/assets/add-80.png"/>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
-16231
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
{
"name": "office-addin-taskpane-js",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/suitenumerique/meet.git"
},
"license": "MIT",
"config": {
"app_to_debug": "outlook",
"app_type_to_debug": "desktop",
"dev_server_port": 3000
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev-server": "webpack serve --mode development",
"lint": "office-addin-lint check",
"lint:fix": "office-addin-lint fix",
"prettier": "office-addin-lint prettier",
"signin": "office-addin-dev-settings m365-account login",
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.49.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
"last 2 versions",
"ie 11"
]
}
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title data-app-name></title>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body></body>
</html>
-117
View File
@@ -1,117 +0,0 @@
/* global Office */
const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers");
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
});
function notify(message) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message,
persistent: false,
icon: "Icon.16x16",
});
}
function insertMeetingLink(event, session) {
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de lecture : ${getResult.error.message}`);
resolve();
return;
}
const newBody = getResult.value + message;
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur d'insertion : ${setResult.error.message}`);
resolve();
return;
}
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify("Lien de réunion inséré !");
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de localisation : ${locationResult.error.message}`);
} else {
notify("Lien de réunion inséré !");
}
resolve();
});
});
});
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
})
.finally(() => {
event.completed();
});
}
function connect(event) {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => {
insertMeetingLink(event, sessionData);
});
},
onTimeout: () => {
notify("Connexion expirée, veuillez réessayer.");
event.completed();
},
onError: (err) => {
notify("Une erreur est survenue, veuillez ré-essayer");
event.completed();
},
});
openTransitDialog(data.transit_token, {
onCancel: () => {
stopPolling();
event.completed();
},
onError: (err) => {
stopPolling();
event.completed();
},
});
})
.catch((err) => {
notify(`Erreur : ${err.message}`);
event.completed();
});
}
function generateMeetingLink(event) {
const session = loadSession();
if (session?.access_token) {
insertMeetingLink(event, session);
} else {
connect(event);
}
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLink);
Office.actions.associate("generateMeetingLinkFromMail", generateMeetingLink);
-82
View File
@@ -1,82 +0,0 @@
const { URLS } = require("./urls");
function getCsrfToken() {
return document.cookie
.split(";")
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
.map((cookie) => cookie.split("=")[1])
.pop();
}
function authHeaders(session) {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
};
}
/**
* Builds headers for CSRF-protected requests.
*
* Two CSRF flows coexist in this addon:
*
* 1. Cookie-based (Django default): used by `exchange`, called from the
* OAuth success page in a normal browser context. Django's CSRF
* middleware has already set the `csrftoken` cookie via the auth
* redirect, so we read it from `document.cookie` and echo it back
* as `X-CSRFToken`. The middleware verifies the header matches the
* cookie. No `csrfToken` argument needed — `getCsrfToken()` handles it.
*
* 2. Body-passed token: used by `poll`, called from the Office dialog /
* taskpane iframe. Cookie access inside Office iframes is unreliable
* across Outlook clients, so we can't depend on `document.cookie`
* being populated. Instead, `init` returns the CSRF token in its JSON
* response body, and callers pass it explicitly to subsequent calls.
* The token still travels as `X-CSRFToken` — only its source differs.
*
* The `csrfToken` parameter takes precedence when provided; falls back
* to the cookie when omitted.
*/
function csrfHeaders(csrfToken) {
const token = csrfToken || getCsrfToken();
return {
"Content-Type": "application/json",
...(token && { "X-CSRFToken": token }),
};
}
async function request(path, { session, csrf, csrfToken, ...opts } = {}) {
const headers = {
...(session && authHeaders(session)),
...(csrf && csrfHeaders(csrfToken)),
...opts.headers,
};
const res = await fetch(path, {
...opts,
headers,
credentials: csrf ? "include" : opts.credentials,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
module.exports = {
initSession: () => request(URLS.init, { method: "POST" }),
pollSession: (csrfToken) =>
request(URLS.poll, {
method: "POST",
csrf: true,
csrfToken,
}),
exchangeSession: (transitToken) =>
request(URLS.exchange, {
method: "POST",
csrf: true,
body: JSON.stringify({ transit_token: transitToken }),
}),
createRoom: (session) =>
request(URLS.rooms, {
method: "POST",
session,
}),
};
-16
View File
@@ -1,16 +0,0 @@
const { APP_NAME } = require("./index");
function isOfficeReady() {
return typeof Office !== "undefined" && Office?.context?.roamingSettings != null;
}
function applyAppName() {
document.querySelectorAll("[data-app-name]").forEach((el) => {
el.textContent = APP_NAME;
});
}
module.exports = {
isOfficeReady,
applyAppName,
};
-7
View File
@@ -1,7 +0,0 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
module.exports = {
BASE_URL,
APP_NAME,
};
@@ -1,52 +0,0 @@
const { APP_NAME } = require("./index");
function _formatPin(pin) {
if (!pin) return "";
const clean = String(pin).replace(/\s+/g, "");
if (!clean) return "";
if (/^\d{10}$/.test(clean)) {
return clean.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#";
}
return clean + "#";
}
// todo - support international format
function _formatPhone(phone) {
if (!phone) return "";
const clean = String(phone).replace(/\s+/g, "");
if (/^\+33\d{9}$/.test(clean)) {
return clean.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5");
}
return clean;
}
// todo - escape html / link
function buildMeetingMessage(data) {
if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data");
}
const url = data.url;
const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code);
const telephonyBlock =
phone && pin
? `
Ou appelez (audio uniquement)
(FR) ${phone}
Code : ${pin}`
: "";
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion ${APP_NAME}
<a href="${url}">${url}</a>${telephonyBlock}
────────────────────────────────────────</pre>`;
return { url, message };
}
module.exports = { buildMeetingMessage };
-47
View File
@@ -1,47 +0,0 @@
const { pollSession } = require("./api");
const POLLING_INTERVAL_MS = 1000;
const POLLING_TIMEOUT_MS = 3 * 60 * 1000;
const POLLING_MAX_ATTEMPTS = POLLING_TIMEOUT_MS / POLLING_INTERVAL_MS;
function isPollAuthenticated(sessionData) {
return sessionData.state === "authenticated" && sessionData.access_token;
}
function startPolling(csrfToken, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
let timeoutId = null;
let cancelled = false;
const poll = () => {
if (pollCount++ >= POLLING_MAX_ATTEMPTS) {
onTimeout?.();
return;
}
pollSession(csrfToken)
.then((sessionData) => {
if (cancelled) return;
if (isPollAuthenticated(sessionData)) {
onSuccess?.(sessionData);
return;
}
timeoutId = setTimeout(poll, POLLING_INTERVAL_MS);
})
.catch((err) => {
if (cancelled) return;
onError?.(err);
});
};
poll();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
module.exports = {
startPolling,
};
-104
View File
@@ -1,104 +0,0 @@
const { isOfficeReady } = require("./helpers");
const SESSION_KEY = "meetSession";
// DEV NOTE:
// Office.context.roamingSettings persists data in the user's mailbox and
// synchronizes it via Exchange across all Outlook clients (desktop, web, mobile)
// where the user signs in. This means anything stored here (including tokens)
// leaves the local device boundary and is replicated across environments.
//
// Microsoft guidance explicitly advises NOT storing secrets (e.g., OAuth access
// tokens, refresh tokens, or other sensitive credentials) in roamingSettings,
// as it is not a secure storage mechanism and lacks OS-level protections.
//
// That said, for the current alpha version we accept this trade-off for simplicity,
// with the expectation that a more secure approach (e.g., in-memory tokens) will replace this.
function saveSession(data) {
if (!isOfficeReady()) {
return Promise.reject(new Error("Office not ready"));
}
if (!data || !data.access_token) {
return Promise.reject(new Error("Missing access_token"));
}
const expiresInSeconds = Number(data.expires_in);
const expiresAt =
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
return new Promise((resolve, reject) => {
const rs = Office.context.roamingSettings;
rs.set(SESSION_KEY, payload);
rs.saveAsync((result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(new Error(result.error?.message || "saveAsync failed"));
}
});
});
}
function loadSession() {
if (!isOfficeReady()) {
return null;
}
let session = null;
try {
const stored = Office.context.roamingSettings.get(SESSION_KEY);
if (stored) session = JSON.parse(stored);
} catch (e) {
clearSession();
return null;
}
if (!session) return null;
// Fail closed if expiry is missing — backend is expected to send expires_in.
if (!session.expiresAt) {
clearSession();
return null;
}
const expiresTs = Date.parse(session.expiresAt);
if (!Number.isFinite(expiresTs) || Date.now() >= expiresTs) {
clearSession();
return null;
}
return session;
}
function clearSession() {
if (!isOfficeReady()) {
return Promise.resolve();
}
return new Promise((resolve) => {
try {
const rs = Office.context.roamingSettings;
rs.remove(SESSION_KEY);
rs.saveAsync((result) => {
resolve();
});
} catch (e) {
resolve();
}
});
}
module.exports = {
saveSession,
loadSession,
clearSession,
};
@@ -1,43 +0,0 @@
const { URLS } = require("./urls");
const DIALOG_SIGNALS = {
ready: "ready",
done: "done",
};
const DIALOG_HEIGHT = 60;
const DIALOG_WIDTH = 50;
function openTransitDialog(transitToken, { onCancel, onError }) {
Office.context.ui.displayDialogAsync(
URLS.transitDialog,
{ height: DIALOG_HEIGHT, width: DIALOG_WIDTH, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg) => {
if (arg.message === DIALOG_SIGNALS.ready) {
dialog.messageChild(transitToken);
return;
}
if (arg.message === DIALOG_SIGNALS.done) {
return;
}
onCancel?.();
dialog.close();
});
return dialog;
}
);
}
module.exports = {
openTransitDialog,
DIALOG_SIGNALS,
};
@@ -1,18 +0,0 @@
const TRANSIT_TOKEN_KEY = "transitToken";
function save(token) {
sessionStorage.setItem(TRANSIT_TOKEN_KEY, token);
}
function consume() {
try {
const token = sessionStorage.getItem(TRANSIT_TOKEN_KEY);
sessionStorage.removeItem(TRANSIT_TOKEN_KEY);
return token;
} catch (err) {
console.error("Failed to read transit token:", err);
return null;
}
}
module.exports = { save, consume };
-15
View File
@@ -1,15 +0,0 @@
const { BASE_URL } = require("./index");
const ADDONS_BASE_URL = `${BASE_URL}/api/v1.0/addons/sessions`;
const URLS = {
authenticate: `${BASE_URL}/api/v1.0/authenticate/`,
successPage: `${BASE_URL}/addons/outlook/success.html`,
transitDialog: `${BASE_URL}/addons/outlook/transit.html`,
init: `${ADDONS_BASE_URL}/init/`,
poll: `${ADDONS_BASE_URL}/poll/`,
exchange: `${ADDONS_BASE_URL}/exchange/`,
rooms: `${BASE_URL}/external-api/v1.0/rooms/`,
};
module.exports = { URLS };
-81
View File
@@ -1,81 +0,0 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#sideload-msg {
display: none;
}
#status {
display: none;
}
.spinner-container {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
}
.spinner-svg {
width: 56px;
height: 56px;
}
/* Background arc (light gray ring) */
.spinner-track {
stroke: #E5E7EB; /* primary.100 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
/* Foreground rotating arc */
.spinner-arc {
stroke: #000091; /* primary.800 equivalent */
fill: none;
stroke-width: 3;
stroke-linecap: round;
/* circumference = 2 * PI * r where r = 11 -> ~69.115 */
/* show 30% -> dashoffset = c - 0.3 * c = ~48.38 */
stroke-dasharray: 69.115 69.115;
stroke-dashoffset: 48.38;
transform-origin: center;
animation: spinner-rotate 1s ease-in-out infinite;
}
@keyframes spinner-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Hourglass fallback for reduced motion */
.spinner-fallback {
display: none;
color: #000091;
}
@media (prefers-reduced-motion: reduce) {
.spinner-svg {
display: none;
}
.spinner-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div class="spinner-container"
role="progressbar"
aria-label="Chargement..."
>
<svg class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken");
applyAppName();
const transitToken = consume();
if (!transitToken) {
console.error("Transit token not found in sessionStorage");
window.close();
} else {
exchangeSession(transitToken)
.catch((e) => {
console.error(`Error occured: ${e}`);
})
.finally(() => {
window.close();
});
}
File diff suppressed because one or more lines are too long
@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
</div>
<footer id="version-tag">
<span class="version-badge">alpha</span>
<span class="version-number">0.0.1</span>
</footer>
</body>
</html>
-128
View File
@@ -1,128 +0,0 @@
const { APP_NAME } = require("../common");
const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder");
// todo - support loading view while polling
// todo - support error view
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
}
function connect() {
initSession()
.then((data) => {
const stopPolling = startPolling(data.csrf_token, {
onSuccess: (sessionData) => {
saveSession(sessionData).then(() => showView("auth"));
},
onTimeout: () => {
showView("unauth");
},
onError: (err) => {
console.error(err);
},
});
openTransitDialog(data.transit_token, {
onCancel: () => stopPolling(),
onError: (err) => {
stopPolling();
},
});
})
.catch((err) => {
console.error(err);
});
}
function disconnect() {
clearSession().finally(() => showView("unauth"));
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
console.error("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
_setButtonLoading();
createRoom(session)
.then((data) => {
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
return new Promise((resolve, reject) => {
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(getResult.error);
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
}
);
});
});
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setButtonIdle();
});
}
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
applyAppName();
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
showView("auth");
} else {
showView("unauth");
}
}
});
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-app-name></title>
<link rel="stylesheet" href="../styles/spinner.css" />
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div
class="spinner-container"
role="progressbar"
aria-label="Chargement..."
>
<svg
class="spinner-svg"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background track -->
<circle class="spinner-track" cx="14" cy="14" r="11"
/>
<!-- Rotating arc -->
<circle class="spinner-arc" cx="14" cy="14" r="11"
/>
</svg>
<!-- Fallback hourglass icon (Remix Icon RiHourglassFill SVG path) -->
<span class="spinner-fallback" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
style="display: block; transform: translateY(1px);"
>
<path d="M6 2H18V4L13 12L18 20V22H6V20L11 12L6 4V2ZM8.535 4L13 11.143L17.465 4H8.535Z"/>
</svg>
</span>
</div>
</body>
</html>
-53
View File
@@ -1,53 +0,0 @@
const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog");
// Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() {
const url = new URL(URLS.authenticate);
url.searchParams.set("returnTo", URLS.successPage);
return url.toString();
}
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) {
applyAppName();
}
Office.context.ui.addHandlerAsync(
Office.EventType.DialogParentMessageReceived,
function (arg) {
const transitToken = arg.message;
if (typeof transitToken !== "string" || transitToken.trim() === "") {
console.error("Invalid transit token received from parent dialog.");
return;
}
// Runs inside the dialog window.
// Flow:
// transit.html saves token → navigates to /authenticate → OAuth redirect →
// success.html. sessionStorage survives because it's per-window-per-origin
// and the dialog window persists across same-origin navigations.
// Fragile: if the IdP opens the redirect in a new tab/window, this breaks
// silently.
// An alternative could be to pass the token via the OAuth `state` param
// and read it back from the redirect URL.
try {
save(transitToken);
Office.context.ui.messageParent(DIALOG_SIGNALS.done);
window.location.href = getAuthenticateUrl();
} catch (err) {
console.error("Failed to store transit token:", err);
}
},
function (result) {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("Failed to register DialogParentMessageReceived handler.", result.error);
return;
}
Office.context.ui.messageParent(DIALOG_SIGNALS.ready);
}
);
});
-128
View File
@@ -1,128 +0,0 @@
/* eslint-disable no-undef */
const devCerts = require("office-addin-dev-certs");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
}
module.exports = async (env, options) => {
const config = {
devtool: "source-map",
entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js",
transit: ["./src/transit/transit.js", "./src/transit/transit.html"],
success: ["./src/success/success.js", "./src/success/success.html"],
},
output: {
clean: true,
},
resolve: {
extensions: [".html", ".js"],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
exclude: /node_modules/,
use: {
loader: "html-loader",
options: {
sources: {
urlFilter: (attribute, value) => {
// Don't try to resolve the runtime-injected config
if (value.includes("config.js")) {
return false;
}
return true;
},
},
},
},
},
{
test: /\.(png|jpg|jpeg|gif|ico)$/,
type: "asset/resource",
generator: {
filename: "assets/[name][ext][query]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new CopyWebpackPlugin({
patterns: [
{
from: "assets/*",
to: "assets/[name][ext][query]",
}
],
}),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/commands/commands.html",
chunks: ["polyfill", "commands"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new HtmlWebpackPlugin({
filename: "transit.html",
template: "./src/transit/transit.html",
chunks: ["polyfill", "transit"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new HtmlWebpackPlugin({
filename: "success.html",
template: "./src/success/success.html",
chunks: ["polyfill", "success"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new htmlWebpackInjectAttributesPlugin(),
],
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
server: {
type: "https",
options:
env.WEBPACK_BUILD || options.https !== undefined
? options.https
: await getHttpsOptions(),
},
port: process.env.npm_package_config_dev_server_port || 3000,
},
};
return config;
};
-38
View File
@@ -1,38 +0,0 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+11 -50
View File
@@ -1,4 +1,4 @@
FROM python:3.13.13-slim AS base
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
@@ -6,67 +6,28 @@ RUN apt-get update && apt-get install -y \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /builder
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
COPY pyproject.toml .
WORKDIR /app
RUN mkdir /install && \
pip install --prefix=/install .
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production
WORKDIR /app
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
CMD ["python", "multi_user_transcriber.py", "start"]
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
-5
View File
@@ -1,5 +0,0 @@
"""Storage parsers specific exceptions."""
class MissingConfigError(Exception):
"""Raised when a variable is not set in configuration."""
-382
View File
@@ -1,382 +0,0 @@
"""Metadata agent that extracts metadata from active room."""
import asyncio
import json
import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentServer,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
load_dotenv()
logger = logging.getLogger("metadata-collector")
AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
server = AgentServer(
permissions=WorkerPermissions(
can_publish=False,
can_publish_data=False,
can_subscribe=True,
hidden=True,
),
)
server.setup_fnc = prewarm
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
participant_id: str
type: str
timestamp: datetime
data: Optional[str] = None
def serialize(self) -> dict:
"""Return a JSON-serializable dictionary representation of the event."""
data = asdict(self)
data["timestamp"] = self.timestamp.isoformat()
return data
class VADAgent(Agent):
"""Agent that monitors voice activity for a specific participant."""
def __init__(self, participant_identity: str, events: List):
"""Initialize with a participant identity and shared events list."""
super().__init__(
instructions="not-needed",
)
self.participant_identity = participant_identity
self.events = events
async def on_enter(self) -> None:
"""Initialize VAD monitoring for this participant."""
@self.session.on("user_state_changed")
def on_user_state(event):
timestamp = datetime.now(timezone.utc)
if event.new_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_start",
timestamp=timestamp,
)
self.events.append(event)
elif event.old_state == "speaking":
event = MetadataEvent(
participant_id=self.participant_identity,
type="speech_end",
timestamp=timestamp,
)
self.events.append(event)
class MetadataCollector:
"""Collect meeting events across all participants in a room.
Creates one AgentSession per participant to capture VAD events
(speech start/end), and listens for connection, disconnection,
and chat events. Persists all collected events as JSON to S3
on shutdown.
"""
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
output_folder = os.getenv("AWS_S3_OUTPUT_FOLDER", "metadata")
self.output_filename = f"{output_folder}/{recording_id}-metadata.json"
# Storage for events
self.events = []
self.participants = {}
logger.info("MetadataCollector initialized")
def start(self):
"""Start listening for room-level events."""
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.on("participant_name_changed", self.on_participant_name_changed)
self.ctx.room.register_text_stream_handler("lk.chat", self.handle_chat_stream)
logger.info("Started listening for participant events")
async def on_chat_message_received(
self, reader: rtc.TextStreamReader, participant_identity: str
):
"""Read a complete chat message and record it as an event."""
full_text = await reader.read_all()
logger.info("Received chat message from %s", participant_identity)
self.events.append(
MetadataEvent(
participant_id=participant_identity,
type="chat_received",
timestamp=datetime.now(timezone.utc),
data=full_text,
)
)
def handle_chat_stream(self, reader, participant_identity):
"""Schedule async processing of an incoming chat stream."""
task = asyncio.create_task(
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
participants.append({"participantId": k, "name": v})
sorted_events = sorted(self.events, key=lambda e: e.timestamp)
payload = {
"events": [event.serialize() for event in sorted_events],
"participants": participants,
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
async def aclose(self):
"""Close all sessions and cleanup resources."""
logger.info("Closing all VAD monitoring sessions…")
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()],
return_exceptions=True,
)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
self.ctx.room.off("participant_name_changed", self.on_participant_name_changed)
logger.info("All VAD sessions closed")
self.save()
async def on_participant_entrypoint(
self, ctx: JobContext, participant: rtc.RemoteParticipant
):
"""Handle new participant by starting a VAD monitoring session."""
if participant.identity in self._sessions:
logger.debug("Session already exists for %s", participant.identity)
return
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_connected",
timestamp=datetime.now(timezone.utc),
)
)
self.participants[participant.identity] = participant.name
logger.info("New participant connected: %s", participant.identity)
try:
session = await self._start_session(participant)
self._sessions[participant.identity] = session
except Exception:
logger.exception("Failed to start session for %s", participant.identity)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing VAD monitoring."""
self.events.append(
MetadataEvent(
participant_id=participant.identity,
type="participant_disconnected",
timestamp=datetime.now(timezone.utc),
)
)
session = self._sessions.pop(participant.identity, None)
if session is None:
logger.debug("No session found for %s", participant.identity)
return
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
logger.info("Participant's name changed: %s", participant.identity)
self.participants[participant.identity] = participant.name
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start VAD monitoring session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
# Create session with VAD only - no STT, LLM, or TTS
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
turn_detection="vad",
user_away_timeout=30.0,
)
# Set up room IO to receive audio from this specific participant
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
audio_enabled=True,
text_enabled=False,
),
output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
),
)
await room_io.start()
await session.start(
agent=VADAgent(
participant_identity=participant.identity, events=self.events
)
)
return session
async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session."""
try:
await session.drain()
await session.aclose()
except Exception:
logger.exception("Error closing session")
async def handle_job_request(job_req: JobRequest) -> None:
"""Accept or reject the job request based on agent presence in the room."""
room_name = job_req.room.name
recording_id = job_req.job.metadata
agent_identity = f"{AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lk:
try:
resp = await lk.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
already_present = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == agent_identity
for p in resp.participants
)
if already_present:
logger.info("Agent already in the room '%s' — reject", room_name)
await job_req.reject()
else:
logger.info(
"Accept job for '%s' — identity=%s", room_name, agent_identity
)
await job_req.accept(identity=agent_identity, metadata=recording_id)
except Exception:
logger.exception("Error treating the job for '%s'", room_name)
await job_req.reject()
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
metadata_collector.start()
ctx.add_participant_entrypoint(metadata_collector.on_participant_entrypoint)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
cli.run_app(server)
+5 -5
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.18.0"
version = "1.12.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
@@ -9,8 +9,7 @@ dependencies = [
"livekit-plugins-silero==1.4.5",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.5",
"minio==7.2.15"
"protobuf==6.33.5"
]
[project.optional-dependencies]
@@ -18,8 +17,9 @@ dev = [
"ruff==0.15.6",
]
[tool.uv]
package = false
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
-1963
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
"""Meet core add-ons module."""
-344
View File
@@ -1,344 +0,0 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from core.models import User
from core.services.jwt_token import JwtTokenService
logger = getLogger(__name__)
_PUBLIC_SESSION_FIELDS = frozenset(
{"state", "access_token", "token_type", "expires_in", "scope"}
)
class SessionDataError(Exception):
"""Raised when session data is invalid or malformed."""
class CSRFTokenError(Exception):
"""Raised when CSRF token verification fails."""
class TransitTokenError(Exception):
"""Raised when a transit token is invalid or expired."""
class SessionExpiredError(Exception):
"""Raised when a session has expired."""
class SessionNotFoundError(Exception):
"""Raised when a session is not found."""
class SuspiciousSessionError(Exception):
"""Raised when session state indicates a possible attack or bug."""
class SessionState(str, Enum):
"""Add-on authentication session lifecycle states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TransitTokenState(str, Enum):
"""Transit token lifecycle states; CONSUMED is retained to detect replay."""
PENDING = "pending"
CONSUMED = "consumed"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Build the underlying JWT service and validate required settings."""
if not settings.ADDONS_CSRF_SECRET:
raise ImproperlyConfigured("CSRF Secret is required.")
if not settings.ADDONS_TOKEN_SCOPE:
raise ImproperlyConfigured("Token scope must be defined.")
self._token_service = JwtTokenService(
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
algorithm=settings.ADDONS_TOKEN_ALG,
issuer=settings.ADDONS_TOKEN_ISSUER,
audience=settings.ADDONS_TOKEN_AUDIENCE,
expiration_seconds=settings.ADDONS_TOKEN_TTL,
token_type=settings.ADDONS_TOKEN_TYPE,
)
@staticmethod
def _cache_key(prefix: str, token: str) -> str:
"""Build a namespaced cache key: ``addons_{prefix}_{token}``."""
return f"addons_{prefix}_{token}"
@staticmethod
def _derive_csrf_token(session_id: str) -> str:
"""Derive the CSRF token as HMAC-SHA256(session_id) under ADDONS_CSRF_SECRET."""
return hmac.new(
settings.ADDONS_CSRF_SECRET.encode("utf-8"),
session_id.encode("utf-8"),
hashlib.sha256,
).hexdigest()
@staticmethod
def _validate_session_not_expired(session_data: dict) -> int:
"""Return remaining seconds until expiry, or raise if missing/malformed/expired."""
expires_at_str = session_data.get("expires_at")
if expires_at_str is None:
raise SessionDataError("Invalid session data: missing expiration.")
try:
expires_at = datetime.fromisoformat(expires_at_str)
except ValueError as e:
raise SessionDataError("Invalid session data: malformed expiration.") from e
remaining_seconds = int(
(expires_at - datetime.now(timezone.utc)).total_seconds()
)
if remaining_seconds <= 0:
raise SessionExpiredError("Session expired.")
return remaining_seconds
def _generate_session_id(self) -> str:
"""Generate a high-entropy URL-safe session_id."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def _generate_transit_token(self) -> str:
"""Generate a high-entropy URL-safe transit token."""
return secrets.token_urlsafe(settings.ADDONS_RANDOM_TOKEN_BYTE_LENGTH)
def init_session(self) -> tuple[str, str, str]:
"""Create a new pending session and its transit binding.
Returns:
(transit_token, session_id, csrf_token)
"""
session_id = self._generate_session_id()
transit_token = self._generate_transit_token()
csrf_token = self._derive_csrf_token(session_id)
expires_at = (
datetime.now(timezone.utc) + timedelta(seconds=settings.ADDONS_SESSION_TTL)
).isoformat()
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at,
"transit_token": transit_token,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
session_data,
settings.ADDONS_SESSION_TTL,
)
transit_token_data = {
"session_id": session_id,
"state": TransitTokenState.PENDING,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token),
transit_token_data,
settings.ADDONS_TRANSIT_TOKEN_TTL,
)
return transit_token, session_id, csrf_token
def verify_csrf(self, session_id: str, submitted_csrf: str) -> None:
"""Constant-time verify submitted_csrf against HMAC(session_id). Raise on mismatch."""
expected_csrf = self._derive_csrf_token(session_id)
if not hmac.compare_digest(expected_csrf, submitted_csrf):
raise CSRFTokenError("Invalid CSRF token.")
def consume_transit_token(self, transit_token: str) -> str:
"""Mark transit token consumed and return its session_id.
A replay (second consume) evicts the session as a security cleanup and raises.
Raises:
TransitTokenError: If token is unknown, expired, or already consumed.
"""
cache_key = self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
transit_token_data = cache.get(cache_key)
if transit_token_data is None:
# Indistinguishable from here: either the token was never issued (attacker
# probing or client bug) or it was issued but expired before consumption.
logger.warning(
"Transit token not found in cache (unknown or expired).",
)
raise TransitTokenError("Invalid or expired transit token.")
state = transit_token_data.get("state", None)
session_id = transit_token_data.get("session_id", None)
if not session_id:
logger.warning("Transit token data missing session_id.")
raise TransitTokenError("Invalid transit token.")
if state == TransitTokenState.CONSUMED:
logger.warning(
"Replay on session %s",
session_id,
)
# Security cleanup: a replay attempt means the transit token leaked
# (or an attacker is probing). Evict the session so the authenticated
# tokens — if they exist — can no longer be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise TransitTokenError("Transit token already consumed.")
new_transit_token_data = {
"state": TransitTokenState.CONSUMED,
"session_id": session_id,
}
cache.set(
cache_key,
new_transit_token_data,
settings.ADDONS_SESSION_TTL,
)
return session_id
@staticmethod
def is_session_pending(session_data: dict) -> bool:
"""Return True if the public session dict is still in the pending state."""
return session_data.get("state") == SessionState.PENDING
def _get_session_data(self, session_id: str) -> dict:
"""Fetch raw session data from cache, or raise SessionNotFoundError."""
if not session_id:
raise SessionNotFoundError("Session not found.")
data = cache.get(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
if data is None:
raise SessionNotFoundError("Session not found.")
return data
def get_session(self, session_id: str) -> dict:
"""Return the public session view; evict the session on authenticated read.
Raises:
SessionNotFoundError: If session is not found.
SessionDataError: If session data is missing the state field.
"""
# raises if session is not found
session_data = self._get_session_data(session_id)
if "state" not in session_data:
raise SessionDataError("Invalid session data: missing state field.")
# One-time read: clear both bindings for authenticated sessions
if session_data["state"] == SessionState.AUTHENTICATED:
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
# Return public fields only
return {k: v for k, v in session_data.items() if k in _PUBLIC_SESSION_FIELDS}
def _validate_transit_token_state(self, session_data: dict) -> None:
"""Assert the session's transit token exists in cache and is in CONSUMED state.
Raises:
SessionDataError: session_data is missing the transit_token field.
SuspiciousSessionError: transit entry is missing, or still pending (flow skipped).
"""
transit_token = session_data.get("transit_token", None)
if transit_token is None:
raise SessionDataError("Invalid session data: missing transit_token field.")
transit_token_data = cache.get(
self._cache_key(settings.ADDONS_CACHE_PREFIX_TRANSIT, transit_token)
)
if transit_token_data is None:
logger.warning("Transit token missing when setting access token.")
raise SuspiciousSessionError("Transit token not found.")
if transit_token_data.get("state") != TransitTokenState.CONSUMED:
logger.warning("Access token requested without completing transit flow.")
raise SuspiciousSessionError("Transit token not consumed.")
def set_access_token(self, user: User, session_id: str) -> None:
"""Authenticate a pending session by minting a JWT and storing it on the session.
Non-pending sessions are evicted as a security cleanup before raising.
Raises:
SessionNotFoundError: If session doesn't exist.
SessionDataError: If session data is malformed.
SessionExpiredError: If session has expired.
SuspiciousSessionError: If session is not pending or transit wasn't consumed.
"""
# raises if session is not found
session_data = self._get_session_data(session_id)
if session_data.get("state") != SessionState.PENDING:
logger.warning(
"Session's state is not pending. Suspicious.",
)
# Security cleanup: evict the session so any cached tokens cannot be polled.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise SuspiciousSessionError("Session is not in pending state.")
# raises if transit_token is invalid
try:
self._validate_transit_token_state(session_data)
except SuspiciousSessionError:
# Security cleanup: evict the session.
cache.delete(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id)
)
raise
# raises if session is expired
remaining_seconds = self._validate_session_not_expired(session_data)
response = self._token_service.generate_jwt(user, settings.ADDONS_TOKEN_SCOPE)
new_data = {
"access_token": response["access_token"],
"token_type": response["token_type"],
"expires_in": response["expires_in"],
"scope": response["scope"],
"expires_at": session_data["expires_at"],
"state": SessionState.AUTHENTICATED,
}
cache.set(
self._cache_key(settings.ADDONS_CACHE_PREFIX_SESSION, session_id),
new_data,
remaining_seconds,
)
-229
View File
@@ -1,229 +0,0 @@
"""Add-ons API endpoints"""
from logging import getLogger
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from rest_framework import decorators, viewsets
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from core.addons.service import (
CSRFTokenError,
SessionDataError,
SessionExpiredError,
SessionNotFoundError,
SuspiciousSessionError,
TokenExchangeService,
TransitTokenError,
)
from core.api.feature_flag import FeatureFlag
from core.api.permissions import IsAuthenticated
logger = getLogger(__name__)
class SessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange.
Implements a three-step flow that lets a third-party add-on (running in an
embedded iframe) obtain an access token without exposing it to client-side
JavaScript:
1. /init: the add-on opens a session and receives a short-lived transit
token (used to bootstrap the OAuth-style exchange in a dialog) and a
CSRF token. The opaque session id is stored in an HttpOnly, Secure,
SameSite=None cookie so it can accompany cross-origin polls.
2. /poll: the add-on polls until the session transitions from pending to
authenticated. On the terminal read, the session payload (access
token, token type, expiry, etc.) is returned, the session is evicted
server-side, and the session cookie is cleared so the tokens can be
retrieved exactly once.
3. /exchange: called from the post-login callback page on our own domain,
after the user has authenticated in a dialog opened by the addon. The
transit token (carried client-side via postMessage + sessionStorage)
is redeemed here for the authenticated user's access token, which is
stored server-side against the session. Requires an authenticated
user — that user is whose access token gets bound to the session.
/init and /poll authenticate the caller through the session cookie +
CSRF token pair alone — no user login is required, since the whole point
of the flow is to bootstrap one. /exchange, by contrast, requires an
authenticated user and does not use the addonsSid cookie.
"""
throttle_classes = []
@decorators.action(
detail=False,
methods=["POST"],
url_path="init",
authentication_classes=[],
permission_classes=[],
)
@FeatureFlag.require("addons")
def init(self, request):
"""Open a new add-on authentication session.
Creates a fresh session server-side and returns the credentials the
add-on needs to drive the rest of the flow.
"""
transit_token, session_id, csrf_token = TokenExchangeService().init_session()
response = drf_response.Response(
{"transit_token": transit_token, "csrf_token": csrf_token},
status=drf_status.HTTP_201_CREATED,
)
# SameSite=None allows the cookie to be sent on cross-origin requests,
# which is required because the /poll endpoint is called from an iframe
# embedded in a third-party site. Secure=True is mandatory when SameSite=None.
# HttpOnly prevents JS access, so the cookie can only be read by the server.
response.set_cookie(
key=settings.ADDONS_SESSION_ID_COOKIE,
value=session_id,
max_age=settings.ADDONS_SESSION_TTL,
httponly=True,
secure=True,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["POST"],
url_path="poll",
authentication_classes=[],
permission_classes=[],
)
@FeatureFlag.require("addons")
def poll(self, request):
"""Poll a session for its current state and, if terminal, consume it.
Authenticates the caller using the addonsSid cookie (set by
/init) together with the X-CSRFToken header, which must match
the CSRF token issued for that session. The session id alone is not
sufficient — both must be presented and must correspond.
Behavior depends on the session's current state:
- **Pending**: the token exchange has not yet completed. Returns
202 Accepted with `{"state": "pending"}`. The cookie is preserved
so the add-on can keep polling.
- **Authenticated** (or any other terminal state): returns 200 OK
with the session payload (access token, token type, expiry, etc.)
and clears the `addonsSid` cookie. The session is also evicted
server-side on this terminal read, so the tokens can be retrieved
exactly once.
A CSRF mismatch is treated as a `SuspiciousOperation` rather than a
normal 4xx, so it is logged by Django's security middleware and
surfaced as a 400 without leaking which check failed.
"""
session_id = request.COOKIES.get(settings.ADDONS_SESSION_ID_COOKIE)
submitted_csrf = request.headers.get("X-CSRFToken")
if not session_id:
return drf_response.Response(
{"detail": "Missing credentials."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
if not submitted_csrf:
return drf_response.Response(
{"detail": "Missing CSRF token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
service = TokenExchangeService()
try:
service.verify_csrf(session_id, submitted_csrf)
except CSRFTokenError as e:
raise SuspiciousOperation(str(e)) from e
try:
session = service.get_session(session_id)
except SessionNotFoundError:
return drf_response.Response(
{"detail": "Session not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
except SessionDataError:
return drf_response.Response(
{"detail": "Invalid or expired session."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
if service.is_session_pending(session):
return drf_response.Response(
{"state": "pending"}, status=drf_status.HTTP_202_ACCEPTED
)
response = drf_response.Response(session, status=drf_status.HTTP_200_OK)
response.delete_cookie(
key=settings.ADDONS_SESSION_ID_COOKIE,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["POST"],
url_path="exchange",
permission_classes=[IsAuthenticated],
)
@FeatureFlag.require("addons")
def exchange(self, request):
"""Redeem a transit token for an access token bound to the current user.
Called from the post-OIDC callback page on our own domain. The transit
token was issued by /init, passed to the authentication dialog via
postMessage, stashed in sessionStorage, and read back by this page
after login completes.
The authenticated user (request.user) is whose access token gets stored
against the session. On success, the addon's next /poll will transition
from pending to authenticated and receive the token payload.
Transit tokens are single-use: a replayed token is rejected with 400.
"""
transit_token = request.data.get("transit_token")
if not transit_token:
return drf_response.Response(
{"detail": "Missing transit_token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
service = TokenExchangeService()
try:
session_id = service.consume_transit_token(transit_token)
except TransitTokenError:
return drf_response.Response(
{"detail": "Invalid or expired transit token."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
try:
service.set_access_token(request.user, session_id)
except SessionNotFoundError:
return drf_response.Response(
{"detail": "Session not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
except (SessionDataError, SessionExpiredError, SuspiciousSessionError):
return drf_response.Response(
{"detail": "Invalid or expired session."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
return drf_response.Response({"status": "ok"}, status=drf_status.HTTP_200_OK)
+10 -3
View File
@@ -8,8 +8,6 @@ from rest_framework import views as drf_views
from rest_framework.decorators import api_view
from rest_framework.response import Response
from core.utils import build_telephony_config
def exception_handler(exc, context):
"""Handle Django ValidationError as an accepted exception.
@@ -60,7 +58,13 @@ def get_frontend_configuration(request):
"allowed_mimetypes"
],
},
"telephony": build_telephony_config(),
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
@@ -69,5 +73,8 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration["encryption"] = {
"enabled": settings.ENCRYPTION_ENABLED,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
-2
View File
@@ -14,8 +14,6 @@ class FeatureFlag:
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
}
@classmethod
-30
View File
@@ -136,33 +136,3 @@ class FilePermission(IsAuthenticated):
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+57 -47
View File
@@ -13,8 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -31,8 +30,17 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = [
"id",
"sub",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_encryption",
]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer):
@@ -75,6 +83,7 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied(
"Only owners of a room can assign other users as owners."
)
return data
def validate_resource(self, resource):
@@ -129,17 +138,43 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"is_encrypted",
"encryption_paused",
]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
def validate_is_encrypted(self, value):
"""is_encrypted is set at creation and is part of the link's identity
(copy-link always carries the hash for an encrypted room). Mid-call
toggling is done via encryption_paused, not by mutating this flag."""
instance = self.instance
if instance and instance.is_encrypted != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation. "
"Use encryption_paused to temporarily suspend encryption."
)
return value
def validate_encryption_paused(self, value):
"""encryption_paused only makes sense on encrypted rooms. Allow either
direction (True ↔ False) post-creation: admins suspend E2EE so a
SIP/device caller can join, then resume once they hang up."""
is_encrypted = (
self.instance.is_encrypted
if self.instance is not None
else self.initial_data.get("is_encrypted", False)
)
if value and not is_encrypted:
raise serializers.ValidationError(
"Cannot pause encryption on a non-encrypted room."
)
return value
def to_representation(self, instance):
@@ -166,6 +201,11 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -178,11 +218,12 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=output["configuration"],
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -238,14 +279,11 @@ class RecordingOptions(BaseModel):
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
collect_metadata: Whether to collect additional metadata during recording.
When `None`, no metadata are collected.
"""
language: str | None = None
transcribe: bool | None = None
collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
@@ -274,7 +312,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
username = serializers.CharField(required=True, allow_blank=True)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -312,19 +350,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
"""Validate room configuration structure.
Unknown fields are rejected.
"""
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
@@ -346,10 +372,6 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -551,15 +573,3 @@ class CreateFileSerializer(ListFileSerializer):
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
class RaiseHandSerializer(BaseValidationOnlySerializer):
"""Serializer for raising or lowering a participant's hand in a room."""
raised = serializers.BooleanField()
class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
+105 -311
View File
@@ -6,13 +6,10 @@ from logging import getLogger
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
@@ -33,7 +30,6 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core.api.filters import ListFileFilter
@@ -48,10 +44,6 @@ from core.recording.event.exceptions import (
)
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -72,16 +64,10 @@ from core.services.lobby import (
LobbyService,
)
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -295,6 +281,14 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
is_encrypted = serializer.validated_data.get("is_encrypted", False)
# Block encrypted room creation if encryption is not enabled on this instance
if is_encrypted and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
{"is_encrypted": "Encryption is not enabled on this server."}
)
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
@@ -305,41 +299,6 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -355,35 +314,32 @@ class RoomViewSet(
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
print(
"[start-recording] body=",
request.data,
"errors=",
serializer.errors,
flush=True,
)
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
{"detail": "Invalid request.", "errors": serializer.errors},
status=drf_status.HTTP_400_BAD_REQUEST,
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
room = self.get_object()
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
# IntegrityError covers the race where two concurrent requests both
# pass that check and the DB-level UNIQUE constraint catches the loser.
return drf_response.Response(
{"error": f"A recording is already in progress for room {room.slug}"},
status=drf_status.HTTP_409_CONFLICT,
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
)
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
@@ -391,23 +347,11 @@ class RoomViewSet(
try:
worker_manager.start(recording)
except RecordingStartError:
models.Recording.objects.filter(pk=recording.pk).update(
status=models.RecordingStatusChoices.FAILED_TO_START
)
return drf_response.Response(
{"error": f"Recording failed to start for room {room.slug}"},
status=drf_status.HTTP_502_BAD_GATEWAY,
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if settings.METADATA_COLLECTOR_ENABLED and (
recording.options.get("collect_metadata", False)
):
try:
MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
@@ -468,12 +412,14 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True)
room = self.get_object()
validated_data = serializer.validated_data
lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
**serializer.validated_data,
**validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
@@ -536,6 +482,7 @@ class RoomViewSet(
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -655,11 +602,7 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
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."""
@@ -668,37 +611,12 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
@@ -737,11 +655,6 @@ class RoomViewSet(
permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"),
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
@@ -774,11 +687,6 @@ class RoomViewSet(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
@@ -789,92 +697,6 @@ class RoomViewSet(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="toggle-hand",
url_name="toggle-hand",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
"""Raise or lower the current participant's hand in the room."""
room = self.get_object()
serializer = serializers.RaiseHandSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
# means the hand is raised. The timestamp is used by clients to determine
# the order in which participants raised their hands.
hand_raised_at = (
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
attributes={"handRaisedAt": hand_raised_at},
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant hand state"},
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="rename",
url_name="rename",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def rename(self, request, pk=None): # pylint: disable=unused-argument
"""Rename the current participant in the room."""
room = self.get_object()
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
name=serializer.validated_data["name"],
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to rename 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,
@@ -1196,124 +1018,96 @@ class FileViewSet(
"""
Check the actual uploaded file and mark it as ready.
"""
# Ensures we go through authorization checks
file = self.get_object()
# Try to update the file with the new state. If the file is already in this state
# we are in a concurrent request, and we should reject that request
updated_rows = models.File.objects.filter(
upload_state=models.FileUploadStateChoices.PENDING,
pk=kwargs["pk"],
).update(upload_state=models.FileUploadStateChoices.ANALYZING)
if updated_rows != 1:
if not file.is_pending_upload:
raise drf_exceptions.ValidationError(
{"file": "This action is only available for files in PENDING state."},
code="file_upload_state_not_pending",
)
file.refresh_from_db()
s3_client = default_storage.connection.meta.client
validation_error = None
try:
# We copy the file to its final destination, we will run the checks on that
# final file and ignore any updates to the temporary file. (We cannot revoke the policy,
# so the temporary file might still be updated after that.)
# The temporary folders will need to be cleaned periodically
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
self._complete_file_deletion(file)
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
self._complete_file_deletion(file)
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.temporary_file_key,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and
# the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
else:
# Use improved MIME type detection combining magic bytes and file extension
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
if validation_error is not None:
self._complete_file_deletion(file)
else:
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
except Exception as e:
logger.exception("Failed to analyze file, reverting to pending state")
file.upload_state = models.FileUploadStateChoices.PENDING
file.save()
raise e
if validation_error:
raise validation_error
# Not yet implemented
# Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id)
+2 -11
View File
@@ -14,19 +14,10 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
auth_header = request.headers.get("Authorization")
if not auth_header:
token = request.data.get("token")
if not token:
return None # No authentication attempted
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
)
token = parts[1]
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -23,14 +23,7 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__(
self,
secret_key,
algorithm,
issuer,
audience,
expiration_seconds,
token_type,
is_enabled,
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
@@ -41,17 +34,10 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
is_enabled: Whether this authentication backend is active
"""
super().__init__()
self.is_enabled = is_enabled
self._token_service = None
if not self.is_enabled:
return
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
@@ -68,9 +54,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Tuple of (user, payload) if authentication successful, None otherwise
"""
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
@@ -203,7 +186,6 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
is_enabled=settings.APPLICATION_ENABLED,
)
def validate_payload(self, payload):
@@ -232,26 +214,6 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
raise exceptions.AuthenticationFailed("Invalid token type.")
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
Validates JWT tokens issued to addons.
"""
def __init__(self):
"""Initialize authentication backend with addons JWT settings from Django settings."""
super().__init__(
secret_key=settings.ADDONS_TOKEN_SECRET_KEY,
algorithm=settings.ADDONS_TOKEN_ALG,
issuer=settings.ADDONS_TOKEN_ISSUER,
audience=settings.ADDONS_TOKEN_AUDIENCE,
expiration_seconds=settings.ADDONS_TOKEN_TTL,
token_type=settings.ADDONS_TOKEN_TYPE,
is_enabled=settings.ADDONS_ENABLED,
)
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
+4 -35
View File
@@ -4,11 +4,10 @@
from django.conf import settings
from pydantic import ValidationError
from rest_framework import serializers
from core import models, utils
from core.api.serializers import BaseValidationOnlySerializer, RoomConfiguration
from core.api.serializers import BaseValidationOnlySerializer
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
@@ -35,37 +34,10 @@ class RoomSerializer(serializers.ModelSerializer):
following the principle of least privilege.
"""
configuration = serializers.JSONField(required=False)
class Meta:
model = models.Room
fields = ["id", "name", "slug", "pin_code", "access_level", "configuration"]
read_only_fields = ["id", "name", "slug", "pin_code"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except ValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
def validate_access_level(self, access_level):
"""Reject public access_level unless explicitly allowed or the default is already public."""
if settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL == models.RoomAccessLevel.PUBLIC:
return access_level
if (
access_level == models.RoomAccessLevel.PUBLIC
and not settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS
):
raise serializers.ValidationError(
"Public rooms are disabled for the external API."
)
return access_level
fields = ["id", "name", "slug", "pin_code", "access_level"]
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
def to_representation(self, instance):
"""Enrich response with application-specific computed fields."""
@@ -96,9 +68,6 @@ class RoomSerializer(serializers.ModelSerializer):
# Set secure defaults
validated_data["name"] = utils.generate_room_slug()
validated_data.setdefault(
"access_level", settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL
)
validated_data.setdefault("configuration", {})
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
return super().create(validated_data)
+35 -17
View File
@@ -4,7 +4,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
@@ -20,14 +20,8 @@ from rest_framework import (
)
from core import api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
ProvisionalUserIntegrityError,
ProvisionalUserService,
)
from . import authentication, permissions, serializers
logger = getLogger(__name__)
@@ -42,7 +36,6 @@ class ApplicationViewSet(viewsets.ViewSet):
url_path="token",
url_name="token",
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
"""Generate JWT access token for application delegation.
@@ -99,14 +92,40 @@ class ApplicationViewSet(viewsets.ViewSet):
)
try:
user, _ = ProvisionalUserService().get_or_create(email, client_id)
except ProvisionalUserCreationDisabledError as not_found_error:
raise drf_exceptions.NotFound("User not found.") from not_found_error
except ProvisionalUserIntegrityError:
return drf_response.Response(
{"error": "Failed to create or retrieve provisional user."},
status=drf_status.HTTP_409_CONFLICT,
)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
scope = " ".join(application.scopes or [])
@@ -154,7 +173,6 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -1,168 +0,0 @@
"""Management command to merge duplicate users based on their email address."""
# pylint: disable=too-many-locals
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
User = get_user_model()
ROLE_PRIORITY = {
RoleChoices.OWNER: 3,
RoleChoices.ADMIN: 2,
RoleChoices.MEMBER: 1,
}
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email into the most recently created one.
The KEPT user is the most recently created. All room memberships, recording
accesses and files are transferred to it. When a conflict exists, the
higher-privilege role wins. Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
help = __doc__
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the merge without writing any changes to the database.",
)
parser.add_argument(
"--email-filter",
type=str,
default=None,
help="Only merge users whose email contains this string (e.g. '@example.com').",
)
def handle(self, *args, **options):
"""Execute the management command."""
dry_run = options["dry_run"]
email_filter = options["email_filter"]
if dry_run:
self.stdout.write("[DRY-RUN] No changes will be written.\n")
users_qs = User.objects.all()
if email_filter:
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.values("email")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email", flat=True)
)
if not duplicate_emails:
self.stdout.write("[INFO] No duplicate users found. Nothing to do.")
return
self.stdout.write(
f"[INFO] Found {len(duplicate_emails)} email(s) with duplicate users."
)
total_merged = 0
total_deleted = 0
failed_emails = []
for email in duplicate_emails:
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
kept_user = users[-1]
stale_users = users[:-1]
self.stdout.write(
f"\n[INFO] Email '{email}': {len(users)} users — "
f"keeping {kept_user.id} (created {kept_user.created_at.date()})."
)
for u in stale_users:
self.stdout.write(
f" stale: {u.id} (created {u.created_at.date()})"
)
if dry_run:
ra_count = ResourceAccess.objects.filter(user__in=stale_users).count()
rca_count = RecordingAccess.objects.filter(user__in=stale_users).count()
f_count = File.objects.filter(creator__in=stale_users).count()
self.stdout.write(
f" [DRY-RUN] Would migrate: {ra_count} ResourceAccess, "
f"{rca_count} RecordingAccess, {f_count} File(s)."
)
continue
try:
group_deleted = 0
with transaction.atomic():
for stale_user in stale_users:
self._merge_resource_accesses(stale_user, kept_user)
self._merge_recording_accesses(stale_user, kept_user)
self._merge_files(stale_user, kept_user)
stale_user.delete()
group_deleted += 1
total_deleted += group_deleted
total_merged += 1
except Exception as exc: # noqa: BLE001 #pylint: disable=broad-exception-caught
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
)
self.stdout.write(
self.style.SUCCESS(
f"\n[DONE] Merged {total_merged} group(s), deleted {total_deleted} user(s)."
)
)
def _merge_resource_accesses(self, stale_user, kept_user):
"""Transfer room memberships from stale_user to kept_user."""
for ra in ResourceAccess.objects.filter(user=stale_user):
existing = ResourceAccess.objects.filter(
user=kept_user, resource=ra.resource
).first()
if existing is None:
ra.user = kept_user
ra.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(ra.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = ra.role
existing.save(update_fields=["role"])
ra.delete()
def _merge_recording_accesses(self, stale_user, kept_user):
"""Transfer recording accesses from stale_user to kept_user."""
for rca in RecordingAccess.objects.filter(user=stale_user):
existing = RecordingAccess.objects.filter(
user=kept_user, recording=rca.recording
).first()
if existing is None:
rca.user = kept_user
rca.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(rca.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = rca.role
existing.save(update_fields=["role"])
rca.delete()
def _merge_files(self, stale_user, kept_user):
"""Re-assign files created by stale_user to kept_user."""
File.objects.filter(creator=stale_user).update(creator=kept_user)
@@ -0,0 +1,36 @@
"""Add Room.is_encrypted and User.default_encryption.
`is_encrypted` is a boolean for now because passphrase-in-URL is the only
supported mode. A future migration may turn it into a CharField/enum if a
local-keys (vault) mode is reintroduced.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="is_encrypted",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
migrations.AddField(
model_name="user",
name="default_encryption",
field=models.BooleanField(
default=False,
help_text="Whether new meetings created by this user are end-to-end encrypted by default.",
verbose_name="Default to end-to-end encryption",
),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-02 17:31
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('core', '0018_rename_active_application_is_active'),
]
operations = [
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('email'), condition=models.Q(('sub__isnull', True)), name='unique_email_when_sub_is_null'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-03 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_user_unique_email_when_sub_is_null'),
]
operations = [
migrations.AlterField(
model_name='file',
name='upload_state',
field=models.CharField(choices=[('pending', 'Pending'), ('analyzing', 'Analyzing'), ('ready', 'Ready')], max_length=25),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-05-12 08:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_room_is_encrypted_user_default_encryption'),
]
operations = [
migrations.AddField(
model_name='room',
name='encryption_paused',
field=models.BooleanField(default=False, help_text='Temporarily suspend E2EE so external devices can join.', verbose_name='Encryption paused'),
),
]
+60 -26
View File
@@ -200,6 +200,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
default_encryption = models.BooleanField(
_("Default to end-to-end encryption"),
default=False,
help_text=_(
"Whether new meetings created by this user are "
"end-to-end encrypted by default."
),
)
objects = auth_models.UserManager()
@@ -211,13 +219,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
constraints = [
models.UniqueConstraint(
models.functions.Lower("email"),
condition=models.Q(sub__isnull=True),
name="unique_email_when_sub_is_null",
)
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -395,7 +396,25 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Public configuration exposed to any room participant via the API
# Boolean for now: today the only encryption mode is the passphrase-in-URL
# one. If a follow-up adds a stronger "local-keys" mode (private keys held
# in a vault iframe), this can grow into a CharField with choices like
# `none / passphrase / local_keys`. Set at creation; never mutated after —
# changing it would change the link's semantics (copy-link / hash carry).
is_encrypted = models.BooleanField(
default=False,
verbose_name=_("Encryption enabled"),
help_text=_("Whether end-to-end encryption is enabled for this room."),
)
# Mid-call admin override: when True on an `is_encrypted` room, the active
# encryption is suspended so a phone/SIP/device caller can join in plaintext.
# The link still carries the hash; toggling back to False resumes E2EE.
# Always False on non-encrypted rooms (enforced by the serializer).
encryption_paused = models.BooleanField(
default=False,
verbose_name=_("Encryption paused"),
help_text=_("Temporarily suspend E2EE so external devices can join."),
)
configuration = models.JSONField(
blank=True,
default=dict,
@@ -421,13 +440,44 @@ class Room(Resource):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
previous = None
if self.pk:
try:
previous = Room.objects.only(
"is_encrypted", "encryption_paused"
).get(pk=self.pk)
except Room.DoesNotExist:
previous = None
super().save(*args, **kwargs)
# Sync encryption state to LiveKit room metadata so the SIP gateway can
# decide between bridge mode and placeholder mode. Best-effort: if no
# LiveKit room exists yet, the call is a no-op.
encryption_changed = previous is None or (
previous.is_encrypted != self.is_encrypted
or previous.encryption_paused != self.encryption_paused
)
if encryption_changed:
try:
from core import utils as core_utils # local import: avoid cycle
core_utils.update_room_metadata(
room_name=str(self.pk),
metadata={
"is_encrypted": bool(self.is_encrypted),
"encryption_paused": bool(self.encryption_paused),
},
)
except Exception: # pylint: disable=broad-except
# Metadata sync is advisory — never break Room.save() over it.
pass
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -846,8 +896,8 @@ class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file."""
PENDING = "pending", _("Pending")
ANALYZING = "analyzing", _("Analyzing")
# Commented out for now, as we may need this when we implement the malware detection logic.
# ANALYZING = "analyzing", _("Analyzing")
# SUSPICIOUS = "suspicious", _("Suspicious")
# FILE_TOO_LARGE_TO_ANALYZE = (
# "file_too_large_to_analyze",
@@ -954,16 +1004,6 @@ class File(BaseModel):
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def temporary_key_base(self):
"""Temporary key base used while upload is still pending."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_TMP_PATH}/{self.pk!s}"
@property
def file_key(self):
"""Key used to store the file in object storage."""
@@ -972,12 +1012,6 @@ class File(BaseModel):
# leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}"
@property
def temporary_file_key(self):
"""Temporary key used to upload the file before it is finalized."""
_, extension = splitext(self.filename)
return f"{self.temporary_key_base}{extension!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the file.
@@ -1,9 +1,7 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import logging
import smtplib
from datetime import datetime, timezone
from django.conf import settings
from django.core.mail import send_mail
@@ -11,12 +9,9 @@ from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core import models
logger = logging.getLogger(__name__)
@@ -136,50 +131,7 @@ class NotificationService:
return not has_failures
@staticmethod
async def _get_recording_timestamps(worker_id):
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
FileInfo.started_at is more accurate than EgressInfo.started_at because
it reflects when file recording actually began. The started_at value exposed
in the manifest file, as well as in the EgressInfo returned by the API,
corresponds to when the egress service received the request, not the moment
the egress worker effectively joined the room.
Returns:
Tuple of (started_at, ended_at) datetimes, either may be None.
"""
if not worker_id:
return None, None
custom_configuration = {
**settings.LIVEKIT_CONFIGURATION,
"timeout": aiohttp.ClientTimeout(total=10),
}
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
try:
egress_list = await lkapi.egress.list_egress(
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
)
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
logger.exception("Could not fetch egress info for worker %s", worker_id)
return None, None
finally:
await lkapi.aclose()
if not egress_list.items or not egress_list.items[0].file_results:
logger.debug("No file_results for worker %s", worker_id)
return None, None
file_result = egress_list.items[0].file_results[0]
def _ns_to_utc(ns):
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _notify_summary_service(recording: models.Recording):
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
if (
@@ -198,35 +150,24 @@ class NotificationService:
.first()
)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
else:
metadata_filename = None
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
payload = {
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
}
headers = {
+31 -56
View File
@@ -1,7 +1,6 @@
"""Meet storage event parser classes."""
import logging
import mimetypes
import re
from dataclasses import dataclass
from functools import lru_cache
@@ -19,9 +18,6 @@ from .exceptions import (
ParsingEventDataError,
)
# Additional MIME type mapping
mimetypes.add_type("audio/ogg", ".ogg")
logger = logging.getLogger(__name__)
@@ -78,8 +74,8 @@ def get_parser() -> EventParser:
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class BaseS3Parser:
"""Base class for handling parsing and validation of S3-compatible storage events."""
class MinioParser:
"""Handle parsing and validation of Minio storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes."""
@@ -95,6 +91,32 @@ class BaseS3Parser:
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
@staticmethod
def parse(data):
"""Convert raw Minio event dictionary to StorageEvent object."""
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
bucket_name = s3["bucket"]["name"]
file_object = s3["object"]
filepath = file_object["key"]
filetype = file_object["contentType"]
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
try:
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=bucket_name,
metadata=None,
)
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
@@ -119,56 +141,9 @@ class BaseS3Parser:
return recording_id
def get_recording_id(self, data):
"""Extract recording ID from S3 event through parsing and validation."""
"""Extract recording ID from Minio event through parsing and validation."""
event_data = self.parse(data)
return self.validate(event_data)
recording_id = self.validate(event_data)
def parse(self, data: Dict) -> StorageEvent:
"""To be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement parse()")
class MinioParser(BaseS3Parser):
"""Minio specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
return StorageEvent(
filepath=s3["object"]["key"],
filetype=s3["object"]["contentType"], # Minio-specific field
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
class S3Parser(BaseS3Parser):
"""AWS S3 specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
# AWS S3 structure can slightly differ from Minio implementation
record = data["Records"][0]
s3 = record["s3"]
filepath = s3["object"]["key"]
if not filepath:
raise ParsingEventDataError("Missing object key name")
filetype, _ = mimetypes.guess_type(filepath)
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
return recording_id
@@ -1,91 +0,0 @@
"""Meeting metadata collection service."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync, sync_to_async
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
from core.models import Recording
logger = getLogger(__name__)
class MetadataCollectorException(Exception):
"""Generic exception in the metadata collector."""
class MetadataCollectorService:
"""Service for dispatching and managing the metadata collector agent."""
@async_to_sync
async def start(self, recording: Recording):
"""Explicitly dispatch the metadata collector agent to a room."""
lkapi = utils.create_livekit_client()
room_id = str(recording.room.id)
try:
response = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.METADATA_COLLECTOR_AGENT_NAME,
room=room_id,
metadata=str(recording.id),
)
)
except Exception as e:
logger.exception(
"Failed to create metadata collector agent for room %s", room_id
)
raise MetadataCollectorException(
"Failed to create metadata collector agent"
) from e
finally:
await lkapi.aclose()
dispatch_id = getattr(response, "id", None)
if not dispatch_id:
logger.error("LiveKit response missing dispatch ID for room %s", room_id)
raise MetadataCollectorException(
f"LiveKit did not return a dispatch_id for room {room_id}"
)
recording.options["metadata_collector_dispatch_id"] = dispatch_id
await sync_to_async(recording.save)(update_fields=["options"])
return dispatch_id
@async_to_sync
async def stop(self, recording: Recording):
"""Stop and delete the agent dispatch associated to the room."""
room_id = str(recording.room.id)
dispatch_id = recording.options.get("metadata_collector_dispatch_id")
lkapi = utils.create_livekit_client()
try:
if not dispatch_id:
logger.warning(
"No metadata collector dispatch ID stored for room %s", room_id
)
return None
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=room_id
)
except Exception as e:
logger.exception(
"Failed to stop metadata collector agent dispatch for room %s",
room_id,
)
raise MetadataCollectorException(
f"Failed to stop metadata collector agent for room {room_id}"
) from e
finally:
await lkapi.aclose()
@@ -7,7 +7,6 @@ from logging import getLogger
from livekit import api
from core import models, utils
from core.models import Recording
logger = getLogger(__name__)
@@ -20,7 +19,7 @@ class RecordingEventsService:
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording: Recording, egress_status):
def handle_update(recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
@@ -41,7 +40,7 @@ class RecordingEventsService:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording: Recording):
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
@@ -1,7 +1,5 @@
"""Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging
from dataclasses import dataclass
from functools import lru_cache
@@ -10,17 +8,8 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings
from django.utils.module_loading import import_string
from livekit import api as livekit_api
logger = logging.getLogger(__name__)
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
# Kept fixed because changing them would shift the goal-post away from the
# "safe drop-in replacement for the default preset" contract of this feature.
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
@dataclass(frozen=True)
class WorkerServiceConfig:
@@ -29,7 +18,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod
@lru_cache
@@ -37,24 +25,6 @@ class WorkerServiceConfig:
"""Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -66,7 +36,6 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
encoding_options=encoding_options,
)
+3 -27
View File
@@ -83,22 +83,6 @@ class BaseEgressService:
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -120,17 +104,9 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3,
)
request_kwargs = {
"room_name": room_name,
"file_outputs": [file_output],
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
+87 -11
View File
@@ -12,16 +12,16 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService
from .participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -162,7 +162,7 @@ class LiveKitEventsService:
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.select_related("room").get(
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
@@ -178,12 +178,6 @@ class LiveKitEventsService:
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try:
MetadataCollectorService().stop(recording)
except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService")
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -195,6 +189,72 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_participant_joined(self, data):
"""Handle 'participant_joined' event.
When a SIP/phone participant joins an end-to-end encrypted room they
cannot decrypt anything. We:
1. Send an in-band notification so admins see a snackbar with an
"Open settings" CTA.
2. Leave the participant connected the gateway is responsible for
holding them on a placeholder prompt loop ("this meeting is
encrypted, ask the host to disable it") until either the admin
turns encryption off (we update LiveKit room metadata, gateway
reacts) or the user hangs up.
See README "Phone / SIP participants" for the full flow.
"""
participant = getattr(data, "participant", None)
if participant is None:
return
# LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS,
# 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external
# device that can't run our E2EE code".
kind = getattr(participant, "kind", 0)
is_external_device = kind in (1, 3)
if not is_external_device:
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError:
return
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist:
return
# Live encryption: is_encrypted set AND not currently paused.
# If the admin has already paused encryption, the gateway will bridge
# the call normally — no need to surface a blocked notification.
if not room.is_encrypted or room.encryption_paused:
return
# 1. Broadcast a system notice — frontends decode this on the
# "encryption-state" or notifications channel and show a snackbar.
try:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": "external_device_blocked",
"participant_identity": participant.identity,
"participant_name": participant.name or participant.identity,
},
)
except utils.NotificationError:
logger.exception(
"Failed to notify room about blocked external device"
)
# No eject. The gateway reads LiveKit room metadata
# (`{is_encrypted, encryption_paused}` — see `Room.save()`) and stays
# in placeholder-prompt mode for the SIP leg as long as encryption is
# live. When the admin sets encryption_paused=true, the gateway
# transitions to a normal bridge without dropping the call.
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -212,6 +272,22 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# Now that the LiveKit room object exists, push the encryption flags
# into its metadata so the SIP gateway can read them as soon as a SIP
# caller joins. Room.save() also tries this on every change, but at
# *creation* time the LiveKit room didn't exist yet — this is the
# first reliable opportunity.
try:
utils.update_room_metadata(
str(room_id),
{
"is_encrypted": bool(room.is_encrypted),
"encryption_paused": bool(room.encryption_paused),
},
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to seed encryption metadata: %s", e)
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
+21 -6
View File
@@ -46,14 +46,18 @@ class LobbyParticipant:
username: str
color: str
id: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, str]:
def to_dict(self) -> Dict[str, object]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
"is_authenticated": self.is_authenticated,
}
@classmethod
@@ -68,6 +72,7 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -99,7 +104,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
secure=not settings.DEBUG,
samesite="Lax",
)
@@ -123,7 +128,7 @@ class LobbyService:
def request_entry(
self,
room: models.Room,
room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
@@ -152,6 +157,7 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -170,13 +176,17 @@ class LobbyService:
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
participant = self.enter(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -201,7 +211,11 @@ class LobbyService:
)
def enter(
self, room_id: UUID, participant_id: str, username: str
self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -216,6 +230,7 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
is_authenticated=is_authenticated,
)
try:
@@ -15,7 +15,6 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -28,10 +27,6 @@ class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantNotFoundException(ParticipantsManagementException):
"""Raised when the target participant does not exist in the room."""
class ParticipantsManagement:
"""Service for managing participants."""
@@ -52,14 +47,6 @@ class ParticipantsManagement:
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping muting",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
@@ -93,14 +80,6 @@ class ParticipantsManagement:
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping removing",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
@@ -138,14 +117,6 @@ class ParticipantsManagement:
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping update",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
@@ -155,44 +126,3 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -1,110 +0,0 @@
"""Service for provisional user creation."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import IntegrityError
from core import models
logger = logging.getLogger(__name__)
class ProvisionalUserError(Exception):
"""Base exception for provisional user service errors."""
class ProvisionalUserCreationDisabledError(ProvisionalUserError):
"""Raised when provisional user creation is disabled by configuration."""
class ProvisionalUserIntegrityError(ProvisionalUserError):
"""Raised when a provisional user cannot be created or retrieved after a race condition."""
class ProvisionalUserService:
"""Handles creation and retrieval of provisional users.
A provisional user is created without a `sub`, identified by email only.
The `sub` is set on first successful OIDC authentication via Django LaSuite.
"""
def __init__(self):
"""Initialize the service."""
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
self._is_creation_enabled = (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
)
def _get_by_email(self, email: str) -> models.User | None:
"""Return the user with this email, or None if not found."""
try:
return models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist:
return None
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
def get_or_create(
self, email: str, client_id: str
) -> tuple[models.User | None, bool]:
"""Get or create a provisional user identified by email.
Args:
email: The email address to identify the user.
client_id: The application client_id, used for audit logging only.
Returns:
A (user, created) tuple mirrors get_or_create conventions.
Raises:
ProvisionalUserError: If creation and retrieval both fail.
"""
user = self._get_by_email(email)
if user:
return user, False
if not self._is_creation_enabled:
raise ProvisionalUserCreationDisabledError(
"Provisional user creation is disabled by configuration."
)
# Create a provisional user without `sub`, identified by email only.
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
try:
user = models.User(sub=None, email=email)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
client_id,
)
return user, True
except (IntegrityError, ValidationError) as e:
logger.warning(
"Race condition on provisional user creation, fetching existing: "
"email=%s, client_id=%s",
email,
client_id,
)
user = self._get_by_email(email)
if user:
return user, False
raise ProvisionalUserIntegrityError(
"Failed to create or retrieve provisional user."
) from e
@@ -1,64 +0,0 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()

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