Visioconférence — détails techniques
Description et fonctionnalités de ce module : Visioconférence.
Deux artefacts Element Call, une seule version
L'appel est rendu par Element Call, déployé sous deux formes qui participent aux mêmes appels :
| Artefact | Où | À quoi il sert |
|---|---|---|
Image Docker ghcr.io/element-hq/element-call (full package) | host dédié call.ent-scolaire.fr (chart helm/matrix) | fenêtre d'appel autonome : « Ouvrir dans un onglet », et seule voie pour un invité extérieur sans session ENT |
Paquet npm @element-hq/element-call-embedded (embedded package) | embarqué dans le module, servi sous /chat/public/element-call/ | appel cadré dans la page de l'ENT |
Pourquoi deux : le full package suppose d'être servi à la racine de son origine (chemins d'assets absolus, service worker), il n'est donc pas hébergeable sous un sous-chemin de l'ENT. Le paquet embedded a des chemins relatifs — c'est ce qui permet de le servir depuis l'ENT lui-même, donc en même origine : pas de stockage tiers partitionné par le navigateur, et le jeton Matrix ne quitte pas l'origine de l'ENT.
Le tag de l'image (elementCall.image.tag, helm/matrix/values.yaml) et la version du paquet npm
(modules/chat-nats/frontend/package.json, épinglée sans ^) doivent rester identiques —
v0.25.0 / 0.25.0 aujourd'hui. Deux générations de client MatrixRTC ne se voient pas
nécessairement dans un même appel : la 0.25.0 supprime le « MatrixRTC legacy mode ». Monter l'un
sans l'autre expose à des participants invisibles selon la porte d'entrée qu'ils ont empruntée.
Comment l'appel démarre sans écran de connexion
POST /chat/rooms/:id/call(RoomController.startCall) s'assure du salon Matrix, puis renvoie un descripteur d'appel (RoomController.callTargets) :mode,embedUrl(relative, servie par l'ENT),callUrl(absolue, host dédié) et un code de transfert à usage unique.- L'iframe charge
bootstrapUrl: une page de transfert servie à côté de l'app embarquée (/chat/public/element-call/bootstrap.html, générée parcopy-element-call.mjs). C'est elle qui échange le code contre la session Matrix du compte fantôme@ent-*(GET /chat/matrix/handoff/:code) et l'écrit danslocalStorage["matrix-auth-store"]— la clé qu'Element Call lit quand il ne reçoit pas dewidgetId(mode autonome) — avant de se remplacer par l'app elle-même. - Le jeton est retiré du stockage local au démontage du panneau (
CallFrame), après l'iframe — Element Call réécrit lui-même cette clé, un nettoyage trop précoce serait annulé.
Une page parente ne peut écrire le stockage local de l'iframe que si elle partage son origine.
C'est toujours vrai pour la messagerie autonome (servie par le module), mais pas pour le
widget du dashboard : même origine en production (<origine>/dashboard), origines distinctes en
développement (:3001 face à :8090). La page de transfert, elle, est servie par le module donc
toujours à la même origine que l'app encadrée — le cadrage marche partout, et aucune page parente
ne manipule plus le jeton Matrix. Ne pas la confondre avec le bootstrap.html du chart
helm/matrix, qui sert la fenêtre autonome sur son host dédié et reste en place.
Clavardage pendant l'appel : ne rien réimplémenter
Element Call n'expose aucune fonction de clavardage (relevé sur ses 155 libellés d'interface : aucune clé de message, de tableau blanc, de sondage ni de captation — les deux clés « enregistrement » du paquet concernent l'inscription d'un invité). Inutile d'en ajouter une : la conversation d'où part l'appel porte déjà l'échange écrit.
Le panneau d'appel de la messagerie est donc en flux dans la colonne du fil (.thread-column),
à 55 % de sa hauteur, et non plus en recouvrement. Le mode plein écran reste disponible via une
classe (.call-frame.expanded) — jamais par un démontage de l'iframe, qui équivaudrait à quitter
la séance. C'est ce que vérifie 05_appel_cadre_dans_lent : le src de l'iframe est identique
avant, pendant et après la bascule, et les hauteurs sont mesurées plutôt que la « visibilité » du
composer (un élément simplement recouvert reste visible au sens de Playwright).
La bulle du dashboard, large de 340 px, ne peut pas présenter les deux : son panneau d'appel reste
plein écran et propose « Écrire dans la conversation », qui ouvre /chat sans le lien profond
?call= — rejoindre l'appel une seconde fois évincerait la première session (un seul appareil de
conférence par personne).
Enregistrement : la garde de consentement
Le module workflowhub livre le modèle droit-image (profil DECISIONNEL) et expose
GET /workflowhub/consentements/image?userIds=…&anneeScolaire=…, qui renvoie pour chaque personne
ACCORDE, REFUSE ou ABSENT.
Trois distinctions structurent cette vérification, et chacune est une erreur classique :
| Distinction | Pourquoi elle compte |
|---|---|
| Déposant ≠ personne concernée | quand un responsable répond pour son enfant, dossier.owner_user_id porte le parent. La clé utile est payload.personne.userId, préremplie à l'ouverture du formulaire |
| Refus ≠ absence de réponse | le formulaire porte un booléen autorisation.enregistrementVisio, lu en plus de l'état du dossier : un dossier ACCEPTE portant un refus ne vaut pas autorisation |
| En cours d'examen ≠ accordé | tant que l'établissement n'a pas décidé, il n'y a pas d'autorisation |
La décision la plus récente fait foi (DISTINCT ON … ORDER BY decided_at DESC) : une démarche peut
être refaite, après correction ou changement d'avis.
Le détail (référence du dossier, date de décision) n'est renvoyé que sur soi-même. Savoir qui bloque un enregistrement est nécessaire pour lever le blocage ; connaître la référence du dossier d'autrui ne l'est pas.
La garde est appliquée par le serveur, dans chat-nats. Le navigateur ne fait que lire l'état
qu'il publie : un contrôle écrit dans le navigateur se contourne, et l'engagement pris envers les
familles ne peut pas reposer sur du code que l'utilisateur maîtrise. C'est la raison pour laquelle
la vérification est passée du frontend (appel direct à WorkflowHub, supprimé depuis) au serveur.
ConsentGate interroge WorkflowHub sur le bus d'événements (workflowhub.consentements,
consommateur clusterisé), et non par HTTP avec le cookie de l'appelant : la décision ne doit pas
dépendre de la session de qui la déclenche — celle-ci peut être celle d'un participant, pas celle du
serveur qui applique la règle. GET /chat/rooms/:id/recording publie l'état complet
(available, allowed, canRecord, blocking nominatif, recording) et fait autorité.
Trois choix à ne pas défaire :
- la vérification porte sur tous les membres du salon, pas sur les seules personnes présentes : quelqu'un peut rejoindre en cours de séance, et n'autoriser que les présents reviendrait à contourner la règle en arrivant en retard ;
- si la vérification est indisponible, on n'affiche rien plutôt que d'affirmer que l'enregistrement est possible. Une garde qui échoue en s'ouvrant n'est pas une garde. Pendant une captation, l'indisponibilité de la vérification arrête l'enregistrement : le doute ne profite jamais à la captation ;
- l'organisateur est celui que désigne
activeCall.startedBydu salon, tranché côté serveur — le navigateur ne peut pas le deviner, il a pu rejoindre l'appel en cours de route.
Les noms des membres sont résolus sans filtre de visibilité de l'annuaire : ce sont les membres d'un salon dont l'appelant fait partie, il les côtoie déjà dans le fil ; les masquer donnerait une liste incomplète des personnes qui bloquent.
Où atterrira l'enregistrement
Le chart helm/matrix porte désormais les pièces nécessaires (désactivées par défaut) :
redis (file de travail entre le SFU et les workers), egress (le worker de captation, avec son
volume de travail) et la clé redis ajoutée à la configuration de LiveKit.
Deux conséquences à connaître avant d'activer :
- activer Redis impose de recharger LiveKit — les appels en cours sont coupés le temps du redémarrage. À faire hors temps scolaire ;
- le volume d'Egress n'est PAS le stockage final. La pile matrix et l'ENT ne tournent pas sur
le même cluster, et un volume
local-pathen RWO ne se partage ni entre nœuds ni entre clusters. Le fichier est donc écrit là pendant la captation, puis déposé dans l'espace documentaire de l'ENT par API — où il devient un vrai document (quota, partage, suppression) — et retiré du volume de travail.
Le poste le plus coûteux de toute la pile sera ce worker : il compose la scène dans un Chrome sans interface (1 cœur, 2 Gio demandés, et un volume de mémoire partagée dédié — sans lui, Chrome plante sur les 64 Mio par défaut d'un conteneur).
L'onglet Stockage de la configuration OpenENT a été ajouté pour cela : le volume de fichiers de l'ENT fait 5 Gio, ce que quelques heures de vidéo suffisent à remplir.
La chaîne côté chat-nats
| Pièce | Rôle |
|---|---|
ConsentGate | interroge WorkflowHub sur le bus ; withoutConsent est une fonction pure, testée |
LiveKitEgress / DefaultLiveKitEgress | pilote la captation en Twirp/JSON sur le SFU. Ne décide rien — il ignore les autorisations : le jour où la captation changera de technologie, la règle n'aura pas à être réécrite |
RecordingService / RecordingServiceMongoImpl | la décision, le registre chat.recordings, l'étiquetage nominatif et les demandes d'effacement |
Le jeton d'API LiveKit est un JWT HS256 signé avec le secret du SFU, écrit à la main (pas de SDK
Java) et portant le seul droit roomRecord : ni publication, ni administration. Sa signature et
la restriction de ses droits sont vérifiées par un test.
chat.recordings porte la référence lisible (VISIO-<AAAAMMJJ>-<6 car.>), qui sert à la fois
de nom de fichier et de référence citable dans une demande de suppression : une personne doit pouvoir
désigner la vidéo à supprimer sans manipuler un identifiant technique.
Routes exposées (toutes en chat.access, réservées aux membres du salon) :
POST/DELETE/GET /chat/rooms/:id/recording, GET /chat/recordings,
POST /chat/recordings/:id/erasure. Le refus pour défaut d'autorisation est un 403 porteur :
il renvoie blocking nominativement, parce qu'un refus opaque ne permet pas d'agir.
@SecuredAction à déployerCes cinq routes sont nouvelles : le répertoire securedaction/ généré doit être déployé avec les
classes, sinon elles répondent 404 avec un corps vide. Vaut aussi pour workflowhub, dont le
consommateur de bus est nouveau.
Comment la vidéo sort du cluster de visio
LiveKit ne sait déposer que sur un stockage objet (S3, GCS, Azure), dont la plateforme ne dispose
pas — le stockage de l'ENT est un système de fichiers. Et le volume de travail d'Egress vit dans
k3sprd tandis que l'ENT vit dans k3srct : un volume local-path en RWO ne se partage ni entre
nœuds ni entre clusters.
D'où un collecteur (conteneur adjoint au worker Egress, egress.collector du chart) : un script
shell qui surveille le répertoire de sortie et POSTe chaque fichier terminé vers
POST /chat/recordings/ingest/<référence>. Le flux va de la visio vers l'ENT : aucune vidéo
n'est exposée publiquement, et il n'y a pas de composant de stockage supplémentaire à exploiter.
| Détail | Pourquoi |
|---|---|
| deux mesures de taille espacées avant l'envoi | un fichier encore en cours d'écriture grossit ; sans cette attente on enverrait une vidéo tronquée |
| 200 ⇒ le fichier local est supprimé | c'est ce qui vide le volume de travail |
400/401/404/410 ⇒ déplacé dans refuses/ | refus définitif : réessayer ne changerait rien, mais supprimer perdrait une vidéo — et le laisser en place ferait boucler la collecte |
| autre code ⇒ conservé, nouvelle tentative | panne passagère (ENT en cours de redémarrage, réseau) |
La route d'ingestion reçoit des vidéos sans session ENT : mal gardée, ce serait un dépôt de
fichiers ouvert sur l'internet. Trois verrous — jeton partagé comparé en temps constant,
référence contrainte à VISIO-AAAAMMJJ-XXXXXX (elle sert de nom de fichier), et fiche
d'enregistrement existante obligatoire — plus une taille plafonnée à 4 Gio, faute de quoi un seul
envoi pourrait remplir le volume de fichiers de l'ENT.
La vidéo est déposée dans l'espace documentaire de l'organisateur et n'est pas partagée
automatiquement avec les participants : multiplier les copies d'une vidéo où figurent des mineurs
irait contre l'objectif même de l'encadrement. Le dépôt est idempotent (filtre sur l'absence de
documentId) — une livraison répétée ne crée pas de doublon.
Reste à faire
- l'activation : le bloc
chat.matrix.livekitdansent-core.yaml(url, clés du SFU,ingest-token,output-dir) et, danshelm/matrix,redis.enabled+egress.enabled+ le tag d'image d'Egress +egress.collector.entIngestUrl/ingestToken. Sans le bloc de configuration, l'état renvoieavailable: false, aucun bouton n'apparaît et la route d'ingestion répond410 chat.recording.notconfigured— état par défaut, et état actuel de la production ; - activer Redis recharge LiveKit et coupe les appels en cours : à faire hors temps scolaire.
Les trois endroits d'où part un appel
| Surface | Ce qui se passe |
|---|---|
Messagerie autonome /chat | panneau d'appel dans la page, sous le bandeau ENT (CallFrame du module) |
| Widget de dialogue du dashboard | même appel cadré, en plein écran par-dessus le dashboard (components/chat/CallFrame.tsx) — visible que la bulle soit ouverte ou fermée |
| Compagnon de bureau | notification native + bandeau + sonnerie ; « Rejoindre » ouvre l'ENT sur le lien profond ?call=<roomId> |
Le lien profond /chat?call=<roomId> rejoint directement l'appel du salon (l'endpoint est
idempotent : démarrer un appel déjà en cours revient à le rejoindre). C'est la seule voie d'entrée
du compagnon de bureau, qui ne connaît que l'identifiant reçu en temps réel.
Compagnon de bureau : pourquoi une connexion permanente
Le compagnon (modules/open-ent-desktop, Tauri) sonde l'ENT toutes les 30 s au mieux
(poll_interval_secs.max(30), cf. poller.rs). Un appel ne sonne qu'une trentaine de secondes :
un sondage arriverait après la sonnerie. src-tauri/src/chat_ws.rs ouvre donc une connexion
permanente sur /chat/ws — la passerelle temps réel du module, qui publie déjà call-started — et
notifie immédiatement.
- Authentification : le cookie de session signé
oneSessionId, envoyé en en-tête à la poignée de main. Le webview ne peut pas ouvrir cette connexion lui-même : la session vit dans le client HTTP Rust, pas dans le navigateur embarqué. - Libellés sans requête supplémentaire : la trame
rooms, émise à la connexion, porte à la fois l'identifiant de l'utilisateur (pour ne pas se notifier soi-même) et les noms de salons. - Ce que voit l'utilisateur : notification système (visible fenêtre fermée ou en arrière-plan),
bandeau persistant avec sonnerie synthétisée (aucun fichier audio embarqué — les sonneries
d'Element Call ne sont pas redistribuables ici), indicateur dans la zone de notification, et
fenêtre ramenée au premier plan. Préférence
videoconferenceet heures calmes respectées pour tout ce qui s'impose ; le bandeau, lui, est toujours émis. - Extinction de l'indicateur : au bout de 3 minutes (la fenêtre de fraîcheur du serveur), ou dès que la personne rejoint ou ignore. Il n'existe aucun signal de fin d'appel côté pont Matrix, donc personne ne peut nous dire mieux.
Le code de transfert étant à usage unique et commun aux deux URL, une seule des deux vues peut l'emprunter ; « Ouvrir dans un onglet » redemande donc un descripteur au serveur.
Paramètres passés à Element Call dans le fragment (jamais envoyé au serveur) :
roomId, homeserver (issu de MATRIX_URL), header=none (le bandeau Open ENT est le seul
chrome), confineToRoom=true, background=solid, theme=light et lang — cette dernière
résolue côté serveur (I18n.acceptLanguage), pour que les trois surfaces n'aient pas à la
recomposer chacune.
chat.matrix.call-mode (bloc chat.matrix d'ent-core.yaml) bascule le comportement sans
reconstruire le frontend : iframe (défaut) ou tab (comportement historique). Les trois
surfaces ci-dessus le respectent.
Le device Matrix est stable par utilisateur ENT (voulu : sinon la même personne apparaît en
double dans la conférence). Conséquence : rejoindre le même appel simultanément depuis le
panneau et depuis l'onglet fait s'évincer les deux sessions (même appartenance m.rtc.member).
L'interface ne présente jamais les deux à la fois.
Content-Security-Policy : rien à ajouter (et ce n'est pas évident)
L'iframe est en même origine, mais elle n'hérite pas de la CSP de l'ENT : une CSP ne se propage
aux documents encadrés que pour les schémas locaux (about:blank, blob:, data:, srcdoc), et
entcore ne pose l'en-tête CSP que sur les vues rendues, pas sur les ressources statiques
(vérifié en local et en production). Le document encadré n'a donc pas de CSP et ses appels au
homeserver et au SFU LiveKit ne sont pas filtrés. Seul frame-src 'self' — d éjà présent — est
requis, pour autoriser le cadre lui-même.
Corollaire à retenir : poser une CSP globale au niveau de Traefik casserait l'appel. Il faudrait
alors y autoriser le homeserver (MATRIX_URL), le service de jetons LiveKit
(https://livekit-jwt.…) et le SFU (wss://livekit.…).
Activation sur un déploiement
Le pont est désactivé par défaut : sans jeton d'Application Service, toute demande d'appel est refusée avec le message « Les appels ne sont pas activés sur ce serveur. ».
L'activer suppose :
- un serveur Matrix et un Element Call déployés (voir le chart
helm/matrixdu dépôtopen-ent-mods) ; - l'enregistrement de l'Application Service
chat-natsauprès du homeserver, qui produit les jetonsas_token(sortant) eths_token(transactions entrantes) ; - le renseignement de ces jetons dans la configuration du module (
CHAT_MATRIX_AS_TOKEN,CHAT_MATRIX_HS_TOKEN).
Couverture de tests
Scénarios e2e Playwright dans
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/ :
03_appel_visio.spec.ts— affichage des actions d'appel (vidéo, vocal, invitation d'un contact externe) dans l'en-tête d'un salon.05_appel_cadre_dans_lent.spec.ts— appel cadré dans l'ENT, sans effet de bord : le descripteur d'appel et l'échange du code de transfert sont interceptés (aucun salon Matrix créé, personne notifié). Vérifie le panneau, l'URL réellement chargée dans l'iframe (/chat/public/element-call/), la délégation des permissions média (allow=), le démarrage effectif d'Element Call dans l'iframe (il applique letheme=lightdemandé sur son propre<body>), la persistance du bandeau ENT, les deux commandes du panneau et le retrait du jeton Matrix du stockage local à la fermeture. Produit la capture de la fiche fonctionnelle.04_appel_en_cours.spec.ts— notification du destinataire lors d'un appel entrant. Ce test écrit : il crée un salon Matrix et notifie le destinataire, donc il est verrouillé sur une conversation précise entre deux comptes de test — si elle n'est pas trouvée, le test s'arrête sans appeler, plutôt que de sonner dans un salon quelconque. Le salon visé se règle par la variableE2E_CHAT_PEER_LABEL(libellé tel qu'affiché dans la liste des conversations).
Reste non illustrée la séance de visioconférence elle-même (grille des participants, partage
d'écran, couper le son) : elle appartient à Element Call, pas à l'ENT, et exige deux participants
réels avec une session de conférence établie — non simulable en e2e. 05_appel_cadre_dans_lent
s'arrête donc au démarrage de la fenêtre d'appel, et 04_appel_en_cours ne capture l'appel réel que
sur une instance dont le pont Matrix est configuré.
Conformité
| Maillon de la chaîne qualité | Référence |
|---|---|
| 🎯 Fonctionnalités attendues | fiche fonctionnelle |
| 🧪 Tests réalisés | couverture e2e ci-dessus (à élargir) |
| ✅ Tests de conformité | tableau de conformité |