# Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.contrib.auth.models import User from django.db.models import Count, F, Q from registration.models import Registration from .models import Channel, Message class ChatConsumer(AsyncJsonWebsocketConsumer): """ Ce consommateur gère les connexions WebSocket pour le chat. """ async def connect(self) -> None: """ Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur. On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e. """ if '_fake_user_id' in self.scope['session']: # Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id']) # Récupération de l'utilisateur⋅rice courant⋅e user = self.scope['user'] if user.is_anonymous: # L'utilisateur⋅rice n'est pas connecté⋅e await self.close() return reg = await Registration.objects.aget(user_id=user.id) self.registration = reg # Acceptation de la connexion await self.accept() # Récupération des canaux accessibles en lecture et/ou en écriture self.read_channels = await Channel.get_accessible_channels(user, 'read') self.write_channels = await Channel.get_accessible_channels(user, 'write') # Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat async for channel in self.read_channels.all(): await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) # Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne await self.channel_layer.group_add(f"user-{user.id}", self.channel_name) async def disconnect(self, close_code: int) -> None: """ Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur. :param close_code: Le code d'erreur. """ if self.scope['user'].is_anonymous: # L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien return async for channel in self.read_channels.all(): # Désabonnement des canaux de diffusion Websocket liés aux canaux de chat await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) # Désabonnement du canal de diffusion Websocket personnel await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name) async def receive_json(self, content: dict, **kwargs) -> None: """ Appelée lorsque le client nous envoie des données, décodées depuis du JSON. :param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'. """ match content['type']: case 'fetch_channels': # Demande de récupération des canaux disponibles await self.fetch_channels() case 'send_message': # Envoi d'un message dans un canal await self.receive_message(**content) case 'edit_message': # Modification d'un message await self.edit_message(**content) case 'delete_message': # Suppression d'un message await self.delete_message(**content) case 'fetch_messages': # Récupération des messages d'un canal (ou d'une partie) await self.fetch_messages(**content) case 'mark_read': # Marquage de messages comme lus await self.mark_read(**content) case 'start_private_chat': # Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice await self.start_private_chat(**content) case unknown: # Type inconnu, on soulève une erreur raise ValueError(f"Unknown message type: {unknown}") async def fetch_channels(self) -> None: """ L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles. On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture, en fournissant nom, catégorie, permission de lecture et nombre de messages non lus. """ user = self.scope['user'] # Récupération des canaux accessibles en lecture, avec le nombre de messages non lus channels = self.read_channels.prefetch_related('invited') \ .annotate(total_messages=Count('messages', distinct=True)) \ .annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \ .annotate(unread_messages=F('total_messages') - F('read_messages')).all() # Envoi de la liste des canaux message = { 'type': 'fetch_channels', 'channels': [ { 'id': channel.id, 'name': channel.get_visible_name(user), 'category': channel.category, 'read_access': True, 'write_access': await self.write_channels.acontains(channel), 'unread_messages': channel.unread_messages, } async for channel in channels ] } await self.send_json(message) async def receive_message(self, channel_id: int, content: str, **kwargs) -> None: """ L'utilisateur⋅ice a envoyé un message dans un canal. On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les utilisateur⋅ices abonné⋅es au canal. :param channel_id: Identifiant du canal où envoyer le message. :param content: Contenu du message. """ user = self.scope['user'] # Récupération du canal channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ .aget(id=channel_id) if not await self.write_channels.acontains(channel): # L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne return # Création du message message = await Message.objects.acreate( author=user, channel=channel, content=content, ) # Envoi du message à toutes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{channel.id}', { 'type': 'chat.send_message', 'id': message.id, 'channel_id': channel.id, 'timestamp': message.created_at.isoformat(), 'author_id': message.author_id, 'author': await message.aget_author_name(), 'content': message.content, }) async def edit_message(self, message_id: int, content: str, **kwargs) -> None: """ L'utilisateur⋅ice a modifié un message. On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal. :param message_id: Identifiant du message à modifier. :param content: Nouveau contenu du message. """ user = self.scope['user'] # Récupération du message message = await Message.objects.aget(id=message_id) if user.id != message.author_id and not user.is_superuser: # Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message return # Modification du contenu du message message.content = content await message.asave() # Envoi de la modification à tou⋅tes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{message.channel_id}', { 'type': 'chat.edit_message', 'id': message_id, 'channel_id': message.channel_id, 'content': content, }) async def delete_message(self, message_id: int, **kwargs) -> None: """ L'utilisateur⋅ice a supprimé un message. On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal. :param message_id: Identifiant du message à supprimer. """ user = self.scope['user'] # Récupération du message message = await Message.objects.aget(id=message_id) if user.id != message.author_id and not user.is_superuser: return # Suppression effective du message await message.adelete() # Envoi de la suppression à tou⋅tes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{message.channel_id}', { 'type': 'chat.delete_message', 'id': message_id, 'channel_id': message.channel_id, }) async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: """ L'utilisateur⋅ice demande à récupérer les messages d'un canal. On vérifie la permission de lecture, puis on renvoie les messages demandés. :param channel_id: Identifiant du canal où récupérer les messages. :param offset: Décalage pour la pagination, à partir du dernier message. Par défaut : 0, on commence au dernier message. :param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages. """ # Récupération du canal channel = await Channel.objects.aget(id=channel_id) if not await self.read_channels.acontains(channel): # L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne return limit = min(limit, 200) # On limite le nombre de messages à 200 maximum # Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e messages = Message.objects \ .filter(channel=channel) \ .annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \ .order_by('-created_at')[offset:offset + limit].all() # Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique await self.send_json({ 'type': 'fetch_messages', 'channel_id': channel_id, 'messages': list(reversed([ { 'id': message.id, 'timestamp': message.created_at.isoformat(), 'author_id': message.author_id, 'author': await message.aget_author_name(), 'content': message.content, 'read': message.read > 0, } async for message in messages ])) }) async def mark_read(self, message_ids: list[int], **_kwargs) -> None: """ L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran. :param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus. """ # Récupération des messages à marquer comme lus messages = Message.objects.filter(id__in=message_ids) async for message in messages.all(): # Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message await message.users_read.aadd(self.scope['user']) # Actualisation du nombre de messages non lus par canal unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \ .annotate(unread_messages=Count('channel_id')) # Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés await self.send_json({ 'type': 'mark_read', 'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()], 'unread_messages': {group['channel_id']: group['unread_messages'] async for group in unread_messages_by_channel.all()}, }) async def start_private_chat(self, user_id: int, **kwargs) -> None: """ L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice. Pour cela, on récupère le salon privé s'il existe, sinon on en crée un. Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal. :param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée. """ user = self.scope['user'] # Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation other_user = await User.objects.aget(id=user_id) # Vérification de l'existence d'un salon privé entre les deux personnes channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user) if not await channel_qs.aexists(): # Le salon privé n'existe pas, on le crée alors channel = await Channel.objects.acreate( name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}", category=Channel.ChannelCategory.PRIVATE, private=True, ) await channel.invited.aset([user, other_user]) # On s'ajoute au salon privé await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) if user != other_user: # On transfère l'autre utilisateur⋅ice dans le salon privé await self.channel_layer.group_send(f"user-{other_user.id}", { 'type': 'chat.start_private_chat', 'channel': { 'id': channel.id, 'name': f"{user.first_name} {user.last_name}", 'category': channel.category, 'read_access': True, 'write_access': True, } }) else: # Récupération dudit salon privé channel = await channel_qs.afirst() # Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé await self.channel_layer.group_send(f"user-{user.id}", { 'type': 'chat.start_private_chat', 'channel': { 'id': channel.id, 'name': f"{other_user.first_name} {other_user.last_name}", 'category': channel.category, 'read_access': True, 'write_access': True, } }) async def chat_send_message(self, message) -> None: """ Envoi d'un message à tou⋅tes les personnes connectées sur un canal. :param message: Dictionnaire contenant les informations du message à envoyer, contenant l'identifiant du message "id", l'identifiant du canal "channel_id", l'heure de création "timestamp", l'identifiant de l'auteur "author_id", le nom de l'auteur "author" et le contenu du message "content". """ await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], 'timestamp': message['timestamp'], 'author': message['author'], 'content': message['content']}) async def chat_edit_message(self, message) -> None: """ Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal. :param message: Dictionnaire contenant les informations du message à modifier, contenant l'identifiant du message "id", l'identifiant du canal "channel_id" et le nouveau contenu "content". """ await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'], 'content': message['content']}) async def chat_delete_message(self, message) -> None: """ Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal. :param message: Dictionnaire contenant les informations du message à supprimer, contenant l'identifiant du message "id" et l'identifiant du canal "channel_id". """ await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']}) async def chat_start_private_chat(self, message) -> None: """ Envoi d'un message pour démarrer une conversation privée à une personne connectée. :param message: Dictionnaire contenant les informations du nouveau canal privé. """ await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name) await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})