Messagerie instantanée — détails techniques
Description et fonctionnalités de ce module : Messagerie instantanée.
Élargir une conversation à deux (DM → groupe)
POST /chat/rooms/:id/members accepte désormais un DM en plus des salons de groupe. Le corps peut
porter un title : il n'est utilisé que si le salon est encore un DM, auquel cas
ChatService.convertDmToGroup s'exécute avant l'ajout des membres.
Trois invariants sont en jeu, et chacun casse quelque chose de visible s'il est oublié :
| Invariant | Sans lui |
|---|---|
type passe de dm à group | un « DM à trois », que l'interface continuerait de présenter comme un tête-à-tête |
dmKey est retiré ($unset) | les deux personnes d'origine ne pourraient plus jamais rouvrir un vrai tête-à-tête : getOrCreateDm cherche sur type=dm + dmKey et retomberait sur ce salon |
la personne qui élargit passe en role=owner | les deux membres d'un DM étant enregistrés en role=member, le salon obtenu n'aurait aucun propriétaire — donc plus personne pour le renommer ni le supprimer (isOwner) |
La conversion est idempotente : son filtre Mongo porte sur type=dm, la rejouer ne touche plus
rien. Un salon repris d'un groupe ENT (sourceGroupId) reste, lui, refusé — sa composition ne doit
pas diverger de ce groupe.
Côté pont Matrix, rien à faire : le salon Matrix est (re)synchronisé sur la liste des membres au
prochain appel démarré (MatrixBridgeService.ensureRoom).
Interfaces concernées — les deux, pour qu'elles ne divergent pas : MemberPickerModal (mode
promote) dans l'application autonome, et GroupMemberDialog (même mode) dans la bulle du
dashboard. La reprise d'un groupe ENT existant n'y est volontairement pas proposée : cette voie
fige la composition du salon, ce qui n'a pas de sens pour une conversation déjà en cours.
Salons de groupe : création, composition, gestion
POST /chat/rooms/group crée un salon nommé ; son créateur devient owner. Le corps accepte un
sourceGroupId optionnel — l'identifiant d'un groupe de l'ENT (classe, discipline, direction,
professeurs principaux, groupe manuel, liste de diffusion) dont le salon reprend la composition.
Un salon repris d'un groupe ENT verrouille sa composition (Room.sourceGroupId renseigné) :
addMembers et l'exclusion d'un tiers répondent chat.membership.locked. Pouvoir retirer quelqu'un
du salon « 6e A » donnerait l'impression de le retirer de la classe ; seul le départ volontaire reste
permis. Le sélecteur de groupes n'apparaît qu'aux profils autorisés à parcourir
/directory/group/admin/list (action RESOURCE d'entcore) : pour les autres, une sonde silencieuse
à l'ouverture masque simplement l'option.
| Route | Réservée à | Note |
|---|---|---|
POST /rooms/group | droit chat.create | créateur = owner |
POST /rooms/:id/members | tout membre | refusé si sourceGroupId |
DELETE /rooms/:id/members/:userId | soi-même, ou le propriétaire pour un tiers | transfert automatique de la propriété si le propriétaire part et qu'il reste des membres |
PUT /rooms/:id · DELETE /rooms/:id | le propriétaire | renommer · supprimer |
Miroir dans l'annuaire : chaque salon de groupe est doublé d'un ManualGroup de même nom
(DirectoryGroupSync, sujets directory.group.manual.* de l'event bus), ce qui le rend visible dans
la fiche d'établissement. Le miroir est best-effort : un échec est journalisé mais ne fait jamais
échouer l'opération chat — c'est une visibilité en plus, pas une dépendance.
Niveaux d'accès : chat.view ouvre l'application ; chat.create couvre création, ajout de
membres, renommage et suppression. C'est ce qui sépare « Lecture » de « Gestion » dans la gestion des
accès. Un profil en Lecture voit le bouton « + Groupe » — le refus vient du serveur, pas de l'écran.
Reprendre ou retirer un message
PUT /rooms/:roomId/messages/:messageId (modifier) et
DELETE /rooms/:roomId/messages/:messageId/everyone (retirer pour tous) sont réservés à l'auteur
du message : MessageController compare user.getUserId() à message.senderId et répond
chat.forbidden sinon. DELETE /rooms/:roomId/messages/:messageId (sans /everyone) ne masque le
message que pour l'appelant.
Aucun rôle — pas même le propriétaire du salon — ne modifie ni ne supprime le message d'autrui : le fil n'est pas réécrivable par un tiers. C'est aussi pourquoi la grille SDET reste « Sans réponse » sur la gestion du rôle de modération dans un espace d'échange : il n'existe pas de modérateur désignable ici, la modération se fait a posteriori sur signalement.
Tableau blanc : un mur collaboratif, pas une réimplémentation
L'onglet « Tableau blanc » d'une conversation encadre un mur du module collaborative-wall
(GET /collaborativewall/id/<wallId>). Le module chat ne parle jamais à ce module : c'est le
navigateur qui crée et partage le mur, avec la session et les droits de la personne. Le backend ne
mémorise que l'identifiant obtenu.
Deux contrats à connaître, tous deux éprouvés contre l'API avant d'être codés :
| Appel | Ce qu'il faut savoir |
|---|---|
POST /collaborativewall | seul name est requis, mais background doit être un objet — une chaîne vide est refusée par le schéma |
PUT /collaborativewall/share/json/:id | corps en formulaire (userId + actions répétées), une requête par personne. C'est le contrat d'entcore (ControllerHelper.shareJsonSubmit lit les attributs de formulaire) : un corps JSON est accepté avec un 200 vide et ne partage rien |
Les rôles accordés aux membres sont collaborativewall.read + collaborativewall.contrib (voir et
écrire), pas manager : le repartage et la suppression restent au créateur. La liste des actions de
chaque rôle vient de GET /collaborativewall/share/json/:id, elle n'est pas codée en dur.
Côté chat, PUT /chat/rooms/:id/wall mémorise l'identifiant. L'écriture est atomique (le filtre
Mongo exige l'absence de wallId) et l'endpoint renvoie le mur déjà en place le cas échéant : deux
personnes qui ouvrent l'onglet en même temps ne se retrouvent pas avec deux murs, dont l'un
orphelin. GET /chat/rooms/:id/members a été ajouté pour le partage.
Ajouter une méthode @SecuredAction ne suffit pas : entcore génère à la compilation les métadonnées
securedaction/SecuredAction-<Controller>.json. Tant qu'elles ne sont pas déployées avec les
classes, la route répond 404 sans trace dans les journaux. Constaté sur ces deux nouvelles routes.
Sondage : l'annonce passe par la conversation
Même parti pris que le tableau blanc, avec une différence de nature : un sondage est ponctuel, on
n'en épingle donc pas un à la conversation. Le bouton crée le sondage dans le module poll, le
partage, puis envoie un message portant /poll#/view/<id>.
| Appel | Ce qu'il faut savoir |
|---|---|
POST /poll | question, answers (au moins deux, sous la forme {value}) et end (chaîne AAAA-MM-JJ hh:mm:ss) sont tous requis |
PUT /poll/share/json/:id | même contrat qu'ailleurs : formulaire, une requête par personne |
Rôles accordés : poll.read (action retrieve) et poll.contrib (action vote) — voir et voter.
poll.manager reste au créateur.
Le message d'annonce a rendu nécessaire un rendu des liens dans le fil (MessageBody) : les corps
de message étaient affichés en texte brut, un lien y était inerte. Le découpage produit des éléments
React — aucun HTML injecté — et ne transforme que http(s)://… et les chemins internes, jamais un
javascript: ou un data:.
Si l'envoi du message échoue (temps réel coupé), le sondage existe et est partagé : l'interface le dit plutôt que de laisser croire à un échec complet.
Partage de documents : le fichier ne bouge pas
Troisième application branchée sur le même principe. Le bouton 📎 liste
GET /workspace/documents?filter=owner, partage le document choisi avec les membres, puis annonce
le lien /workspace/document/<id> dans le fil.
Rôle accordé : workspace.read uniquement — ni contrib ni manager. Partager un document
dans une conversation ne donne pas le droit de le modifier ni de le repartager.
Seuls les documents dont la personne est propriétaire sont proposés : partager suppose d'en avoir le droit, et proposer un fichier qu'on ne peut pas repartager ne mènerait qu'à un échec au moment du partage.
/workspace/document/<id> en local n'est pas un défaut de l'URLLa réponse porte quand même son Content-Disposition : la route a répondu, c'est le stockage
qui n'a pas le fichier. Le miroir local restaure les bases, pas les fichiers déposés.
Statut de présence : un second registre, indépendant de la connexion
PresenceRegistry porte deux registres en mémoire, ni l'un ni l'autre persisté (repartent à vide
au redémarrage du verticle) :
| Registre | Clé → valeur | Expire ? |
|---|---|---|
lastSeen | userId → dernier heartbeat | oui, TTL presence-ttl-seconds (défaut 30 s) — pilote isOnline |
manualStatus | userId → available/busy/away/dnd | non — le dernier choix reste jusqu'au suivant, y compris après une déconnexion |
Le second registre répond au besoin SDET UTI-CCO-MES (« L'utilisateur peut indiquer son statut, disponible/non disponible/occupé/etc. »), traduit en un jeu fermé de quatre valeurs pour rester affichable et traduisible.
Propagation :
- Action WS
{"action":"status","value":"busy"}(sansroomId, traitée avant l'extraction qui l'exige pour les autres actions) :ChatWebSocketHandler.onClientMessagevalide la valeur contrePresenceRegistry.VALID_STATUSES, met à jour le registre local, diffuse aux WS locales (broadcastPresence) et publie sur NATS (chat.presence.<userId>, champavailability) pour les autres instances. ChatRealtimeService.publishPresence(userId, online, availability)porte désormaisavailabilityen plus deonline. Un changement de statut à connexion stable (pas de transition en ligne/hors ligne) déclenche quand même une diffusion locale sur les instances distantes :onPresenceEventcompare l'ancien et le nouveau statut, pas seulementonline.- L'event
roomsinitial (à la connexion WS) portemyStatus: sans lui, un second onglet ou une reconnexion repartirait toujours suravailableplutôt que le dernier choix — le registre n'a pas de TTL, encore fallait-il le repousser au client. GET /chat/presence?ids=...(repli REST pour les pairs déjà en ligne avant l'ouverture du WS) renvoie désormais{online, availability}par utilisateur, plus le simple booléen d'avant : seul le frontend chat-nats consomme cet endpoint, pas de contrat externe à préserver.GET /chat/users/visible(annuaire) porte désormais aussiavailabilitypar utilisateur — c'est ce que consomme le widget de dialogue rapide du dashboard (open-ent/frontend/apps/dashboard,QuickChatWidget.tsx/useChatSocket.ts, implémentation séparée du frontend chat-nats, pas de code partagé entre les deux) pour peupler ses pastilles et son propre menu de statut.
Hors ligne l'emporte toujours côté affichage, sur les deux surfaces : le client (RoomList.tsx côté
app autonome, peerDotColor() côté widget) calcule la pastille comme online ? availability : 'offline', jamais l'inverse — le registre, lui, garde le dernier statut choisi même hors ligne
(c'est ce qui permet de le retrouver à la reconnexion).
L'app autonome chat-nats, le widget du dashboard et l'application mobile (open-ent/frontend/apps/ mobile, src/apps/chat/, React Native) ne partagent aucun code frontend entre elles (deux repos
open-ent-mods/open-ent) : chacune a son propre useChatSocket, son propre sélecteur de statut,
ses propres couleurs — répliquées à l'identique (#43a047/#e53935/#f9a825/#8e24aa) pour
rester cohérentes côté utilisateur. Une évolution du protocole (action:"status", champ
availability) est à répercuter dans les trois si elle doit rester utilisable partout.
Couverture de tests
Le module est couvert par les scénarios e2e Playwright suivants :
- Application autonome —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/01_acces_au_module.spec.ts: chargement de l'UI deux colonnes et envoi d'un message dans un salon. - Widget dashboard —
apps/dashboard-e2e/src/tests/02_chat_widget.spec.ts: ouverture du widget, choix d'un contact, envoi du message et apparition dans le fil. - Horaires — vue élève —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/02_horaires_lecture_seule.spec.ts: hors plage, le bandeau de fermeture s'affiche et la saisie est désactivée (lecture seule). - Horaires — administration —
apps/dashboard-e2e/src/tests/09_messaging_hours.spec.ts: le super-administrateur définit et enregistre le défaut global des horaires. - Partage de document —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/09_partage_document.spec.ts: la liste des documents est fixée par interception, le filtre par nom et l'affichage de la taille sont vérifiés. Le test ne choisit pas de document : un choix partagerait un fichier réel avec toutes les personnes de la conversation et enverrait un message. - Sondage —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/08_sondage.spec.ts: la fenêtre reflète les exigences du module (bouton inactif tant qu'il manque la question ou la deuxième réponse), une réponse peut être ajoutée, et un message contenant une URL rend un lien cliquable — l'historique est intercepté pour ne pas dépendre des données de l'instance. Le test ne lance pas le sondage : ce serait écrire un sondage, ses partages et un message. La chaîne a été exercée manuellement dans un navigateur. - Tableau blanc —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/07_tableau_blanc.spec.ts: l'onglet appartient à la conversation (donc disponible hors appel), et selon que la conversation a déjà un mur ou non, le test vérifie l'encadrement du mur ou l'invite de création. Il ne clique pas sur « Créer » : la création écrit pour de vrai (un mur, et son partage avec tous les membres). La chaîne complète a été éprouvée contre l'API, y compris le second appel qui ne doit pas écraser le mur du premier. - Statut de présence — application autonome —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/12_statut_de_presence.spec.ts: le sélecteur passe de « Disponible » à « Occupé » (la pastille change de couleur), et le choix est retrouvé après un rechargement de la page (persistance côté serveur, sans TTL sur le statut manuel) — avant d'être remis à « Disponible » pour ne pas laisser ce compte « occupé » pour les scénarios suivants. - Statut de présence — widget dashboard —
apps/dashboard-e2e/src/tests/04_chat_widget_statut.spec.ts: même vérification (menu de statut ouvert depuis la pastille du header, changement de couleur), côtéQuickChatWidget.tsx. NécessiteBASE_URL=http://localhost:8090en local (accès direct à:3001casse la présence temps réel, cf. la note ci-dessous surgetEntNavigationBase) — même remise à « Disponible » en fin de test. - Élargir une conversation —
apps/open-ent-e2e/src/modules/40_messagerie_instantanee/06_ajouter_une_personne_a_un_dm.spec.ts: le bouton n'apparaît que sur une conversation à deux, la fenêtre propose un nom, et la requête envoyée porte bien les identifiants et le nom du futur groupe. L'appel est intercepté : la conversion étant irréversible côté serveur, elle n'est pas jouée sur une conversation réelle. La conversion elle-même (type, titre, propriétaire, et le fait qu'un nouveau tête-à-tête ne retombe pas sur le salon promu) a été vérifiée en local contre l'API, sur une conversation créée puis supprimée pour l'occasion.