Compare commits

..

23 Commits

Author SHA1 Message Date
Martin Guitteny f19fb41d92 🔧(agents) change the way docker starts agents
Change compose to start the agents in separate services
and make installs in Dockerfile and not in compose
It's cleaner that way
2025-10-20 14:01:10 +02:00
Martin Guitteny 5ae1362adf ️(summary) update threshold for mapping names to speaker
Update the threshold for the mapping speaker to their names.
It will now tell us the name with a "?" if confidence is between
0.3 and 0.5 and write the name if confidence is over 0.5
2025-10-20 13:26:57 +02:00
Martin Guitteny 29ad48403a ️(summary) change name diarization to use dataframe
Change the algorithm to map speakers to their name to use
dataframe to improve performance.
2025-10-17 14:00:24 +02:00
Martin Guitteny 67bc2ae84b (agents) format code with ruff
format code with ruff to pass checks
2025-10-17 13:42:57 +02:00
Martin Guitteny 82e7f2db8c (agents) add livekit-plugins in compose
Add livekit-pluging-silero for VAD agent in compose
(it's not supposed to be started like this and will be changed)
2025-10-17 13:38:48 +02:00
Martin Guitteny 561fe8d03c (summary) add numpy and pandas dependencies
Add pandas and numpy to use dataframe to calculate
the speakers's names in diarization and improve performances.
2025-10-17 11:32:40 +02:00
Martin Guitteny 9e00ea6679 🎨(agents) change metadata agent
Copy the metadata agent from "Metadata agent #741" branch
to have the VAD agent and the right output format for metadatas
2025-10-17 11:28:55 +02:00
Martin Guitteny f7ba2ccafe 🔨(summary) add egress manifest in summary
Add the possibility of getting egress manifest by sending the
worker id in the notification.
This will allow us to get the starting time of the recording
to be able to align perfectly the metadatas and the transcription.
2025-10-10 11:26:07 +02:00
Martin Guitteny a02bea4911 🐛(agents) make agents tolerante to no owner
Make the agent tolerant to no known owner

In the tests we do not always have an owner so
it is necessary to make the pipeline tolerant to
that special case.
2025-10-06 11:26:25 +02:00
Martin Guitteny fd6f933acd 🐛(agents) hide agent and fix egress end when agent disabled
Fix the error created by trying to close agent when it is
disabled.
Make the agent hidden so users do not see it.

It makes the diarization invisible for users.
2025-10-06 11:09:39 +02:00
Martin Guitteny 64269c51fe fixup! 🚩(agents) add feature flag for metadata agent 2025-10-06 10:19:21 +02:00
Martin Guitteny b4201e7678 🐛(summary) fix transcription when metadata disabled
Adding a condition on feature flag to get metadata in bucket
Fixing the format_segments function to work without metadatas

This is important because metadatas for names diarization
will not be enabled for everyone at first
2025-10-03 18:15:00 +02:00
Martin Guitteny 57b3a03371 🚩(agents) add feature flag for metadata agent
Change FeatureFlag class to accept featureflag from posthog.
add metadata_agent featuer flag to check if the feature flag
is enabled for the creator of the recording.
This feature flag will be used to allow some chosen users to test
the named diarization feature.
2025-10-03 17:46:18 +02:00
Martin Guitteny 4babe42ede fixup! 🚧(agents) add metadata agent call on livekit webhook 2025-09-29 10:27:41 +02:00
Martin Guitteny 34716b62a8 (tests) update start recording test
Update start recording test because start_room_recording
output changed.
2025-09-26 14:53:45 +02:00
Martin Guitteny cd4842e011 fixup! 🚧(agents) add metadata agent call on livekit webhook 2025-09-26 14:12:07 +02:00
Martin Guitteny ab120808c9 (summary) add the usage of metadata to use name in transcript
Change the formatting of the formatting to use the names
instead of SPEAKER_00
2025-09-26 14:00:29 +02:00
Martin Guitteny e3267f9d87 🚧(agents) add metadata agent call on livekit webhook
Add the call of metadata agent when webhook from
egress_started is received.
Add dispatch id in recording model to be able to
shutdown agent when recording end.
Still need to make the transcription start after
metadatas are uploaded on minio.
2025-09-26 14:00:05 +02:00
Martin Guitteny 9b78ab995c 🔧(agents) add metadata agent in makefile compose and settings
Add metadata-agent container in compose to setup metadata
agent dev
Add run agents in makefile "make run"
Add agent name in settings
Setup livekit webhook in liverkit-server to start agent
and recording simultaneously
2025-09-24 20:47:47 +02:00
Martin Guitteny b4f6287097 🚧(agents) convert id to name in metadatas
get the name in the conversation and convert id
to name in the metadata. Preference order:
name attribute, metadata display_name, identity
2025-09-23 21:27:06 +02:00
Martin Guitteny 214f41fe2c 🚧(summary) add logic to map name to participant + cleaning
Add logic to map name to participant by aligning whisper
output and metadata, calculating jaccard similarity for
each speaker and participant
Cleaning metadata extractor to pass ruff checks
2025-09-22 10:19:47 +02:00
Martin Guitteny 0affc5628b 🔨(agents) change json metadata dump to bucket_s3
Change the saving of the json metadata to the minio bucket_s3
to prepare the changes to map the name in the diarization
2025-09-19 19:08:39 +02:00
Martin Guitteny 731bce92df 🚧(agents) add metadata extractor agent
Add a visible agent that joins the room and
writes in a json all the active speaker informations.
2025-09-19 17:32:46 +02:00
66 changed files with 1007 additions and 3142 deletions
+75
View File
@@ -45,6 +45,7 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -53,6 +54,21 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -92,6 +108,7 @@ bootstrap: \
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build
build-backend: ## build the app-dev container
@@ -103,6 +120,10 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
build-agents: ## build the agents image(s)
@$(COMPOSE) build metadata-agent
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -122,10 +143,15 @@ 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 agents
@$(COMPOSE) up --force-recreate -d metadata-agent
.PHONY: run-agents
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -137,6 +163,55 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
+9 -26
View File
@@ -34,10 +34,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Meeting transcription (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
@@ -55,7 +54,6 @@ Were continuously adding new features to enhance your experience, with the la
- [Get started](#get-started)
- [Docs](#docs)
- [Self-host](#self-host)
- [Contributing](#contributing)
- [Philosophy](#philosophy)
- [Open source](#open-source)
@@ -63,37 +61,22 @@ Were continuously adding new features to enhance your experience, with the la
## Get started
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
### Open-source deployment (Advanced)
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## Self-host
### La Suite Meet is easy to install on your own servers
We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
**Questions?** Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
> [!NOTE]
> 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 |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- 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)
+36 -16
View File
@@ -46,21 +46,6 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;"
app-dev:
build:
context: .
@@ -87,7 +72,6 @@ services:
- nginx
- livekit
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
@@ -298,3 +282,39 @@ services:
watch:
- action: rebuild
path: ./src/summary
metadata-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "metadata_extractor.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
ROOM_METADATA_AGENT_NAME: "metadata-extractor"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_SECURE_ACCESS: "false"
PYTHONUNBUFFERED: "1"
AWS_STORAGE_BUCKET_NAME: "meet-media-storage"
AWS_S3_REGION_NAME: "local"
depends_on:
- livekit
- minio
transcriber-agent:
build:
context: ./src/agents
dockerfile: Dockerfile
working_dir: /app
command: ["python", "multi-user-transcriber.py", "start"]
environment:
LIVEKIT_URL: "ws://livekit:7880"
LIVEKIT_API_KEY: "devkey"
LIVEKIT_API_SECRET: "secret"
PYTHONUNBUFFERED: "1"
depends_on:
- livekit
+1 -1
View File
@@ -42,7 +42,7 @@ COPY ./docker/dinum-frontend/fonts/ \
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
@@ -1,5 +1,11 @@
log_level: debug
redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- "http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/"
+81
View File
@@ -0,0 +1,81 @@
# LiveKit Egress
LiveKit offers Universal Egress, designed to provide universal exports of LiveKit sessions or tracks to a file or stream data.
It is kept in a separate system to keep the load off the [Single Forwarding Unit (SFU)](https://docs.livekit.io/reference/internals/livekit-sfu/) and avoid impacting real-time audio or video performance/quality.
## Getting started
### Prerequisite
1. **Verify Services**: Ensure the LiveKit server and Egress service are both up and running.
2. **Install CLI**: Confirm that the LiveKit CLI utility is installed on your system.
3. **Set Permissions**: Since the Egress service does not run as the root user, you need to grant write permissions to all users for the output directory. Update the permissions of the `docker/livekit/out` folder before starting the docker-compose stack:
```bash
$ chmod o+w ./docker/livekit/out
```
### Make a recording
LiveKit provides examples for creating Egress requests, which you can find [here](https://github.com/livekit/livekit-cli/tree/main/cmd/livekit-cli/examples). One of these examples has been added to the repository under `docker/livekit/egress-example`.
Follow these steps to start an Egress request:
1. **Create a Room**: Create a room either through the frontend or using the `livekit-cli` command.
2. **Retrieve Room Name**: Get the room's name (e.g., the UUID4 in the URL from the frontend).
3. **Update Configuration**: Edit the `docker/livekit/egress-example/room-composite-file.json` file with your room's name.
4. **Start Egress Request**: Initiate a new Egress request.
```bash
$ livekit-cli start-room-composite-egress --request ./docker/livekit/egress-example/room-composite-file.json
Using default project meet
EgressID: EG_XXXXXXXXXXXX Status: EGRESS_STARTING
```
You can list running Egress:
```Bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
You can stop the Egress at any time once your recording is finished:
```Bash
$ livekit-cli stop-egress --id EG_XXXXXXXXXXXX
Using default project meet
Stopping Egress EG_XXXXXXXXXXXX
```
The Egress should be marked as completed:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_COMPLETE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
```
Finally, you should find two new files in the `./docker/livekit/out directory`: an `.mp4` recording and its associated metadata in a `.json` file:
```bash
$ ls ./docker/livekit/out
your-room-name-YYYY-MM-DDTHHMMSS.mp4
your-room-name-YYYY-MM-DDTHHMMSS.mp4.json
```
### Resources
[Official Egress repository](https://github.com/livekit/egress)
-53
View File
@@ -1,53 +0,0 @@
# Authentication (OIDC)
La Suite Meet supports **OIDC authentication** using the Authorization Code Flow.
Authentication relies on [django-lasuite](https://github.com/suitenumerique/django-lasuite) for OIDC integration, token validation, and user management.
## OIDC Configuration
| Option | Description | Default |
|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------ |
| **Client Settings** | | |
| OIDC_RP_CLIENT_ID | OIDC client identifier registered with your provider | `meet` |
| OIDC_RP_CLIENT_SECRET | OIDC client secret (keep confidential) | — |
| OIDC_CREATE_USER | Automatically create a local user if none exists | `true` |
| **Security & Verification** | | |
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
| **Endpoints** | | |
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
| OIDC_OP_TOKEN_ENDPOINT | URL to exchange authorization code for tokens | — |
| OIDC_OP_USER_ENDPOINT | URL to fetch user information | — |
| OIDC_OP_USER_ENDPOINT_FORMAT | Format of user endpoint response. Options: `AUTO` (detect automatically), `JWT`, or `JSON` | `AUTO` |
| OIDC_OP_LOGOUT_ENDPOINT | URL for logout requests | — |
| **User Info Mapping** | | |
| OIDC_USERINFO_FULLNAME_FIELDS | List of OIDC claims used to build users full name | `["given_name", "usual_name"]` |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC claim used for the users short name | `given_name` |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | List of essential claims required from the provider | `[]` |
| **Redirects & Scopes** | | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC redirect URIs (**recommended in production**) | `false` |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirects | `[]` |
| OIDC_REDIRECT_FIELD_NAME | Query parameter name used for redirect after login | `returnTo` |
| OIDC_RP_SCOPES | Scopes to request during authentication | `openid email` |
| LOGIN_REDIRECT_URL | URL to redirect after successful login | — |
| LOGIN_REDIRECT_URL_FAILURE | URL to redirect after failed login | — |
| LOGOUT_REDIRECT_URL | URL to redirect after logout | — |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through HTTP GET (POST is recommended for security) | `true` |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters to include in OIDC authentication requests | `{}` |
| **PKCE (Proof Key for Code Exchange)** | | |
| OIDC_USE_PKCE | Enable PKCE for enhanced security (**recommended**) | `false` |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method to generate PKCE code challenge (`S256` recommended) | `S256` |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as PKCE code verifier (43128 characters) | `64` |
| **Other** | | |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Silent login allows La Suite Meet to authenticate users automatically without showing a login prompt, providing a seamless experience when an active session already exists with the OIDC provider. It works by replaying the authentication request with prompt=none: if the user has a valid session, login succeeds silently; otherwise, it fails gracefully and redirects the user to the initial page. Silent login is optional and enabled by default in standard deployments. The app retries silent login after any 401 response, with at least a 30-second interval between attempts (not configurable via environment variables). Controlled by the backend parameter. /!\ Your OIDC provider must support `prompt=none`. | `false` |
## Sessions
* After login, users receive a **Django session cookie** to maintain authentication across requests.
* Default session duration is 12 hours (`SESSION_COOKIE_AGE = 60 * 60 * 12`).
* Ensure your session policy matches your security requirements.
-6
View File
@@ -1,6 +0,0 @@
# Calendar integrations (WIP)
These features are currently under active development and are not yet ready for official documentation. Comprehensive documentation will be provided as soon as possible.
An initial integration with OpenExchange is already available and will be documented shortly.
-143
View File
@@ -1,143 +0,0 @@
# Room Recording (Beta)
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
**Current Limitations**:
* Users cannot record and transcribe simultaneously. ([Issue #527](https://github.com/suitenumerique/meet/issues/527)
is on our backlog)
* Recording layout cannot be configured from the frontend. By default, the egress captures the active speaker and any shared screens. (not yet planned)
* Shareable links with an embedded video player are not yet supported. (not yet planned)
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE]
> Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage.
## How It Works
```mermaid
sequenceDiagram
participant User
participant Frontend as Frontend (React)
participant Backend as Django Backend
participant LiveKit as LiveKit API
participant Egress as LiveKit Egress
participant Storage as Object Storage
participant Room as LiveKit Room
participant Email as Email Service
User->>Frontend: Click start recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
Backend->>LiveKit: Create egress request
LiveKit->>Egress: Start room composite egress
Egress->>Room: Join room as recording participant
Note over Egress,Room: Egress joins room to capture audio/video
LiveKit-->>Backend: Return egress_id
Backend->>Backend: Update Recording with worker_id
Backend-->>Frontend: HTTP 201 - Recording started
Frontend->>Frontend: Update recording status
Frontend->>Frontend: Notify other participants
Note over Frontend: Via LiveKit data channel
Note over Egress,Room: Recording in progress...
User->>Frontend: Click stop recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/stop-recording/
Backend->>LiveKit: Stop egress request
LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file
Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner
Backend-->>Frontend: HTTP 200 - Recording stopped
Frontend->>Frontend: Update UI and notify participants
Email->>User: Send email with recording link
User->>Frontend: Navigate to /recording/{id} to download file
Frontend->>Frontend: Download recording file
```
## Configuration Options
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **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. |
### Manual Storage Webhook
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## LiveKit Egress
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
Currently, only `RoomCompositeEgress` is supported. This mode combines all video and audio tracks from the room into a single recording.
To monitor egress workers and inspect recording status, it is recommended to install `livekit-cli`. For example, you can list active egress sessions using the following command:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
-24
View File
@@ -1,24 +0,0 @@
# Signaling
Signaling is essential for LiveKits real-time communication. It enables peers to discover each other, exchange session descriptions, and negotiate network paths for audio and video streams.
## How Signaling Works
LiveKit signaling relies on a WebSocket connection between the client and the LiveKit API server. This WebSocket is required for all signaling messages, including session descriptions, ICE candidates, and connection state updates.
We do not cover internal signaling behavior. For full reference, see the [LiveKit client protocol](https://docs.livekit.io/reference/internals/client-protocol/).
> [!IMPORTANT]
> The WebSocket is the backbone of LiveKit signaling. All signaling messages rely on it, and without it, ICE candidate exchange and peer connection setup cannot occur. If the WebSocket connection is lost, the client automatically attempts to resume the RTC session once connectivity is restored.
## Environment Variables
| Variable | Type | Default | Purpose |
| ----------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `True` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `True` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
-4
View File
@@ -1,4 +0,0 @@
# Live subtitles (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
-4
View File
@@ -1,4 +0,0 @@
# Meeting summarization (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
-87
View File
@@ -1,87 +0,0 @@
# Telephony SIP (Beta)
Enable participants to join a video conference via phone, allowing them to participate in the room even when their internet connection is poor or unavailable.
**Current Limitations**:
* Supports only a single SIP trunk provider per instance.
* A participant joining over the phone cannot enter the room until the first WebRTC participant has connected.
## Special requirements
To use the telephony feature, the following components are required:
* A running [LiveKit SIP server](https://github.com/livekit/sip) ([documentation](https://docs.livekit.io/home/self-hosting/sip-server/)) to handle SIP participants and connect them to room sessions.
* A SIP trunk to route incoming and outgoing phone calls.
* Webhook events configured between the LiveKit server and the backend.
## How It Works
### Room Lifecycle
```mermaid
sequenceDiagram
participant Backend
participant LiveKit as LiveKit Service
participant SIP as LiveKit SIP
participant Dispatch as SIP Dispatch
Backend->>Backend: Create new room
Backend->>Backend: Assign unique pin code to room
LiveKit-->>Backend: Webhook room_started
Backend->>Dispatch: Create LiveKit SIP dispatch rule
LiveKit-->>Backend: Webhook room_ended
Backend->>Dispatch: Clear LiveKit SIP dispatch rule
```
### Participant calling
```mermaid
sequenceDiagram
participant Caller as Caller
participant SIPProvider as SIP Trunk Provider
participant LiveKitSIP as LiveKit SIP
participant Dispatch as SIP Dispatch
participant Room as LiveKit Room
Caller->>SIPProvider: Dial phone number
SIPProvider->>LiveKitSIP: Route call to SIP server
LiveKitSIP->>Caller: Prompt for room pin code
Caller->>LiveKitSIP: Enter pin code
LiveKitSIP->>Dispatch: Check dispatch rule for pin and trunk ID
Dispatch-->>LiveKitSIP: Return room ID if found
LiveKitSIP->>Room: Connect participant to room
```
## Configuration
| Option | Type | Default | Description |
| ------------------------------ | ---------------- | ------- |-----------------------------------------------------------------------------------------------------------------|
| ROOM_TELEPHONY_ENABLED | Boolean | False | Enable or disable telephony (phone call) support for rooms. |
| ROOM_TELEPHONY_PIN_LENGTH | Positive Integer | 10 | Length of the PIN code participants must enter to join a call. |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Positive Integer | 5 | Maximum number of attempts a participant can make when entering the PIN. |
| ROOM_TELEPHONY_PHONE_NUMBER | String | None | The phone number associated with the room for incoming calls. Required to route calls via the telephony system. |
| ROOM_TELEPHONY_DEFAULT_COUNTRY | String | "US" | Default country code for phone numbers, used for parsing and formatting phone numbers. |
### SIP Trunk Authentication
You may need to configure authentication between LiveKit SIP and your SIP trunk provider to enable participants to join via phone.
Please refer to [the official documentation](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/).
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
### Language Customization for Audio Prompts
You may need to configure the default LiveKit voice to match your locale. By default, all LiveKit audio instructions are in English.
To customize the prompts, mount the appropriate audio files as a volume in your deployment. The audio resources are available here: [LiveKit SIP audio files](https://github.com/livekit/sip/tree/main/res).
## Documentation
For detailed information on integrating and configuring SIP with LiveKit, refer to the official LiveKit SIP documentation: [LiveKit SIP Documentation](https://docs.livekit.io/sip/). This guide covers SIP server setup, trunk configuration, dispatch rules, etc.
-92
View File
@@ -1,92 +0,0 @@
# Transcription
La Suite Meet provides a room transcription capability, currently available in beta. This feature is under active development, with ongoing enhancements planned.
The transcription feature enables users to record room sessions. Upon completion of a recording, the room owner receives a notification containing a link to LaSuite Docs, where the transcribed meeting content can be accessed.
> [!NOTE]
> Audio recordings are automatically deleted after the configured `RECORDING_EXPIRATION_DAYS` period.
For configuration and setup details of the recording functionality, refer to the [Recording feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
This page only describes the supplementary tools required for audio processing.
Example of a transcript :
```
**SPEAKER_00**: Hello everyone!
**SPEAKER_01**: Yes, it works.
```
### Current Limitations
* Participant identification is not yet implemented; participants are labeled generically (e.g., `PARTICIPANT_1`).
* Transcription backend relies on [WhisperX](https://github.com/m-bain/whisperX), which does not provide an OpenAI-compatible API.
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To enable the transcription feature, the following components must be in place:
* Recording feature components: All dependencies and configurations required for the [recording feature](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
* LaSuite Docs instance: A running [LaSuite Docs](https://github.com/suitenumerique/docs) capable of handling requests to the `/create-for-owner` endpoint.
* WhisperX API: A running WhisperX service. An open-source implementation combining WhisperX and FastAPI is available [here](https://github.com/suitenumerique/meet-whisperx).
* Deployment of the [summary service](https://hub.docker.com/r/lasuite/meet-summary), a Celery worker, and a Redis instance.
## How It Works
```mermaid
sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
Backend->>Summary: POST /api/v1/tasks/ (bearer token, payload)
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
Celery->>Celery: Format transcript (text)
Celery->>Docs: POST /create-for-owner (title, content, email, sub, api token)
Docs-->>Celery: Acknowledgement
```
## Configuration Options
| Option | Type | Default | Description |
| ------------------------ | --------- |-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| app_name | String | `"app"` | Name of the application/service. |
| app_api_v1_str | String | `"/api/v1"` | Base path for the API endpoints. |
| app_api_token | Secret | — | API token for authenticating requests. |
| recording_max_duration | Integer | `None` | Maximum duration of audio recordings in milliseconds. Set to `None` for unlimited. Audio recordings longer than the configured limit will be ignored and not processed. |
| celery_broker_url | String | `"redis://redis/0"` | Celery broker URL. |
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
| webhook_api_token | Secret | — | Token to authenticate incoming webhook requests. |
| webhook_url | String | — | URL to which webhook events are sent. |
| document_default_title | String | `"Transcription"` | Default title for generated documents. |
| document_title_template | String | `'Réunion "{room}" du {room_recording_date} à {room_recording_time}'` | Template for document title. |
| sentry_is_enabled | Boolean | `False` | Enable or disable Sentry error tracking. |
| sentry_dsn | String | `None` | DSN for Sentry integration. |
-28
View File
@@ -1,28 +0,0 @@
# Installation
If you want to install La Suite Meet you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
## Docker Compose
We understand that not everyone has a Kubernetes cluster available.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
> [!WARNING]
> Under construction: A PR is in progress to support deploying La Suite Meet via Docker Compose.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
Here is the list of other methods in alphabetical order:
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&show=lasuite-meet&query=lasuite-meet), ⚠️ unstable
- Yunohost: [Packages](https://github.com/YunoHost-Apps/meet_ynh), ⚠️ under construction (for small instances only)
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
## Cloud providers
Currently, no cloud providers are listed for deploying La Suite Meet.
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
-475
View File
@@ -1,475 +0,0 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with application-delegated authentication.
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
#### Scopes
* `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms.
* `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Authentication
description: Application authentication and token generation
- name: Rooms
description: Room management operations
paths:
/applications/token:
post:
tags:
- Authentication
summary: Generate JWT token
description: |
Exchange application credentials for a scoped JWT token that allows the application
to act on behalf of a specific user.
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
operationId: generateToken
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
responses:
'200':
description: Token generated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
tokenResponse:
summary: Successful token generation
value:
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
token_type: "Bearer"
expires_in: 3600
scope: "rooms:list rooms:retrieve rooms:create"
'401':
description: Authentication failed
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
invalidCredentials:
summary: Invalid credentials
value:
error: "Invalid credentials"
inactiveApplication:
summary: Application is inactive
value:
error: "Application is inactive"
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
userNotFound:
summary: User not found
value:
error: "User not found."
'403':
description: Access denied - cannot delegate user
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
delegationDenied:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
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:**
- 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
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/applications/token` endpoint.
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: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: Authentication required or token invalid/expired
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid or expired token
value:
error: "Invalid token."
tokenExpired:
summary: Token has expired
value:
error: "Token expired."
ForbiddenError:
description: Insufficient permissions for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
+65
View File
@@ -0,0 +1,65 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin --tags
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
-118
View File
@@ -1,118 +0,0 @@
# Theming La Suite Meet
There are two ways to customize LaSuite Meet:
- **Runtime Theming**. You can load a custom CSS file to apply any CSS you want. You can change all design-system tokens through CSS variables: colors, fonts, spacing multipliers, and more.
- **Build-time Theming**. Some additional things, like the app name appearing in the browser tab, can be customized through environment variables that are applied at build-time.
## Runtime Theming
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css
```
> [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
This feature lets you customize the apps look with any CSS, giving full flexibility and allowing changes to take effect instantly at runtime without touching the code.
### Example Use Case
Let's say you want to change the font of our application to a custom font. You can create a custom CSS file with the following contents:
```css
@import url(https://fonts.bunny.net/css?family=Roboto:wght@400;700&display=swap);
:root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
> The app does **not provide separate light/dark themes**: outside a meeting it defaults to light, and in a room it switches to dark.
### Key Semantic Tokens
These control the main visual aspects of the interface:
| Category | Purpose | Example Token |
| ---------- | ------------------------------- | --------------------------- |
| Primary | Brand color, buttons, links | `--colors-primary` |
| Dark Mode | Primary color in room/dark mode | `--colors-primary-dark-500` |
| Greyscale | Text, backgrounds, borders | `--colors-greyscale-500` |
| Success | Success states | `--colors-success` |
| Error | Errors and destructive actions | `--colors-error` |
| Warning | Warnings and alerts | `--colors-warning` |
| Alert | Notification backgrounds | `--colors-alert` |
| Font Sans | Main UI font | `--fonts-sans` |
| Font Serif | Alternate/reading font | `--fonts-serif` |
| Font Mono | Code or technical font | `--fonts-mono` |
### Assets (Logo, Images)
You can override built-in assets (such as the logo or images) by mounting your own files into the container.
Simply bind-mount your custom assets to the following path inside the container:
```
/usr/share/nginx/html/assets
```
Any files you mount here will **override the defaults at runtime**.
For example, to replace the images used in the landing page carousel, provide your own versions with the same filenames and paths:
```
/usr/share/nginx/html/
└── assets/intro-slider/
├── 1.png
├── 2.png
├── 3.png
└── 4.png
```
## Build-Time Theming
Some settings cannot be applied at runtime and require rebuilding the Docker image.
One key example is the **application title and name**, controlled by the `VITE_APP_TITLE` build argument.
* **Default:** `La Suite Meet`
* **Override:** supply your own value at build time
```bash
docker build \
--build-arg VITE_APP_TITLE="My Custom Meet" \
-t my-org/meet:latest .
```
```dockerfile
# Dockerfile
ARG VITE_APP_TITLE="La Suite Meet"
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
```
For a real-world example, see how DINUM rebuilds the frontend to match their branding:
[DINUM Dockerfile](../docker/dinum-frontend/Dockerfile)
----
# **Footer Configuration**
The footer cannot be customized yet. This is a work in progress, and we welcome contributions — feel free to open a pull request if youd like to help add this feature.
You can enable the official French government footer by setting the environment variable `FRONTEND_USE_FRENCH_GOV_FOOTER` to true. This option is disabled (false) by default.
-6
View File
@@ -65,9 +65,3 @@ ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
# External Applications
EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
+1 -3
View File
@@ -19,6 +19,4 @@ USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi_user_transcriber.py", "start"]
COPY . .
+7 -3
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
from dotenv import load_dotenv
from dotenv import find_dotenv, load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
@@ -107,13 +107,16 @@ class MetadataAgent:
# todo - raise error if none
self.bucket_name = os.getenv("AWS_STORAGE_BUCKET_NAME")
logger.info("Using S3 bucket: %s", self.bucket_name)
dotenv_path = find_dotenv()
logger.info(f"Fichier .env trouvé : {dotenv_path}")
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
self.output_filename = (
f"{os.getenv('AWS_S3_OUTPUT_FOLDER', 'metadata')}/{recording_id}-metadata.json"
f"{os.getenv('AWS_S3_OUTPUT_FOLDER', 'recordings').strip('/')}/"
f"{json.loads(recording_id).get('recording_id')}-metadata.json"
)
# Storage for events
@@ -323,6 +326,7 @@ async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user VAD monitor."""
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
logger.info("Recording ID: %s", recording_id)
vad_monitor = MetadataAgent(ctx, recording_id)
vad_monitor.start()
+2 -2
View File
@@ -1,14 +1,14 @@
[project]
name = "agents"
version = "0.1.39"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1",
"minio==7.2.15"
"minio==7.2.15",
]
[project.optional-dependencies]
-64
View File
@@ -1,6 +1,5 @@
"""Admin classes and registrations for core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -151,66 +150,3 @@ class RecordingAdmin(admin.ModelAdmin):
return _("Multiple owners")
return str(owners[0].user)
class ApplicationDomainInline(admin.TabularInline):
"""Inline admin for managing allowed domains per application."""
model = models.ApplicationDomain
extra = 0
class ApplicationAdminForm(forms.ModelForm):
"""Custom form for Application admin with multi-select scopes."""
scopes = forms.MultipleChoiceField(
choices=models.ApplicationScope.choices,
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
@admin.register(models.Application)
class ApplicationAdmin(admin.ModelAdmin):
"""Admin interface for managing applications and their permissions."""
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [
"name",
"id",
"created_at",
"updated_at",
"scopes",
"client_id",
"client_secret",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
def get_readonly_fields(self, request, obj=None):
"""Make client_id and client_secret readonly after creation."""
if obj: # Editing existing object
return self.readonly_fields + ["client_id", "client_secret"]
return self.readonly_fields
def get_fields(self, request, obj=None):
"""Hide client_secret after creation."""
fields = super().get_fields(request, obj)
if obj:
return [f for f in fields if f != "client_secret"]
return fields
def get_scopes_display(self, obj):
"""Display scopes in list view."""
if obj.scopes:
return ", ".join(obj.scopes)
return _("No scopes")
get_scopes_display.short_description = _("Scopes")
+55 -10
View File
@@ -1,30 +1,75 @@
"""Feature flag handler for the Meet core app."""
import logging
import os
from functools import wraps
from logging import getLogger
from django.conf import settings
from django.http import Http404
from posthog import Posthog
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class FeatureFlagError(Exception):
"""Feature flag management error."""
class FeatureFlag:
"""Check if features are enabled and return error responses."""
"""Feature flag management using Django settings and PostHog."""
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"metadata_agent": {"posthog": "is_metadata_agent_enabled"},
"recording": {"setting": "RECORDING_ENABLE"},
"storage_event": {"setting": "RECORDING_STORAGE_EVENT_ENABLE"},
"subtitle": {"setting": "ROOM_SUBTITLE_ENABLED"},
}
_ph_client = None
@classmethod
def flag_is_active(cls, flag_name):
def _get_ph_client(cls):
"""Initialize and return PostHog client if configured."""
if cls._ph_client is not None:
return cls._ph_client
api_key = os.getenv("POSTHOG_API_KEY")
host = os.getenv("POSTHOG_API_HOST", "https://eu.i.posthog.com")
logging.info("PostHog config: api_key=%s, host=%s", api_key, host)
if Posthog and api_key and host:
logging.info("Initializing PostHog client")
cls._ph_client = Posthog(project_api_key=api_key, host=host)
logging.info("PostHog client initialized: %s", bool(cls._ph_client))
return cls._ph_client
@classmethod
def flag_is_active(cls, flag_name, *, distinct_id=None, default=False):
"""Check if a feature flag is active."""
setting_name = cls.FLAGS.get(flag_name)
if setting_name is None:
cfg = cls.FLAGS.get(flag_name)
if not cfg:
return False
return getattr(settings, setting_name, False)
setting_name = cfg.get("setting")
if setting_name is not None:
return bool(getattr(settings, setting_name, False))
posthog_flag = cfg.get("posthog")
if posthog_flag:
ph = cls._get_ph_client()
if ph and distinct_id:
try:
logger.info(
"Checking PostHog flag %s for id=%s", posthog_flag, distinct_id
)
return bool(ph.feature_enabled(posthog_flag, distinct_id))
except FeatureFlagError as e:
logging.error("Error checking feature flag %s: %s", flag_name, e)
return default
return default
return default
@classmethod
def require(cls, flag_name):
-14
View File
@@ -31,10 +31,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_extractor import (
MetadataExtractorException,
MetadataExtractorService,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -305,7 +301,6 @@ class RoomViewSet(
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
@@ -332,15 +327,6 @@ class RoomViewSet(
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if (
settings.ROOM_METADATA_EXTRACTOR_ENABLED
and recording.mode == models.RecordingModeChoices.TRANSCRIPT
):
try:
MetadataExtractorService().start(recording)
except MetadataExtractorException:
pass
return drf_response.Response(
{"message": f"Recording successfully started for room {room.slug}"},
status=drf_status.HTTP_201_CREATED,
@@ -1 +0,0 @@
"""Meet core external API endpoints"""
@@ -1,109 +0,0 @@
"""Authentication Backends for external application to the Meet core app."""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
import jwt
from rest_framework import authentication, exceptions
User = get_user_model()
logger = logging.getLogger(__name__)
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
Returns:
Tuple of (user, payload) if authentication successful, None otherwise
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
Args:
token: JWT token string
Returns:
Tuple of (user, payload)
Raises:
AuthenticationFailed: If token is invalid, expired, or user not found
"""
# Decode and validate JWT
try:
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except jwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt.InvalidTokenError as e:
logger.warning("Invalid JWT token: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
@@ -1,76 +0,0 @@
"""Permission handlers for application-delegated API access."""
import logging
from typing import Dict
from rest_framework import exceptions, permissions
from .. import models
logger = logging.getLogger(__name__)
class BaseScopePermission(permissions.BasePermission):
"""Base class for scope-based permission checking.
Subclasses must define `scope_map` attribute mapping actions to required scopes.
"""
scope_map: Dict[str, str] = {}
def has_permission(self, request, view):
"""Check if the JWT token contains the required scope for this action.
Args:
request: DRF request object with authenticated user
view: ViewSet instance
Returns:
bool: True if permission granted
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
token_payload = request.auth
token_scopes = token_payload.get("scope")
if not token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions.")
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return True
class HasRequiredRoomScope(BaseScopePermission):
"""Permission class for Room-related operations."""
scope_map = {
"list": models.ApplicationScope.ROOMS_LIST,
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE,
"create": models.ApplicationScope.ROOMS_CREATE,
"update": models.ApplicationScope.ROOMS_UPDATE,
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
@@ -1,73 +0,0 @@
"""Serializers for the external API of the Meet core app."""
# pylint: disable=abstract-method
from django.conf import settings
from rest_framework import serializers
from core import models, utils
from core.api.serializers import BaseValidationOnlySerializer
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
class ApplicationJwtSerializer(BaseValidationOnlySerializer):
"""Validate OAuth2 JWT token request data."""
client_id = serializers.CharField(write_only=True)
client_secret = serializers.CharField(write_only=True)
grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS])
scope = serializers.CharField(write_only=True)
class RoomSerializer(serializers.ModelSerializer):
"""External API serializer for room data exposed to applications.
Provides limited, safe room information for third-party integrations:
- Secure defaults for room creation (trusted access level)
- Computed fields (url, telephony) for external consumption
- Filtered data appropriate for delegation scenarios
- Tracks creation source for auditing
Intentionally exposes minimal information to external applications,
following the principle of least privilege.
"""
class Meta:
model = models.Room
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."""
output = super().to_representation(instance)
request = self.context.get("request")
pin_code = output.pop("pin_code", None)
if not request:
return output
# Add room URL for direct access
if settings.APPLICATION_BASE_URL:
output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}"
# Add telephony information if enabled
if settings.ROOM_TELEPHONY_ENABLED:
output["telephony"] = {
"enabled": True,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER,
"pin_code": pin_code,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
}
return output
def create(self, validated_data):
"""Create room with secure defaults for application delegation."""
# Set secure defaults
validated_data["name"] = utils.generate_room_slug()
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
return super().create(validated_data)
-194
View File
@@ -1,194 +0,0 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
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.validators import validate_email
import jwt
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import api, models
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
detail=False,
methods=["post"],
url_path="token",
url_name="token",
)
def generate_jwt_access_token(self, request, *args, **kwargs):
"""Generate JWT access token for application delegation.
Validates application credentials and generates a JWT token scoped
to a specific user email, allowing the application to act on behalf
of that user.
Note: The 'scope' parameter accepts an email address to identify the user
being delegated. This design allows applications to obtain user-scoped tokens
for delegation purposes. The scope field is intentionally generic and can be
extended to support other values in the future.
Reference: https://stackoverflow.com/a/27711422
"""
serializer = serializers.ApplicationJwtSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
client_id = serializer.validated_data["client_id"]
client_secret = serializer.validated_data["client_secret"]
try:
application = models.Application.objects.get(client_id=client_id)
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
email = serializer.validated_data["scope"]
try:
validate_email(email)
except ValidationError:
return drf_response.Response(
{
"error": "Scope should be a valid email address.",
},
status=drf_status.HTTP_400_BAD_REQUEST,
)
if not application.can_delegate_email(email):
logger.warning(
"Application %s denied delegation for %s",
application.client_id,
email,
)
return drf_response.Response(
{
"error": "This application is not authorized for this email domain.",
},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
class RoomViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
Provides JWT-authenticated access to room operations for external applications
acting on behalf of users. All operations are scope-based and filtered to the
authenticated user's accessible rooms.
Supported operations:
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
"""
authentication_classes = [authentication.ApplicationJWTAuthentication]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
user = self.request.user
if user.is_authenticated:
queryset = (
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
)
else:
queryset = self.get_queryset().none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id,
self.request.user.id,
getattr(self.request.auth, "client_id", "unknown"),
)
+1 -37
View File
@@ -9,7 +9,7 @@ from django.utils.text import slugify
import factory.fuzzy
from faker import Faker
from core import models, utils
from core import models
fake = Faker()
@@ -117,39 +117,3 @@ class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
recording = factory.SubFactory(RecordingFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
class ApplicationFactory(factory.django.DjangoModelFactory):
"""Create fake applications for testing."""
class Meta:
model = models.Application
name = factory.Faker("company")
active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
class Params:
"""Factory traits for common application configurations."""
with_all_scopes = factory.Trait(
scopes=[
models.ApplicationScope.ROOMS_LIST,
models.ApplicationScope.ROOMS_RETRIEVE,
models.ApplicationScope.ROOMS_CREATE,
models.ApplicationScope.ROOMS_UPDATE,
models.ApplicationScope.ROOMS_DELETE,
]
)
class ApplicationDomainFactory(factory.django.DjangoModelFactory):
"""Create fake application domains for testing."""
class Meta:
model = models.ApplicationDomain
domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory)
-43
View File
@@ -1,43 +0,0 @@
"""
Core application fields
"""
from logging import getLogger
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
logger = getLogger(__name__)
class SecretField(models.CharField):
"""CharField that automatically hashes secrets before saving.
Use for API keys, client secrets, or tokens that should never be stored
in plain text. Already-hashed values are preserved to prevent double-hashing.
Inspired by: https://github.com/django-oauth-toolkit/django-oauth-toolkit
"""
def pre_save(self, model_instance, add):
"""Hash the secret if not already hashed, otherwise preserve it."""
secret = getattr(model_instance, self.attname)
try:
hasher = identify_hasher(secret)
logger.debug(
"%s: %s is already hashed with %s.",
model_instance,
self.attname,
hasher,
)
except ValueError:
logger.debug(
"%s: %s is not hashed; hashing it now.", model_instance, self.attname
)
hashed_secret = make_password(secret)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
@@ -1,52 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 20:55
import core.utils
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Descriptive name for this application.', max_length=255, verbose_name='Application name')),
('active', models.BooleanField(default=True)),
('client_id', models.CharField(default=core.utils.generate_client_id, max_length=100, unique=True)),
('client_secret', core.fields.SecretField(blank=True, default=core.utils.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)),
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), blank=True, default=list, size=None)),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
'db_table': 'meet_application',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ApplicationDomain',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('domain', models.CharField(help_text='Email domain this application can act on behalf of.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.application')),
],
options={
'verbose_name': 'Application domain',
'verbose_name_plural': 'Application domains',
'db_table': 'meet_application_domain',
'ordering': ('domain',),
'unique_together': {('application', 'domain')},
},
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.6 on 2025-09-25 15:14
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.AddField(
model_name='recording',
name='metadata_dispatch_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=[('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'), ('de-de', 'German')], default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+1 -101
View File
@@ -11,7 +11,6 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
@@ -19,10 +18,8 @@ from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -576,6 +573,7 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
metadata_dispatch_id = models.CharField(max_length=100, null=True, blank=True)
class Meta:
db_table = "meet_recording"
@@ -720,101 +718,3 @@ class RecordingAccess(BaseAccess):
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
class ApplicationScope(models.TextChoices):
"""Available permission scopes for application operations."""
ROOMS_CREATE = "rooms:create", _("Create rooms")
ROOMS_LIST = "rooms:list", _("List rooms")
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
class Application(BaseModel):
"""External application for API authentication and authorization.
Represents a third-party integration or automated system that accesses
the API using OAuth2-style client credentials (client_id/client_secret).
Supports scoped permissions and optional domain restrictions for delegation.
"""
name = models.CharField(
max_length=255,
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
client_secret = fields.SecretField(
max_length=255,
blank=True,
default=utils.generate_client_secret,
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
)
scopes = ArrayField(
models.CharField(max_length=50, choices=ApplicationScope.choices),
default=list,
blank=True,
)
class Meta:
db_table = "meet_application"
ordering = ("-created_at",)
verbose_name = _("Application")
verbose_name_plural = _("Applications")
def __str__(self):
return f"{self.name!s}"
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
if not self.allowed_domains.exists():
return True # No domain restrictions
domain = get_domain_from_email(email)
return self.allowed_domains.filter(domain__iexact=domain).exists()
class ApplicationDomain(BaseModel):
"""Domain authorized for application delegation."""
domain = models.CharField(
max_length=253, # Max domain length per RFC 1035
validators=[
validators.DomainNameValidator(
accept_idna=False,
message=_("Enter a valid domain"),
)
],
verbose_name=_("Domain"),
help_text=_("Email domain this application can act on behalf of."),
)
application = models.ForeignKey(
"Application",
on_delete=models.CASCADE,
related_name="allowed_domains",
)
class Meta:
db_table = "meet_application_domain"
ordering = ("domain",)
verbose_name = _("Application domain")
verbose_name_plural = _("Application domains")
unique_together = [("application", "domain")]
def __str__(self):
"""Return string representation of the domain."""
return self.domain
def save(self, *args, **kwargs):
"""Save the domain after normalizing to lowercase."""
self.domain = self.domain.lower().strip()
super().save(*args, **kwargs)
@@ -131,8 +131,10 @@ class NotificationService:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
worker_id = recording.worker_id
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
@@ -143,6 +145,7 @@ class NotificationService:
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"worker_id": worker_id,
}
headers = {
@@ -1,97 +0,0 @@
"""Wip."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
logger = getLogger(__name__)
class MetadataExtractorException(Exception):
"""Wip."""
class MetadataExtractorService:
"""Wip."""
@async_to_sync
async def start(self, recording):
"""Wip."""
lkapi = utils.create_livekit_client()
room_id = str(recording.room.id)
try:
response = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.ROOM_METADATA_EXTRACTOR_AGENT_NAME,
room=room_id,
metadata=str(recording.id),
)
)
except Exception as e:
logger.exception(
"Failed to create metadata extractor agent for room %s", room_id
)
raise MetadataExtractorException(
"Failed to create metadata extractor 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 MetadataExtractorException(
f"LiveKit did not return a dispatch_id for room {room_id}"
)
return dispatch_id
@async_to_sync
async def stop(self, recording):
"""Wip."""
room_name = str(recording.room.id)
lkapi = utils.create_livekit_client()
try:
dispatches = await lkapi.agent_dispatch.list_dispatch(room_name=room_name)
dispatch_id = next(
(
d.id
for d in dispatches
if d.agent_name == settings.ROOM_METADATA_EXTRACTOR_AGENT_NAME
),
None,
)
if not dispatch_id:
logger.warning(
"No metadata extractor agent found for room %s", room_name
)
return None
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=room_name
)
except Exception as e:
logger.exception(
"Failed to stop metadata extractor agent dispatch for room %s",
room_name,
)
raise MetadataExtractorException(
f"Failed to stop metadata metadata extractor agent for room {room_name}"
) from e
finally:
await lkapi.aclose()
@@ -1,8 +1,19 @@
"""Recording-related LiveKit Events Service"""
import asyncio
import logging
from logging import getLogger
from django.core.exceptions import ObjectDoesNotExist
from django.db import DatabaseError, transaction
import aiohttp
from core import models, utils
from core.api.feature_flag import FeatureFlag
from core.services.metadata import MetadataService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -11,6 +22,21 @@ class RecordingEventsError(Exception):
"""Recording event handling fails."""
def get_recording_creator_id(recording: models.Recording) -> str | None:
"""Get the user ID of the recording creator (owner)."""
owner_id = (
models.RecordingAccess.objects.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
user__isnull=False,
)
.order_by("created_at")
.values_list("user_id", flat=True)
.first()
)
return str(owner_id) if owner_id else None
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@@ -47,3 +73,127 @@ class RecordingEventsService:
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def handle_egress_started(recording):
"""Start metadata agent after transaction commit."""
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
service = MetadataService()
logger.info(
"Scheduling metadata start for recording=%s room_id=%s", rec_id, room_id
)
def _start():
"""Start metadata agent after transaction commit."""
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "status", "room_id", "room__id")
.get(id=rec_id)
)
if rec.status not in (
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
):
logger.info(
"Skip metadata start: status=%s (rec=%s)", rec.status, rec.id
)
return
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at start: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
dispatch_id = service.start_metadata(room, rec_id)
rec.metadata_dispatch_id = dispatch_id
rec.save(update_fields=["metadata_dispatch_id"])
logger.info(
"Metadata start dispatched for rec=%s room_id=%s", rec.id, room.pk
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during start (rec=%s, room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_start, robust=True)
@staticmethod
def handle_egress_ended(recording):
"""Stop metadata agent after transaction commit."""
service = MetadataService()
rec_id = recording.id
room_id = recording.room_id
creator_id = get_recording_creator_id(recording)
if not FeatureFlag.flag_is_active("metadata_agent", distinct_id=creator_id):
logger.info("Metadata agent disabled by PostHog flag for id=%s", creator_id)
return
def _stop():
try:
rec = (
models.Recording.objects.select_related("room")
.only("id", "room_id", "room__id")
.get(id=rec_id)
)
room = rec.room
if room.pk != room_id:
logger.error(
"Room mismatch at stop: rec.room_id=%s event.room_id=%s",
room.pk,
room_id,
)
return
try:
service.stop_metadata(room, rec.metadata_dispatch_id)
logger.info(
"Metadata stop dispatched for rec=%s room_id=%s",
rec.id,
room.pk,
)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
logger.warning(
"Controller unreachable during stop (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except ObjectDoesNotExist as e:
logger.warning(
"Recording or Room not found while stopping (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
except DatabaseError as e:
logger.warning(
"DB error while stopping metadata (rec=%s room_id=%s): %s",
rec_id,
room_id,
e,
exc_info=True,
)
transaction.on_commit(_stop, robust=True)
+136 -16
View File
@@ -2,6 +2,8 @@
# pylint: disable=no-member
import logging
import re
import uuid
from enum import Enum
from logging import getLogger
@@ -11,10 +13,6 @@ from django.conf import settings
from livekit import api
from core import models
from core.recording.services.metadata_extractor import (
MetadataExtractorException,
MetadataExtractorService,
)
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -23,6 +21,8 @@ from core.recording.services.recording_events import (
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
@@ -81,6 +81,30 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended"
def _extract_recording_id_from_egress(data) -> str | None:
"""Try to extract recording_id from egress data filenames.
On failure, return None.
"""
ei = getattr(data, "egress_info", None)
if not ei:
return None
fname = getattr(getattr(ei, "file", None), "filename", None)
if not fname:
try:
outs = getattr(getattr(ei, "room_composite", None), "file_outputs", [])
if outs:
fname = getattr(outs[0], "filepath", None)
except (AttributeError, IndexError, TypeError):
fname = None
if not fname:
return None
m = re.search(r"([0-9a-fA-F-]{36})\.ogg$", fname)
return m.group(1) if m else None
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
@@ -98,7 +122,7 @@ class LiveKitEventsService:
def receive(self, request):
"""Process webhook and route to appropriate handler."""
logger.debug("LiveKit webhook received")
auth_token = request.headers.get("Authorization")
if not auth_token:
raise AuthenticationError("Authorization header missing")
@@ -122,15 +146,19 @@ class LiveKitEventsService:
if not handler or not callable(handler):
return
logger.debug("Handling LiveKit webhook event: %s", data)
# pylint: disable=not-callable
handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
logger.debug(
"Egress ended: id=%s status=%s",
getattr(data.egress_info, "egress_id", None),
getattr(data.egress_info, "status", None),
)
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:
@@ -138,14 +166,12 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
settings.ROOM_METADATA_EXTRACTOR_ENABLED
and recording.mode == models.RecordingModeChoices.TRANSCRIPT
):
try:
MetadataExtractorService().stop(recording)
except MetadataExtractorException:
pass
try:
self.recording_events.handle_egress_ended(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_ended for recording {recording.id}"
) from e
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
@@ -158,6 +184,100 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_egress_started(self, data):
ei = getattr(data, "egress_info", None) or (
data.get("egress_info") if isinstance(data, dict) else None
)
egress_id = None
room_name = None
status = None
if ei:
egress_id = getattr(ei, "egress_id", None) or getattr(ei, "egressId", None)
room_name = getattr(ei, "room_name", None) or getattr(ei, "roomName", None)
status = getattr(ei, "status", None)
logger.warning(
"egress_started: egress_id=%s status=%s room_name=%s",
egress_id,
status,
room_name,
)
if not egress_id:
logger.error("egress_started without egress_id")
return
rec = models.Recording.objects.filter(worker_id=egress_id).first()
if rec is None:
rec_id = _extract_recording_id_from_egress(data)
if rec_id:
rec = (
models.Recording.objects.filter(id=rec_id)
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Attached worker_id=%s to recording=%s via filename",
egress_id,
rec.id,
)
if rec is None and room_name:
rec = (
models.Recording.objects.filter(
room__livekit_name=room_name,
status__in=[
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.ACTIVE,
],
)
.order_by("-created_at")
.select_related("room")
.first()
)
if rec and not rec.worker_id:
rec.worker_id = egress_id
rec.save(update_fields=["worker_id"])
logger.info(
"Heuristically attached worker_id=%s to recording=%s via livekit room",
egress_id,
rec.id,
)
if rec is None:
logger.warning(
"egress_started: unknown egress_id=%s (room_name=%s)",
egress_id,
room_name,
)
return
lk_name = getattr(rec.room, "livekit_name", None)
if room_name and lk_name and lk_name != room_name:
logger.error(
"Room mismatch: rec[%s].room=%s != event.room=%s",
rec.id,
lk_name,
room_name,
)
return
try:
logger.debug(
"Processing egress_started for recording %s (room=%s)",
rec.id,
lk_name or rec.room_id,
)
self.recording_events.handle_egress_started(rec)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process egress_started for recording {rec.id}"
) from e
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
+88
View File
@@ -0,0 +1,88 @@
"""Service for managing metadata agents in LiveKit rooms."""
import json
import logging
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import (
CreateAgentDispatchRequest,
)
from core import utils
logging.basicConfig(level=logging.DEBUG)
logger = getLogger(__name__)
class MetadataException(Exception):
"""Exception raised when metadata operations fail."""
class MetadataService:
"""Service for managing metadata agents in LiveKit rooms."""
def __init__(self) -> None:
self.agent_name = settings.ROOM_METADATA_AGENT_NAME
@async_to_sync
async def start_metadata(self, room, recording_id) -> None:
"""Start metadata agent in the specified room."""
lkapi = utils.create_livekit_client()
try:
payload = (
json.dumps({"recording_id": str(recording_id)})
if recording_id
else None
)
resp = await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=self.agent_name,
room=str(room.id),
metadata=payload,
)
)
dispatch_id = resp.id
dispatch_id = getattr(resp, "id", None)
if not dispatch_id:
raise MetadataException("LiveKit did not return a dispatch_id")
logger.info(
"Agent dispatch created: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
return dispatch_id
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise MetadataException("Failed to create metadata agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_metadata(self, room, dispatch_id) -> None:
"""Stop metadata agent in the specified room."""
logger.info(
"deleting agent dispatch: room=%s agent=%s dispatch_id=%s",
room.id,
self.agent_name,
dispatch_id,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.agent_dispatch.delete_dispatch(
dispatch_id=str(dispatch_id), room_name=str(room.id)
)
logger.info(
"Agent dispatch deleted: room=%s agent=%s", room.id, self.agent_name
)
except Exception as e:
logger.exception("Failed to delete agent dispatch for room %s", room.id)
raise MetadataException("Failed to stop metadata agent") from e
finally:
await lkapi.aclose()
@@ -183,9 +183,10 @@ def test_start_recording_success(
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 201
assert response.json() == {
"message": f"Recording successfully started for room {room.slug}"
}
assert (
response.json()["message"]
== f"Recording successfully started for room {room.slug}"
)
# Verify the mediator was called with the recording
recording = Recording.objects.first()
@@ -1,334 +0,0 @@
"""
Tests for external API /room endpoint
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from django.conf import settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room
pytestmark = pytest.mark.django_db
def generate_test_token(user, scopes):
"""Generate a valid JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
def test_api_rooms_list_requires_authentication():
"""Listing rooms without authentication should return 401."""
client = APIClient()
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
def test_api_rooms_list_with_invalid_token():
"""Listing rooms with invalid token should return 401."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_LIST scope
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 2
returned_ids = [r["id"] for r in response.data["results"]]
assert str(room1.id) in returned_ids
assert str(room3.id) in returned_ids
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_RETRIEVE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:retrieve" in str(
response.data
)
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "US"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert response.data == {
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"access_level": str(room.access_level),
"url": f"http://your-application.com/{room.slug}",
"telephony": {
"enabled": True,
"phone_number": "+1-555-0100",
"pin_code": room.pin_code,
"default_country": "US",
},
}
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
)
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "url" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "telephony" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
# Missing client_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
@@ -1,275 +0,0 @@
"""
Tests for external API /token endpoint
"""
# pylint: disable=W0621
import jwt
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core.factories import (
ApplicationDomainFactory,
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope
pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
# Store plain secret before it's hashed
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": "invalid-client-id",
"client_secret": "some-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "not-an-email",
},
format="json",
)
assert response.status_code == 400
assert "scope should be a valid email address." in str(response.data).lower()
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 403
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "nonexistent@example.com",
},
format="json",
)
assert response.status_code == 404
assert "User not found" in str(response.data)
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@@ -1,331 +0,0 @@
"""
Unit tests for the Application and ApplicationDomain models
"""
# pylint: disable=W0613
from unittest import mock
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
import pytest
from core.factories import ApplicationDomainFactory, ApplicationFactory
from core.models import Application, ApplicationDomain, ApplicationScope
pytestmark = pytest.mark.django_db
# Application Model Tests
def test_models_application_str():
"""The str representation should be the name."""
application = ApplicationFactory(name="My Integration")
assert str(application) == "My Integration"
def test_models_application_name_maxlength():
"""The name field should be at most 255 characters."""
ApplicationFactory(name="a" * 255)
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(name="a" * 256)
assert "Ensure this value has at most 255 characters (it has 256)." in str(
excinfo.value
)
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
def test_models_application_scopes_default():
"""Scopes should default to empty list."""
application = Application.objects.create(name="Test App")
assert application.scopes == []
def test_models_application_client_id_auto_generated():
"""Client ID should be automatically generated on creation."""
application = ApplicationFactory()
assert application.client_id is not None
assert len(application.client_id) > 0
def test_models_application_client_id_unique():
"""Client IDs should be unique."""
app1 = ApplicationFactory()
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(client_id=app1.client_id)
assert "Application with this Client id already exists." in str(excinfo.value)
def test_models_application_client_id_length(settings):
"""Client ID should match configured length."""
app1 = ApplicationFactory()
assert len(app1.client_id) == 40 # default value
settings.APPLICATION_CLIENT_ID_LENGTH = 20
app2 = ApplicationFactory()
assert len(app2.client_id) == 20
def test_models_application_client_secret_auto_generated():
"""Client secret should be automatically generated and hashed on creation."""
application = ApplicationFactory()
assert application.client_secret is not None
assert len(application.client_secret) > 0
def test_models_application_client_secret_hashed_on_save():
"""Client secret should be hashed when saved."""
plain_secret = "my-plain-secret"
with mock.patch(
"core.models.utils.generate_client_secret", return_value=plain_secret
):
application = ApplicationFactory(client_secret=plain_secret)
# Secret should be hashed, not plain
assert application.client_secret != plain_secret
# Should verify with check_password
assert check_password(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
"""Re-saving should not re-hash an already hashed secret."""
application = ApplicationFactory()
original_hash = application.client_secret
# Update another field and save
application.name = "Updated Name"
application.save()
# Hash should remain unchanged
assert application.client_secret == original_hash
def test_models_application_updates_preserve_client_id():
"""Application updates should preserve existing client_id."""
application = ApplicationFactory()
original_client_id = application.client_id
application.name = "Updated Name"
application.save()
assert application.client_id == original_client_id
def test_models_application_scopes_valid_choices():
"""Only valid scope choices should be accepted."""
application = ApplicationFactory(
scopes=[
ApplicationScope.ROOMS_LIST,
ApplicationScope.ROOMS_CREATE,
ApplicationScope.ROOMS_RETRIEVE,
]
)
assert len(application.scopes) == 3
assert ApplicationScope.ROOMS_LIST in application.scopes
def test_models_application_scopes_invalid_choice():
"""Invalid scope choices should raise validation error."""
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(scopes=["invalid:scope"])
assert "is not a valid choice" in str(excinfo.value)
def test_models_application_can_delegate_email_no_restrictions():
"""Application with no domain restrictions can delegate any email."""
application = ApplicationFactory()
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@anotherdomain.org") is True
def test_models_application_can_delegate_email_allowed_domain():
"""Application can delegate email from allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
def test_models_application_can_delegate_email_denied_domain():
"""Application cannot delegate email from non-allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@other.com") is False
def test_models_application_can_delegate_email_case_insensitive():
"""Domain matching should be case-insensitive."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@EXAMPLE.COM") is True
assert application.can_delegate_email("user@Example.Com") is True
def test_models_application_can_delegate_email_multiple_domains():
"""Application with multiple allowed domains should check all."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.org")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@other.org") is True
assert application.can_delegate_email("test@denied.com") is False
# ApplicationDomain Model Tests
def test_models_application_domain_str():
"""The str representation should be the domain."""
domain = ApplicationDomainFactory(domain="example.com")
assert str(domain) == "example.com"
def test_models_application_domain_ordering():
"""Domains should be returned ordered by domain name."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="zulu.com")
ApplicationDomainFactory(application=application, domain="alpha.com")
ApplicationDomainFactory(application=application, domain="beta.com")
domains = ApplicationDomain.objects.all()
assert domains[0].domain == "alpha.com"
assert domains[1].domain == "beta.com"
assert domains[2].domain == "zulu.com"
@pytest.mark.parametrize(
"valid_domain",
[
"example.com",
"sub.example.com",
"deep.sub.example.com",
"example-with-dash.com",
"123.example.com",
],
)
def test_models_application_domain_valid_domain(valid_domain):
"""Valid domain names should be accepted."""
ApplicationDomainFactory(domain=valid_domain)
@pytest.mark.parametrize(
"invalid_domain",
[
"not a domain",
"example..com",
"-example.com",
"example-.com",
"example.com-",
],
)
def test_models_application_domain_invalid_domain(invalid_domain):
"""Invalid domain names should raise validation error."""
with pytest.raises(ValidationError):
ApplicationDomainFactory(domain=invalid_domain)
def test_models_application_domain_lowercase_on_save():
"""Domain should be normalized to lowercase on save."""
domain = ApplicationDomainFactory(domain="EXAMPLE.COM")
assert domain.domain == "example.com"
def test_models_application_domain_strip_whitespace_on_save():
"""Domain should strip whitespace on save."""
domain = ApplicationDomainFactory(domain=" example.com ")
assert domain.domain == "example.com"
def test_models_application_domain_combined_normalization():
"""Domain should strip and lowercase in one operation."""
domain = ApplicationDomainFactory(domain=" EXAMPLE.COM ")
assert domain.domain == "example.com"
def test_models_application_domain_unique_together():
"""Same domain cannot be added twice to same application."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
with pytest.raises(ValidationError) as excinfo:
ApplicationDomainFactory(application=application, domain="example.com")
assert "Application domain with this Application and Domain already exists." in str(
excinfo.value
)
def test_models_application_domain_same_domain_different_apps():
"""Same domain can belong to different applications."""
app1 = ApplicationFactory()
app2 = ApplicationFactory()
ApplicationDomainFactory(application=app1, domain="example.com")
ApplicationDomainFactory(application=app2, domain="example.com")
assert app1.allowed_domains.count() == 1
assert app2.allowed_domains.count() == 1
def test_models_application_domain_cascade_delete():
"""Deleting application should delete its domains."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.com")
assert ApplicationDomain.objects.count() == 2
application.delete()
assert ApplicationDomain.objects.count() == 0
def test_models_application_domain_related_name():
"""Domains should be accessible via application.allowed_domains."""
application = ApplicationFactory()
domain1 = ApplicationDomainFactory(application=application, domain="example.com")
domain2 = ApplicationDomainFactory(application=application, domain="other.com")
assert list(application.allowed_domains.all()) == [domain1, domain2]
def test_models_application_domain_filters_delegation():
"""Adding/removing domains should affect can_delegate_email."""
application = ApplicationFactory()
# No restrictions initially
assert application.can_delegate_email("user@example.com") is True
# Add domain restriction
domain = ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("user@other.com") is False
# Remove domain restriction
domain.delete()
assert application.can_delegate_email("user@other.com") is True
-27
View File
@@ -7,7 +7,6 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -18,20 +17,6 @@ router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
# - External API
external_router = DefaultRouter()
external_router.register(
"application",
external_viewsets.ApplicationViewSet,
basename="external_application",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -44,15 +29,3 @@ urlpatterns = [
),
),
]
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
f"external-api/{settings.EXTERNAL_API_VERSION}/",
include(
[
*external_router.urls,
]
),
)
)
-52
View File
@@ -8,8 +8,6 @@ Utils functions used in the core app
import hashlib
import json
import random
import secrets
import string
from typing import List, Optional
from uuid import uuid4
@@ -242,53 +240,3 @@ async def notify_participants(room_name: str, notification_data: dict):
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
def generate_secure_token(length: int = 30, charset: str = ALPHANUMERIC_CHARSET) -> str:
"""Generate a cryptographically secure random token.
Uses SystemRandom for proper entropy, suitable for OAuth tokens
and API credentials that must be non-guessable.
Inspired by: https://github.com/oauthlib/oauthlib/blob/master/oauthlib/common.py
Args:
length: Token length in characters (default: 30)
charset: Character set to use for generation
Returns:
Cryptographically secure random token
"""
return "".join(secrets.choice(charset) for _ in range(length))
def generate_client_id() -> str:
"""Generate a unique client ID for application authentication.
Returns:
Random client ID string
"""
return generate_secure_token(settings.APPLICATION_CLIENT_ID_LENGTH)
def generate_client_secret() -> str:
"""Generate a secure client secret for application authentication.
Returns:
Cryptographically secure client secret
"""
return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH)
def generate_room_slug():
"""Generate a random room slug in the format 'xxx-xxxx-xxx'."""
sizes = [3, 4, 3]
parts = [
"".join(secrets.choice(string.ascii_lowercase) for _ in range(size))
for size in sizes
]
return "-".join(parts)
+2 -56
View File
@@ -69,10 +69,6 @@ class Base(Configuration):
USE_SWAGGER = False
API_VERSION = "v1.0"
EXTERNAL_API_VERSION = "v1.0"
EXTERNAL_API_ENABLED = values.BooleanValue(
False, environ_name="EXTERNAL_API_ENABLED", environ_prefix=None
)
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
@@ -668,58 +664,9 @@ class Base(Configuration):
environ_prefix=None,
)
# Metadata Extractor settings
ROOM_METADATA_EXTRACTOR_ENABLED = values.BooleanValue(
False, environ_name="ROOM_METADATA_EXTRACTOR_ENABLED", environ_prefix=None
)
ROOM_METADATA_EXTRACTOR_AGENT_NAME = values.Value(
ROOM_METADATA_AGENT_NAME = values.Value(
"metadata-extractor",
environ_name="ROOM_METADATA_EXTRACTOR_AGENT_NAME",
environ_prefix=None,
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
environ_name="APPLICATION_CLIENT_ID_LENGTH",
environ_prefix=None,
)
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
128,
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
environ_prefix=None,
)
APPLICATION_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="APPLICATION_JWT_SECRET_KEY", environ_prefix=None
)
APPLICATION_JWT_ALG = values.Value(
"HS256",
environ_name="APPLICATION_JWT_ALG",
environ_prefix=None,
)
APPLICATION_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="APPLICATION_JWT_ISSUER",
environ_prefix=None,
)
APPLICATION_JWT_AUDIENCE = values.Value(
None,
environ_name="APPLICATION_JWT_AUDIENCE",
environ_prefix=None,
)
APPLICATION_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="APPLICATION_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
APPLICATION_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="APPLICATION_JWT_TOKEN_TYPE",
environ_prefix=None,
)
APPLICATION_BASE_URL = values.Value(
None,
environ_name="APPLICATION_BASE_URL",
environ_name="ROOM_METADATA_AGENT_NAME",
environ_prefix=None,
)
@@ -841,7 +788,6 @@ class Test(Base):
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
+3 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.39"
version = "0.1.38"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -38,7 +38,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.7",
"django==5.2.6",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -48,6 +48,7 @@ dependencies = [
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"posthog==6.0.3",
"psycopg[binary]==3.2.9",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
+1 -1
View File
@@ -38,7 +38,7 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.39",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.39",
"version": "0.1.38",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.39",
"version": "0.1.38",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -73,7 +73,6 @@ backend:
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
ROOM_SUBTITLE_ENABLED: True
ROOM_METADATA_EXTRACTOR_ENABLED: True
migrate:
@@ -263,23 +262,12 @@ agents:
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_OUTPUT_FOLDER: recordings
image:
repository: localhost:5001/meet-agents
pullPolicy: Always
tag: "latest"
command:
- "python"
- "metadata_extractor.py"
- "start"
# Extra volume mounts to manage our local custom CA and avoid to disable ssl
extraVolumeMounts:
- name: certs
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.13-beta.1
version: 0.0.12
-28
View File
@@ -74,20 +74,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
- path: /external-api/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- with .Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
@@ -124,20 +110,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /external-api/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- with $.Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.38",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.39",
"version": "0.1.38",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.39",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.39",
"version": "0.1.38",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.39",
"version": "0.1.38",
"author": "",
"license": "ISC",
"description": "",
+1 -7
View File
@@ -2,13 +2,7 @@
This is an experimental part of the stack. It currently lacks proper observability, unit tests, and other production-grade features. This serves as the base for AI features in Visio.
## How it works
Please refer to the [Recording feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md) and the [Transcription feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/transcription.md).
## How to develop
(To develop locally follow the instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
## Usage
From the root of the project:
+3 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -15,6 +15,8 @@ dependencies = [
"posthog==6.0.3",
"requests==2.32.4",
"sentry-sdk[fastapi, celery]==2.30.0",
"pandas>=2.2.0",
"numpy>=1.26.0"
]
[project.optional-dependencies]
+2 -3
View File
@@ -18,7 +18,6 @@ settings = get_settings()
class TaskCreation(BaseModel):
"""Task data."""
owner_id: str
filename: str
email: str
sub: str
@@ -26,7 +25,7 @@ class TaskCreation(BaseModel):
room: Optional[str]
recording_date: Optional[str]
recording_time: Optional[str]
worker_id: Optional[str]
router = APIRouter(prefix="/tasks")
@@ -36,7 +35,6 @@ async def create_task(request: TaskCreation):
"""Create a task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
request.filename,
request.email,
request.sub,
@@ -44,6 +42,7 @@ async def create_task(request: TaskCreation):
request.room,
request.recording_date,
request.recording_time,
request.worker_id,
],
queue=settings.transcribe_queue,
)
+2 -2
View File
@@ -118,9 +118,9 @@ class MetadataManager:
"retries": 0,
}
_required_args_count = 8
_required_args_count = 7
if len(task_args) != _required_args_count:
logger.error("Invalid number of arguments to enable metadata manager.")
logger.error("Invalid number of arguments.")
return
filename, email, _, received_at, *_ = task_args
+231 -19
View File
@@ -6,10 +6,13 @@ import json
import os
import tempfile
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
import numpy as np
import openai
import pandas as pd
import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
@@ -30,6 +33,159 @@ from summary.core.prompt import (
PROMPT_USER_PART,
)
def nanoseconds_to_seconds(nanoseconds: int) -> float:
"""Convert nanoseconds timestamp to seconds since epoch."""
return nanoseconds / 1_000_000_000
def parse_iso_timestamp(iso_string: str) -> float:
"""Convert ISO timestamp to seconds since epoch."""
dt = datetime.fromisoformat(iso_string.replace("+00:00", ""))
return dt.timestamp()
def calculate_overlap_vectorized(starts1, ends1, starts2, ends2):
"""Calculate overlap duration between two sets of time intervals (vectorized)."""
overlap_starts = np.maximum(starts1[:, None], starts2)
overlap_ends = np.minimum(ends1[:, None], ends2)
return np.maximum(0, overlap_ends - overlap_starts)
def build_speech_segments_df(events: List[Dict[str, Any]]) -> pd.DataFrame:
"""Build speech segments DataFrame from events.
Returns: DataFrame with columns [participant_id, start_time, end_time]
"""
df = pd.DataFrame(events)
df["timestamp"] = df["timestamp"].apply(parse_iso_timestamp)
starts = df[df["type"] == "speech_start"][["participant_id", "timestamp"]]
ends = df[df["type"] == "speech_end"][["participant_id", "timestamp"]]
starts = starts.sort_values("timestamp").reset_index(drop=True)
ends = ends.sort_values("timestamp").reset_index(drop=True)
segments = pd.merge(
starts.rename(columns={"timestamp": "start_time"}),
ends.rename(columns={"timestamp": "end_time"}),
left_index=True,
right_index=True,
suffixes=("_start", "_end"),
)
segments = segments[
segments["participant_id_start"] == segments["participant_id_end"]
]
segments = segments.rename(columns={"participant_id_start": "participant_id"})
segments = segments[["participant_id", "start_time", "end_time"]]
return segments
def assign_participant_ids( # noqa: PLR0912
diarization_output: Dict[str, Any],
metadatas: List[Dict[str, Any]],
recording_metadata: Dict[str, Any],
overlap_threshold: float = 0.3,
) -> Dict[str, Any]:
"""Assign participant IDs to WhisperX diarization speakers."""
recording_start = nanoseconds_to_seconds(recording_metadata["started_at"])
participant_segments_df = build_speech_segments_df(metadatas.get("events"))
if participant_segments_df.empty:
return {}
words_df = pd.DataFrame(diarization_output)
if words_df.empty:
return {}
words_df = words_df.dropna(subset=["start", "end"])
if words_df.empty:
return {}
if "speaker" not in words_df.columns:
words_df["speaker"] = "UNKNOWN"
words_df["speaker"] = words_df["speaker"].fillna("UNKNOWN")
words_df["abs_start"] = recording_start + words_df["start"]
words_df["abs_end"] = recording_start + words_df["end"]
speaker_segments_list = []
for speaker, group in words_df.groupby("speaker"):
grp = group.sort_values("abs_start").reset_index(drop=True)
segments = []
current_start = grp.iloc[0]["abs_start"]
current_end = grp.iloc[0]["abs_end"]
for idx in range(1, len(grp)):
if grp.iloc[idx]["abs_start"] - current_end < 1.0:
current_end = grp.iloc[idx]["abs_end"]
else:
segments.append(
{
"speaker": speaker,
"start_time": current_start,
"end_time": current_end,
}
)
current_start = grp.iloc[idx]["abs_start"]
current_end = grp.iloc[idx]["abs_end"]
segments.append(
{"speaker": speaker, "start_time": current_start, "end_time": current_end}
)
speaker_segments_list.extend(segments)
speaker_segments_df = pd.DataFrame(speaker_segments_list)
speaker_to_participant = {}
for speaker in speaker_segments_df["speaker"].unique():
spk_segs = speaker_segments_df[speaker_segments_df["speaker"] == speaker]
best_match = None
best_overlap = 0
for participant_id in participant_segments_df["participant_id"].unique():
part_segs = participant_segments_df[
participant_segments_df["participant_id"] == participant_id
]
overlaps = calculate_overlap_vectorized(
spk_segs["start_time"].values,
spk_segs["end_time"].values,
part_segs["start_time"].values,
part_segs["end_time"].values,
)
total_overlap = overlaps.sum()
total_speaker_duration = (
spk_segs["end_time"] - spk_segs["start_time"]
).sum()
if total_speaker_duration > 0:
overlap_ratio = total_overlap / total_speaker_duration
if overlap_ratio > best_overlap and overlap_ratio >= overlap_threshold:
best_overlap = overlap_ratio
best_match = participant_id
speaker_to_participant[speaker] = {
"participant_id": best_match,
"confidence": best_overlap,
}
for speaker, mapping in speaker_to_participant.items():
for participant in metadatas.get("participants", []):
if participant["participantId"] == mapping["participant_id"]:
speaker_to_participant[speaker] = participant["name"]
if mapping["confidence"] < overlap_threshold + 0.2:
speaker_to_participant[speaker] += "?"
break
return speaker_to_participant
settings = get_settings()
analytics = get_analytics()
@@ -135,7 +291,7 @@ class LLMService:
raise LLMException("LLM call failed.") from e
def format_segments(transcription_data):
def format_segments(transcription_data, metadata=None, manifest=None):
"""Format transcription segments from WhisperX into a readable conversation format.
Processes transcription data with segments containing speaker information and text,
@@ -143,17 +299,36 @@ def format_segments(transcription_data):
conversation with speaker labels.
"""
formatted_output = ""
if not transcription_data or not hasattr(transcription_data, "segments"):
if isinstance(transcription_data, dict) and "segments" in transcription_data:
segments = transcription_data["segments"]
else:
return "Error: Invalid transcription data format"
logger.info("Formatting segments with metadata: %s", metadata)
logger.info("Transcription data: %s", transcription_data)
if metadata:
mapping = assign_participant_ids(
diarization_output=transcription_data,
metadatas=metadata,
recording_metadata=manifest,
overlap_threshold=0.3,
)
logger.info("Speaker to participant mapping: %s", mapping)
previous_label = None
for segment in transcription_data:
spk = segment.get("speaker") or "UNKNOWN_SPEAKER"
text = segment.get("text") or ""
if not text:
continue
label = mapping.get(spk) or spk
if label != previous_label:
formatted_output += f"\n\n **{label}**: {text}"
else:
formatted_output += f" {text}"
previous_label = label
return formatted_output
else:
segments = transcription_data.segments
previous_speaker = None
previous_speaker = None
for segment in segments:
for segment in transcription_data:
speaker = segment.get("speaker", "UNKNOWN_SPEAKER")
text = segment.get("text", "")
if text:
@@ -202,9 +377,8 @@ def task_failure_handler(task_id, exception=None, **kwargs):
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
def process_audio_transcribe_summarize_v2( # noqa: PLR0915
self,
owner_id: str,
filename: str,
email: str,
sub: str,
@@ -212,6 +386,7 @@ def process_audio_transcribe_summarize_v2(
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
worker_id: Optional[str],
):
"""Process an audio file by transcribing it and generating a summary.
@@ -287,12 +462,49 @@ def process_audio_transcribe_summarize_v2(
os.remove(temp_file_path)
logger.debug("Temporary file removed: %s", temp_file_path)
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not transcription.segments
else format_segments(transcription)
)
if (
analytics.is_feature_enabled("is_metadata_agent_enabled", distinct_id=sub)
and settings.is_summary_enabled
):
file = filename.split("/")[1].split(".")[0]
metadata_obj = minio_client.get_object(
settings.aws_storage_bucket_name,
object_name=settings.metadata_file.format(filename=file),
)
file_manifest = "recordings/" + worker_id + ".json"
manifest_obj = minio_client.get_object(
settings.aws_storage_bucket_name,
object_name=file_manifest,
)
logger.info("Manifest file downloaded: %s", file_manifest)
logger.info("Downloading metadata file")
try:
metadata_bytes = metadata_obj.read()
metadata_json = json.loads(metadata_bytes.decode("utf-8"))
manifest_bytes = manifest_obj.read()
manifest_json = json.loads(manifest_bytes.decode("utf-8"))
finally:
metadata_obj.close()
metadata_obj.release_conn()
manifest_obj.close()
manifest_obj.release_conn()
logger.info("Metadata file successfully downloaded")
logger.debug("Manifest: %s", manifest_json)
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not getattr(transcription, "segments", None)
else format_segments(transcription.segments, metadata_json, manifest_json)
)
else:
formatted_transcription = (
DEFAULT_EMPTY_TRANSCRIPTION
if not transcription.segments
else format_segments(transcription.segments, None)
)
metadata_manager.track_transcription_metadata(task_id, transcription)
if not room or not recording_date or not recording_time:
@@ -321,7 +533,7 @@ def process_audio_transcribe_summarize_v2(
metadata_manager.capture(task_id, settings.posthog_event_success)
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
analytics.is_feature_enabled("summary-enabled", distinct_id=sub)
and settings.is_summary_enabled
):
logger.info("Queuing summary generation task.")
+1
View File
@@ -59,6 +59,7 @@ class Settings(BaseSettings):
# Summary related settings
is_summary_enabled: bool = True
metadata_file: Optional[str] = "recordings/{filename}-metadata.json"
# Sentry
sentry_is_enabled: bool = False