Skip to main content

Messagerie instantanée — détails techniques

Fiche fonctionnelle

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é :

InvariantSans lui
type passe de dm à groupun « 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=ownerles 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.

RouteRéservée àNote
POST /rooms/groupdroit chat.createcréateur = owner
POST /rooms/:id/memberstout membrerefusé si sourceGroupId
DELETE /rooms/:id/members/:userIdsoi-même, ou le propriétaire pour un tierstransfert automatique de la propriété si le propriétaire part et qu'il reste des membres
PUT /rooms/:id · DELETE /rooms/:idle propriétairerenommer · 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 :

AppelCe qu'il faut savoir
POST /collaborativewallseul name est requis, mais background doit être un objet — une chaîne vide est refusée par le schéma
PUT /collaborativewall/share/json/:idcorps 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.

Déploiement d'une nouvelle route sécurisée

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>.

AppelCe qu'il faut savoir
POST /pollquestion, 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/:idmê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.

Un 404 sur /workspace/document/<id> en local n'est pas un défaut de l'URL

La 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) :

RegistreClé → valeurExpire ?
lastSeenuserId → dernier heartbeatoui, TTL presence-ttl-seconds (défaut 30 s) — pilote isOnline
manualStatususerId → available/busy/away/dndnon — 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"} (sans roomId, traitée avant l'extraction qui l'exige pour les autres actions) : ChatWebSocketHandler.onClientMessage valide la valeur contre PresenceRegistry.VALID_STATUSES, met à jour le registre local, diffuse aux WS locales (broadcastPresence) et publie sur NATS (chat.presence.<userId>, champ availability) pour les autres instances.
  • ChatRealtimeService.publishPresence(userId, online, availability) porte désormais availability en plus de online. 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 : onPresenceEvent compare l'ancien et le nouveau statut, pas seulement online.
  • L'event rooms initial (à la connexion WS) porte myStatus : sans lui, un second onglet ou une reconnexion repartirait toujours sur available plutô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 aussi availability par 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).

Trois implémentations frontend, un seul protocole WS

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 autonomeapps/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 dashboardapps/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èveapps/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 — administrationapps/dashboard-e2e/src/tests/09_messaging_hours.spec.ts : le super-administrateur définit et enregistre le défaut global des horaires.
  • Partage de documentapps/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.
  • Sondageapps/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 blancapps/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 autonomeapps/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 dashboardapps/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écessite BASE_URL=http://localhost:8090 en local (accès direct à :3001 casse la présence temps réel, cf. la note ci-dessous sur getEntNavigationBase) — même remise à « Disponible » en fin de test.
  • Élargir une conversationapps/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.