Code source de lgrez.blocs.tools

"""lg-rez / blocs / Outils divers et variés

Récupération d'objets Discord, décorateurs pour commandes, structures d'interaction dans les channels, utilitaires d'emojis, de date/temps, de formatage...

"""

from functools import wraps
import asyncio
import datetime
import unidecode
import re

import discord
import discord.utils
from discord.ext import commands

from lgrez.blocs import bdd, bdd_tools
from lgrez.blocs.bdd import Tables, Joueurs, Roles, BaseActions, Actions, BaseActionsRoles, Taches, Triggers, Reactions, CandidHaro
# on importe toutes les tables, plus simple pour y accéder depuis des réactions etc (via eval_accols)


### ---------------------------------------------------------------------------
### Utilitaires de récupération d'objets Discord (détectent les mentions)
### ---------------------------------------------------------------------------

#: Raccourci pour :func:`discord.utils.get`
get = discord.utils.get


def _find_by_mention_or_name(collec, val, pattern=None, must_be_found=False, raiser=None):
    """Utilitaire pour la suite : trouve <val> dans <collec>

    [pattern]           Motif RegEx à utiliser pour la recherche
    [must_be_found]     Si True, raise une AssertionError si <val> est introuvable
    [raiser]            Nom de la fonction à envoyer dans l'exception si introuvable
    """
    if not val:
        item = None
    elif pattern and (match := re.search(pattern, val)):
        item = get(collec, id=int(match.group(1)))
    else:
        item = get(collec, name=val)

    if must_be_found:
        assert item, f"{raiser or 'tools._find_by_mention_or_name'} : Élément {val} introuvable"

    return item


[docs]def channel(arg, nom, must_be_found=True): """Renvoie l'objet associé au salon ``#nom``. Args: nom (:class:`str`): nom du channel (texte/vocal/catégorie) ou sa mention (détection directe par regex) arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux channels must_be_found (:class:`bool`): si ``True`` (défaut), raise une :exc:`AssertionError` si le channel ``#nom`` n'existe pas (si ``False``, renvoie ``None``) Returns: :class:`discord.abc.GuildChannel` """ try: channels = arg.channels if isinstance(arg, discord.Guild) else arg.guild.channels except AttributeError: raise TypeError("tools.channel : Impossible de remonter aux channels depuis l'argument trasmis") return _find_by_mention_or_name(channels, nom, pattern="<#([0-9]{18})>", must_be_found=must_be_found, raiser="tools.channel")
[docs]def role(arg, nom, must_be_found=True): """Renvoie l'objet associé au rôle ``@&nom``. Args: nom (:class:`str`): nom du rôle ou sa mention (détection directe par regex) arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux rôles must_be_found (:class:`bool`): si ``True`` (défaut), raise une :exc:`AssertionError` si le channel ``@&nom`` n'existe pas (si ``False``, renvoie ``None``) Returns: :class:`discord.Role` """ try: roles = arg.roles if isinstance(arg, discord.Guild) else arg.guild.roles except AttributeError: raise TypeError("tools.role : Impossible de remonter aux rôles depuis l'argument trasmis") return _find_by_mention_or_name(roles, nom, pattern="<@&([0-9]{18})>", must_be_found=must_be_found, raiser="tools.role")
[docs]def member(arg, nom, must_be_found=True): """Renvoie l'objet associé au membre ``@nom``. Args: nom (:class:`str`): nom du joueur ou sa mention (détection directe par regex) arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux membres must_be_found (:class:`bool`): si ``True`` (défaut), raise une :exc:`AssertionError` si le membre ``@nom`` n'existe pas (si ``False``, renvoie ``None``) Returns: :class:`discord.Member` """ try: members = arg.members if isinstance(arg, discord.Guild) else arg.guild.members except AttributeError: raise TypeError("tools.member : Impossible de remonter aux membres depuis l'argument trasmis") return _find_by_mention_or_name(members, nom, pattern="<@!([0-9]{18})>", must_be_found=must_be_found, raiser="tools.member")
[docs]def emoji(arg, nom, must_be_found=True): """Renvoie l'objet associé à l'emoji ``:nom:``. Args: nom (:class:`str`): nom de l'emoji (texte/vocal/catégorie) ou son utilisation (détection directe par regex) arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux emojis must_be_found (:class:`bool`): si ``True`` (défaut), raise une :exc:`AssertionError` si l'emoji ``:nom:`` n'existe pas (si ``False``, renvoie ``None``) Returns: :class:`discord.Emoji` """ try: emojis = arg.emojis if isinstance(arg, discord.Guild) else arg.guild.emojis except AttributeError: raise TypeError("tools.emoji : Impossible de remonter aux emojis depuis l'argument trasmis") return _find_by_mention_or_name(emojis, nom, pattern="<:.*:([0-9]{18})>", must_be_found=must_be_found, raiser="tools.emoji")
[docs]def private_chan(member, must_be_found=True): """Renvie le channel privé d'un joueur Args: member (:class:`discord.Member`): membre du serveur must_be_found (:class:`bool`): si ``True`` (défaut), raise une :exc:`AssertionError` si le channel n'existe pas (si ``False``, renvoie ``None``) Returns: :class:`discord.TextChannel` """ joueur = Joueurs.query.get(member.id) assert joueur, f"tools.private_chan : Joueur {member} introuvable" chan = member.guild.get_channel(joueur.chan_id_) if must_be_found: assert chan, f"tools.private_chan : Chan privé de {joueur} introuvable" return chan
# Appel aux MJs
[docs]def mention_MJ(arg): """Renvoie la mention du rôle "MJ" si le joueur n'est pas un MJ, ``"@MJ"`` sinon. Args: arg (:class:`~discord.Member` | :class:`~discord.ext.commands.Context`): membre ou contexte d'un message envoyé par un membre """ member = arg.author if hasattr(arg, "author") else arg if hasattr(member, "top_role") and member.top_role.name == "MJ": # Si webhook, pas de top_role return "@MJ" else: return role(arg, "MJ").mention
### --------------------------------------------------------------------------- ### Exceptions ### ---------------------------------------------------------------------------
[docs]class CommandExit(RuntimeError): """Lever cette exception force l'arrêt immédiat d'une commande, et empêche le bot de réagir à nouveau Sous-classe de :exc:`RuntimeError` """ pass
### --------------------------------------------------------------------------- ### Décorateurs pour les différentes commandes, en fonction de leur usage ### --------------------------------------------------------------------------- #: décorateur pour commande (:func:`discord.ext.commands.check`) : commandes exécutables uniquement par un MJ ou un webhook mjs_only = commands.check_any(commands.check(lambda ctx: ctx.message.webhook_id), commands.has_role("MJ")) #: décorateur pour commande (:func:`discord.ext.commands.check`) : commandes exécutables par un MJ, un rédacteur ou un webhook (pour IA) mjs_et_redacteurs = commands.check_any(commands.check(lambda ctx: ctx.message.webhook_id), commands.has_any_role("MJ", "Rédacteur")) #: décorateur pour commande (:func:`discord.ext.commands.check`) : commandes exécutables uniquement par un joueur (inscrit en base), vivant ou mort joueurs_only = commands.has_any_role("Joueur en vie", "Joueur mort") #: décorateur pour commande (:func:`discord.ext.commands.check`) : commandes exécutables uniquement par un joueur vivant vivants_only = commands.has_role("Joueur en vie") # @tools.private
[docs]def private(cmd): """Décorateur pour commande : lors d'une invocation de la commande décorée hors d'un channel privé (``#conv-bot-``), supprime le message d'invocation et exécute la commande dans le channel privée de l'invoqueur. Ce décorateur n'est utilisable que sur une commande définie dans un Cog. Si le joueur ayant utilisé la commande n'a pas de chan privé (pas en base), raise une :exc:`AssertionError`. Utilisable en combinaison avec :func:`.joueurs_only` et :func:`.vivants_only` (pas avec les autres attention, vu que seuls les joueurs ont un channel privé) """ @wraps(cmd) async def new_cmd(self, ctx, *args, **kwargs): # Cette commande est renvoyée à la place de cmd if not ctx.channel.name.startswith("conv-bot-"): # Si pas déjà dans une conv bot : await ctx.message.delete() # On supprime le message, ctx.channel = private_chan(ctx.author) # On remplace le chan dans le contexte d'appel par le chan privé, await ctx.send(f"{quote(ctx.message.content)}\n" # On envoie un warning dans le chan privé, f"{ctx.author.mention} :warning: Cette commande est interdite en dehors de ta conv privée ! :warning:\n" f"J'ai supprimé ton message, et j'exécute la commande ici :") return await cmd(self, ctx, *args, **kwargs) # Et on appelle cmd, avec le contexte modifié ! return new_cmd
### --------------------------------------------------------------------------- ### Commandes d'interaction avec les joueurs : input, boucles, confirmation... ### --------------------------------------------------------------------------- # Commande générale, à utiliser à la place de bot.wait_for('message', ...)
[docs]async def wait_for_message(bot, check, trigger_on_commands=False): """Attend et renvoie le premier message reçu rencontrant les critères demandés. Surcouche de :meth:`discord.ext.commands.Bot.wait_for` permettant d'ignorer les commandes et de réagir au mot-clé ``stop``. Args: bot (:class:`.LGBot`): bot connecté au serveur check (:class:`function`\(:class:`discord.Message` -> :class:`bool`\)): fonction validant ou non chaque message trigger_on_commands (:class:`bool`): si ``False`` (défaut), un message respectant ``check`` sera ignoré si c'est une commande. Returns: :class:`discord.Message` Si le message est ``"stop"`` ou ``"!stop"`` (ou autre casse), raise une exception :exc:`.CommandExit` (même si le message respecte ``check``). """ if trigger_on_commands: def trig_check(m): return (check(m) or m.content.lower() in ["stop", "!stop"]) # et on trigger en cas de STOP else: def trig_check(m): return ((check(m) and not m.content.startswith(bot.command_prefix)) # on ne trigger pas sur les commandes or m.content.lower() in ["stop", "!stop"]) # et on trigger en cas de STOP message = await bot.wait_for('message', check=trig_check) if message.content.lower() in ["stop", "!stop"]: if message.author == bot.user: raise CommandExit(ital("(Arrêt commande précédente)")) else: raise CommandExit("Arrêt demandé") else: return message
# Raccourci pratique
[docs]async def wait_for_message_here(ctx, trigger_on_commands=False): """Attend et renvoie le premier message reçu dans <ctx>. Surcouche de :func:`.wait_for_message` filtrant uniquement les messages envoyés dans ``ctx.channel`` par quelqu'un d'autre que le bot Args: ctx (:class:`discord.ext.commands.Context`): contexte d'une commande trigger_on_commands: passé directement à :func:`.wait_for_message` Returns: :class:`discord.Message` """ def trig_check(message): return (message.channel == ctx.channel and message.author != ctx.bot.user) message = await wait_for_message(ctx.bot, check=trig_check, trigger_on_commands=trigger_on_commands) return message
# Permet de boucler question -> réponse tant que la réponse vérifie pas les critères nécessaires dans chan
[docs]async def boucle_message(bot, chan, in_message, condition_sortie, rep_message=None): """Permet de lancer une boucle question/réponse tant que la réponse ne vérifie pas une condition Args: bot (:class:`.LGBot`): bot connecté au serveur chan (:class:`discord.TextChannel`): channel dans lequel lancer la boucle condition_sortie (:class:`function`\(:class:`discord.Message` -> :class:`bool`\)): fonction validant ou non chaque message in_message (:class:`str`): si défini, message à envoyer avant la boucle rep_message (:class:`str`): si défini, permet de définir un message de boucle différent de ``in_message`` (identique si ``None``). Doit être défini si ``in_message`` n'est pas défini. Returns: :class:`discord.Message` """ if not rep_message: rep_message = in_message if not rep_message: raise ValueError("tools.boucle_message : [in_message] ou [rep_message] doit être défini !") def check_chan(m): #C heck que le message soit envoyé par l'utilisateur et dans son channel perso return m.channel == chan and m.author != bot.user await chan.send(in_message) rep = await wait_for_message(bot, check_chan) while not condition_sortie(rep): await chan.send(rep_message) rep = await wait_for_message(bot, check_chan) return rep
[docs]async def boucle_query_joueur(ctx, cible=None, message=None, sensi=0.5): """Retourne un joueur (entrée de BDD) d'après son nom Args: ctx (:class:`discord.ext.commands.Context`): contexte d'une commande cible (:class:`str`): premier essai de cible (donnée par le joueur dans l'appel à une commande, par exemple) message (:class:`str`): si défini (et ``cible`` non définie), message à envoyer avant la boucle sensi (:class:`float`): sensibilité de la recherche (c.f. :func:`.bdd_tools.find_nearest`) Returns: :class:`.bdd.Joueurs` Attend que le joueur entre un nom de joueur, et boucle 5 fois au max (avant de l'insulter et de raise une erreur) pour chercher le plus proche joueur dans la table :class:`.bdd.Joueurs`. """ if message and not cible: await ctx.send(message) for i in range(5): if i == 0 and cible: # Au premier tour, si on a donné une cible rep = cible else: mess = await wait_for_message_here(ctx) rep = mess.content.strip("()[]{}<>") if id := ''.join([c for c in rep if c.isdigit()]): # Si la chaîne contient un nombre, on l'extrait if joueur := Joueurs.query.get(int(id)): # Si cet ID correspond à un utilisateur, on le récupère return joueur # On a trouvé l'utilisateur ! nearest = bdd_tools.find_nearest(rep, Joueurs, carac="nom", sensi=sensi, solo_si_parfait=False, match_first_word=True) # Sinon, recherche au plus proche if not nearest: await ctx.send("Aucune entrée trouvée, merci de réessayer :") elif nearest[0][1] == 1 and not (len(nearest) > 1 and nearest[1][1] == 1): # Si le score le plus haut est égal à 1... return nearest[0][0] # ...renvoyer l'entrée correspondante elif len(nearest) == 1: m = await ctx.send(f"Je n'ai trouvé qu'une correspondance : {nearest[0][0].nom}\nÇa part ?") if await yes_no(ctx.bot, m): return nearest[0][0] else: await ctx.send("Bon d'accord, alors qui ?") else: s = "Les joueurs les plus proches de ton entrée sont les suivants : \n" for i, j in enumerate(nearest[:10]): s += f"{emoji_chiffre(i+1)}. {j[0].nom} \n" m = await ctx.send(s + ital("Tu peux les choisir en réagissant à ce message, ou en répondant au clavier.")) n = await choice(ctx.bot, m, min(10, len(nearest))) return nearest[n-1][0] await ctx.send("Et puis non, tiens ! \n https://giphy.com/gifs/fuck-you-middle-finger-ryan-stiles-x1kS7NRIcIigU") raise RuntimeError("Le joueur est trop con, je peux rien faire")
# Récupère un input par réaction
[docs]async def wait_for_react_clic(bot, message, emojis={}, *, process_text=False, text_filter=lambda s: True, post_converter=None, trigger_all_reacts=False, trigger_on_commands=False): """Ajoute des reacts à un message et attend que quelqu'un appuie sur une Args: bot (:class:`.LGBot`): bot connecté au serveur message (:class:`discord.Message`): message où ajouter les réactions emojis (:class:`list` | :class:`dict`): reacts à ajouter, éventuellement associés à une valeur qui sera retournée au clic sur l'emoji process_text (:class:`bool`): si ``True``, détecte aussi la réponse par message et retourne ledit message (défaut : ``False``) text_filter (:class:`function`\(:class:`str` -> :class:`bool`\)): si ``process_text``, ne réagit qu'aux messages pour lesquels ``text_filter(message)`` renvoie ``True`` (défaut : tous) post_converter (:class:`function`\(:class:`str` -> :class:`object`\)): si ``process_text`` et que l'argument est défini, le message détecté est passé dans cette fonction avant d'être renvoyé trigger_all_reacts (:class:`bool`): si ``True``, détecte l'ajout des toutes les réactions (et pas seulement celles dans ``emojis``) et renvoie l'emoji directement si il n'est pas dans ``emojis`` (défaut : ``False``) trigger_on_commands (:class:`bool`): passé directement à :func:`.wait_for_message`. Returns: :class:`str`, représentant - le nom de l'emoji si ``emojis`` est une liste et clic sur une des reacts, ou si ``trigger_all_reacts`` vaut ``True`` et ajout d'une autre react ; - le message reçu si ``process_text`` vaut ``True``, que ``post_converter`` n'est pas défini et réaction à un message OU :class:`object`, représentant - la valeur associée si ``emojis`` est un dictionnaire et clic sur une des reacts ; - la valeur retournée par ``post_converter`` si il est défini, que ``process_text`` vaut ``True`` et réaction à un message """ if not isinstance(emojis, dict): # Si emoji est une liste, on en fait un dictionnaire emojis = {emoji: emoji for emoji in emojis} try: # Si une erreur dans ce bloc, on supprime les emojis du bot (sinon c'est moche) for emoji in emojis: # On ajoute les emojis await message.add_reaction(emoji) emojis_names = {emoji.name if hasattr(emoji, "name") else emoji: emoji for emoji in emojis} def react_check(react): # Check REACT : bon message, pas un autre emoji, et pas react du bot return (react.message_id == message.id and react.user_id != bot.user.id and (trigger_all_reacts or react.emoji.name in emojis_names)) react_task = asyncio.create_task(bot.wait_for('raw_reaction_add', check=react_check), name="react") if process_text: def message_check(mess): # Check MESSAGE : bon channel, pas du bot, et filtre return (mess.channel == message.channel and mess.author != bot.user and text_filter(mess.content)) else: def message_check(mess): # On process DANS TOUS LES CAS, mais juste pour détecter "stop" si process_text == False return False mess_task = asyncio.create_task(wait_for_message(bot, check=message_check, trigger_on_commands=True), name="mess") done, pending = await asyncio.wait([react_task, mess_task], return_when=asyncio.FIRST_COMPLETED) # On lance # Le bot attend ici qu'une des deux tâches aboutissent done_task = next(iter(done)) # done = tâche réussie if done_task.get_name() == "react": emoji = done_task.result().emoji if trigger_all_reacts and emoji.name not in emojis_names: ret = emoji else: ret = emojis.get(emoji) or emojis.get(emojis_names.get(emoji.name)) # Si clic sur react, done.result = react for emoji in emojis: await message.remove_reaction(emoji, bot.user) # On finit par supprimer les emojis mis par le bot else: # Réponse par message / STOP mess = done_task.result().content # Si envoi de message, done.result = message ret = post_converter(mess) if post_converter else mess await message.clear_reactions() except Exception as exc: await message.clear_reactions() raise exc from Exception return ret
# Surcouche de wait_for_react_clic pour demander une confirmation / question fermée simplement
[docs]async def yes_no(bot, message): """Ajoute les reacts ✅ et ❎ à message et renvoie ``True`` ou ``False`` en fonction de l'emoji cliqué OU de la réponse textuelle détectée Surcouche de :func:`wait_for_react_clic` pour demander une confirmation / question fermée simplement. Args: bot (:class:`.LGBot`): bot connecté au serveur message (:class:`discord.Message`): message où ajouter les réactions Réponses textuelles reconnues : - Pour ``True`` : ``["oui", "o", "yes", "y", "1", "true"]`` - Pour ``False`` : ``["non", "n", "no", "n", "0", "false"]`` ainsi que toutes leurs variations de casse. Returns: :class:`bool` """ yes_words = ["oui", "o", "yes", "y", "1", "true"] yes_no_words = yes_words + ["non", "n", "no", "n", "0", "false"] return await wait_for_react_clic( bot, message, emojis={"✅": True, "❎": False}, process_text=True, text_filter=lambda s: s.lower() in yes_no_words, post_converter=lambda s: s.lower() in yes_words)
# Surcouche de wait_for_react_clic pour demander de choisir dans une liste simplement
[docs]async def choice(bot, message, N, start=1, *, additionnal={}): """Ajoute des reacts chiffres (1️⃣, 2️⃣, 3️⃣...) à message et renvoie le numéro cliqué OU détecté par réponse textuelle Surcouche de :func:`wait_for_react_clic` pour demander de choisir dans une liste simplement. Args: bot (:class:`.LGBot`): bot connecté au serveur message (:class:`discord.Message`): message où ajouter les réactions N (:class:`int`): chiffre jusqu'auquel aller, inclus (``<= 10``) start (:class:`int`): chiffre auquel commencer (``<= N``, défaut ``1``) additionnal (:class:`dict`): dictionnaire emoji à ajouter après les chiffres -> valeur renvoyée si cliqué Réponses textuelles reconnues : nombres seuls entre ``start`` et ``N`` Returns: :class:`int` """ emojis = {emoji_chiffre(i): i for i in range(start, N+1)} if additionnal: emojis.update(additionnal) return await wait_for_react_clic( bot, message, emojis=emojis, process_text=True, text_filter=lambda s: s.isdigit() and start <= int(s) <= N, post_converter=int)
[docs]async def sleep(chan, x): """Pause l'exécution d'une commande en affichant l'indicateur *typing* ("*LGBot est en train d'écrire...*") sur un salon Permat d'afficher plusieurs messages d'affillée en laissant le temps de lire, tout en indiquant que le bot n'a pas fini d'écrire Args: chan (:class:`discord.abc.Messageable`): salon / contexte /... sur lequel attendre x (:class:`float`): temps à attendre, en secondes """ async with chan.typing(): await asyncio.sleep(x)
### --------------------------------------------------------------------------- ### Utilitaires d'emojis ### ---------------------------------------------------------------------------
[docs]def emoji_camp(arg, camp): """Renvoie l'emoji associé à un camp donné. Args: arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux emojis camp (str): parmis ``"village"``, ``"loups"``, ``"nécro"``, ``"solitaire"``, ``"autre"`` Returns: :class:`discord.Emoji` or `""` """ d = {"village": "village", "loups": "lune", "nécro": "necro", "solitaire": "pion", "autre": "pion"} if camp in d: return emoji(arg, d[camp]) else: return ""
[docs]def montre(heure=None): """Renvoie l'emoji horloge (🕧, 🕓, 🕝...) le plus proche d'une heure donnée Args: heure (:class:`str`): heure à représenter au format ``"XXh"`` ou ``"XXhMM"`` (défaut : heure actuelle) Returns: :class:`str` """ if heure and isinstance(heure, str): heure, minute = heure.split("h") heure = int(heure) % 12 minute = int(minute) % 60 if minute else 0 else: now = datetime.datetime.now() heure = now.hour % 12 minute = now.minute if 15 < minute < 45: # Demi heure L = ["🕧", "🕜", "🕝", "🕞", "🕟", "🕠", "🕡", "🕢", "🕣", "🕤", "🕥", "🕦"] else: # Heure pile L = ["🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"] return L[heure] if minute < 45 else L[(heure + 1) % 12]
[docs]def emoji_chiffre(chiffre, multi=False): """Renvoie l'emoji / les emojis 0️⃣, 1️⃣, 2️⃣... correspond à un chiffre/nombre Args: chiffre (:class:`int`): chiffre/nombre à représenter multi (:class:`bool`): si ``True``, ``chiffre`` peut être n'importe quel entier positif, dont les chiffres seront convertis séparément ; sinon (par défaut), ``chiffre`` doit être un entier entre ``0`` et ``10``. Returns: :class:`str` """ if isinstance(chiffre, int) and 0 <= chiffre <= 10: return ["0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"][chiffre] elif multi and str(chiffre).isdigit(): return ''.join([emoji_chiffre(int(c)) for c in str(chiffre)]) else: raise ValueError("L'argument de tools.emoji_chiffre doit être un entier entre 0 et 10 OU un entier positif avec multi=True")
[docs]def super_chiffre(chiffre, multi=False): """Renvoie le(s) caractère(s) Unicode ⁰, ¹, ²... correspond à un chiffre/nombre Args: chiffre (:class:`int`): chiffre/nombre à représenter multi (:class:`bool`): si ``True``, ``chiffre`` peut être n'importe quel entier positif, dont les chiffres seront convertis séparément ; sinon (par défaut), ``chiffre`` doit être un entier entre ``0`` et ``9``. Returns: :class:`str` """ if isinstance(chiffre, int) and 0 <= chiffre <= 9: return ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"][chiffre] elif multi and str(chiffre).isdigit(): return ''.join([super_chiffre(int(c)) for c in str(chiffre)]) else: raise ValueError("L'argument de tools.super_chiffre doit être un entier entre 0 et 9 OU un entier positif avec multi=True")
[docs]def sub_chiffre(chiffre, multi=False): """Renvoie le(s) caractère(s) unicode ₀, ₁, ₂... correspond à un chiffre/nombre Args: chiffre (:class:`int`): chiffre/nombre à représenter multi (:class:`bool`): si ``True``, ``chiffre`` peut être n'importe quel entier positif, dont les chiffres seront convertis séparément ; sinon (par défaut), ``chiffre`` doit être un entier entre ``0`` et ``9``. Returns: :class:`str` """ if isinstance(chiffre, int) and 0 <= chiffre <= 9: return ["₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉"][chiffre] elif multi and str(chiffre).isdigit(): return ''.join([sub_chiffre(int(c)) for c in str(chiffre)]) else: raise ValueError("L'argument de tools.sub_chiffre doit être un entier entre 0 et 9 OU un entier positif avec multi=True")
### --------------------------------------------------------------------------- ### Utilitaires de date / temps, notemment liées aux horaires de jeu ### ---------------------------------------------------------------------------
[docs]def heure_to_time(heure): """Convertit l'écriture d'une heure en objet :class:`datetime.time` Args: heure (:class:`str`): heure au format ``HHh``, ``HHhMM`` ou ``HH:MM`` Returns: :class:`datetime.time` """ try: if "h" in heure: hh, mm = heure.split("h") else: hh, mm = heure.split(":") return datetime.time(int(hh), int(mm) if mm else 0) except ValueError as exc: raise ValueError(f"Valeur \"{heure}\" non convertible en temps") from exc
[docs]def time_to_heure(tps, sep="h", force_minutes=False): """Convertit un objet :class:`datetime.time` en heure (version maison de :meth:`datetime.time.strftime`) Args: tps (:class:`datetime.time`): temps à convertir sep (:class:`str`): séparateur heures / minutes à utiliser (défaut ``"h"``) force_minutes (:class:`bool`): si ``False`` (défaut), les minutes ne sont indiquées que si différentes de ``0``. Returns: :class:`str` (``""`` si ``tps`` est ``None``) """ if tps: if force_minutes or tps.minute > 0: return f"{tps.hour}{sep}{tps.minute:02}" else: return f"{tps.hour}{sep}" else: return ""
# Renvoie le datetime correspondant au prochain moment ou tps arrive DANS LES HORAIRES DU JEU : du dimanche 19:00:00 au vendredi 18:59:59.
[docs]def next_occurence(tps): """Renvoie le timestamp correspondant à la prochaine occurence d'une heure donnée Renvoie le prochain timestamp arrivant DANS LES HORAIRES DU JEU : du dimanche 19:00:00 au vendredi 18:59:59. Args: tps (:class:`datetime.time`): heure dont on veut connaître la prochaine occurence Returns: :class:`datetime.datetime` """ pause = datetime.time(hour=19) now = datetime.datetime.now() jour = now.date() if tps <= now.time(): # Si plus tôt dans la journée que l'heure actuelle jour += datetime.timedelta(days=1) # on réfléchit comme si on était demain très tôt wd = jour.weekday() # Jour de la semaine, Lu = 0 ... Di = 6 if tps < pause: if wd <= 4: # Avant 19h du lundi au vendredi : OK pass else: # Avant 19h mais on est samedi/dimanche jour += datetime.timedelta(days=(7-wd)) else: if wd <= 3 or wd == 6: # Après 19h du dimanche au jeudi : OK pass else: # Après 19h et on est vendredi/samedi jour += datetime.timedelta(days=(6-wd)) return datetime.datetime.combine(jour, tps) # passage de date et time à datetime
[docs]def debut_pause(): """Renvoie le timestamp correspondant au prochain vendredi 19h Returns: :class:`datetime.datetime` """ pause_time = datetime.time(hour=19) pause_wday = 4 # Vendredi now = datetime.datetime.now() jour = now.date() if pause_time <= now.time(): # Si plus tôt dans la journée que l'heure actuelle jour += datetime.timedelta(days=1) # on réfléchit comme si on était demain très tôt pause_jour = jour + datetime.timedelta(days=(pause_wday - jour.weekday()) % 7) # Jour décalé du nombre de jours avant vendredi return datetime.datetime.combine(pause_jour, pause_time) # passage de date et time à datetime
[docs]def fin_pause(): """Renvoie le timestamp correspondant au prochain dimanche 19h Returns: :class:`datetime.datetime` """ reprise_time = datetime.time(hour=19) reprise_wday = 6 # Dimanche now = datetime.datetime.now() jour = now.date() if reprise_time <= now.time(): # Si plus tôt dans la journée que l'heure actuelle jour += datetime.timedelta(days=1) # on réfléchit comme si on était demain très tôt reprise_jour = jour + datetime.timedelta(days=(reprise_wday - jour.weekday()) % 7) # Jour décalé du nombre de jours avant vendredi return datetime.datetime.combine(reprise_jour, reprise_time) # passage de date et time à datetime
### --------------------------------------------------------------------------- ### Split et log ### --------------------------------------------------------------------------- # Sépare <mess> en une liste de messages de moins de <N>=2000 mots (limitation Discord), en séparant aux <sep>=sauts de ligne si possible. # Ajoute <rep> à la fin des messages tronqués de leur séparateur final.
[docs]def smooth_split(mess, N=1990, sep='\n', rep=''): """Sépare un message en une liste de messages moins longs qu'un nombre de caractères donné Très utile pour envoyer des messages de (potentiellement) plus de 2000 caractères (limitation Discord) Args: mess (:class:`str`): message à couper N (:class:`int`): taille maximale des messages formés (défaut ``1990``, pour avoir un peu de marge et permettre d'entourer de ``````` par exemple) sep (:class:`str`) : caractères où séparer préférentiellement le texte (défaut : sauts de ligne). Si ``mess`` contient une sous-chaîne plus longue que ``N`` ne contenant pas ``sep``, le message est tronqué à la limite. rep (:class:`str`) : chaîne ajoutée à la fin de chaque message formé (tronqué du séparateur final) (défaut : aucune) Returns: :class:`list`\[:class:`str`\] """ mess = str(mess) LM = [] # Liste des messages psl = 0 # indice du Précédent Saut de Ligne L = len(mess) while psl + N < L: if mess.count(sep, psl, psl+N+len(sep)): # +len(sep) parce que si sep est à la fin, on le dégage i = psl + N - mess[psl: psl+N+len(sep)][::-1].find(sep) # un peu sombre mais vrai, tkt frère LM.append(mess[psl: i] + rep) psl = i + 1 # on élimine le \n else: LM.append(mess[psl: psl + N]) psl += N if psl < L: LM.append(mess[psl:]) # ce qui reste return LM
[docs]async def send_blocs(messageable, mess, *, N=1990, sep='\n', rep=''): """Envoie un (potentiellement long) message en le coupant en blocs si nécaissaire Surcouche de :func:`.smooth_split` envoyant directement les messages formés Retourne la liste des messages envoyés Args: messageable (:class:`discord.abc.Messageable`): objet où envoyer le message (:class:`~discord.ext.commands.Context` ou :class:`~discord.TextChannel`) mess (:class:`str`): message à envoyer N, sep, rep: *identique à* :func:`.smooth_split` Returns: :class:`list`\[:class:`discord.Message`\] """ messages = [] for bloc in smooth_split(mess, N=N, sep=sep, rep=rep): messages.append(await messageable.send(bloc)) return messages
[docs]async def send_code_blocs(messageable, mess, *, N=1990, sep='\n', rep='', prefixe="", langage=""): """Envoie un (potentiellement long) message sous forme de bloc(s) de code Retourne la liste des messages envoyés :Paramètres: messageable, mess, N, sep, rep: *identiques à :func:`.send_blocs`* prefixe (:class:`str`): texte à mettre hors des code blocs, au début du premier message language: *identique à* :func:`.code_bloc` Returns: :class:`list`\[:class:`discord.Message`\] """ if prefixe: prefixe = prefixe.rstrip() + "\n" messages = [] for i, bloc in enumerate(smooth_split(prefixe + mess, N=N, sep=sep, rep=rep)): if prefixe and i == 0: bloc = bloc[len(prefixe):] message = await messageable.send(prefixe + code_bloc(bloc, langage=langage)) else: message = await messageable.send(code_bloc(bloc, langage=langage)) messages.append(message) return messages
# Log dans #logs
[docs]async def log(arg, message, *, code=False, N=1990, sep='\n', rep='', prefixe="", langage=""): """Envoie un message dans le channel ``#logs`` Retourne la liste des messages envoyés Args: arg (:class:`~discord.ext.commands.Context` | :class:`~discord.Guild` | :class:`~discord.Member` | :class:`~discord.abc.GuildChannel`): argument "connecté" au serveur, permettant de remonter aux channels message (:class:`str`): message à log code (:class:`bool`): si ``True``, log sous forme de bloc(s) de code (défaut ``False``) N, sep, rep: *identique à* :func:`.send_blocs` prefixe: *identique à* :func:`.send_code_blocs`, simplement ajouté avant ``message`` si ``code`` vaut ``False`` language: *identique à* :func:`.send_code_blocs`, sans effet si `code` vaut ``False`` Returns: :class:`list`\[:class:`discord.Message`\] """ logchan = channel(arg, "logs") if code: return (await send_code_blocs(logchan, message, N=N, sep=sep, rep=rep, prefixe=prefixe, langage=langage)) else: if prefixe: message = prefixe.rstrip() + "\n" + message return (await send_blocs(logchan, message, N=N, sep=sep, rep=rep))
### --------------------------------------------------------------------------- ### Autres fonctions diverses ### ---------------------------------------------------------------------------
[docs]async def create_context(bot, member, content): """Simule qu'un membre a envoyé une message dans son chan privé et "génère" le contexte associé Args: bot (:class:`.LGBot`): bot connecté au serveur member (:class:`discord.Member`): membre dont on veut simuler l'action. **Doit être inscrit en base** (pour avoir un chan privé) content (:class:`str`): message à "faire envoyer" au joueur, généralement une commande Utile notemment pour simuler des commandes à partir de clics sur des réactions. Returns: :class:`discord.ext.commands.Context` """ chan = private_chan(member) message = (await chan.history(limit=1).flatten())[0] # On a besoin de récupérer un message, ici le dernier de la conv privée message.author = member message.content = content return await bot.get_context(message)
[docs]def nom_role(role, prefixe=False): """Retourne le nom d'un rôle à partir de son *slug* Args: role (:class:`str`): :attr:`.bdd.Roles.slug` à chercher prefile (:class:`bool`): inclure le préfixe ou non Returns: :class:`str` (:attr:`.bdd.Roles.slug`) | ``None`` (si non trouvé) """ if role := Roles.query.get(role): if prefixe: return f"{role.prefixe}{role.nom}" else: return role.nom else: return None
# Remove accents
[docs]def remove_accents(s): """Enlève les accents d'un chaîne, mais conserve les caractères spéciaux non linguistiques (emojis...) Version plus douce de ``unidecode.unidecode``. Args: s (:class:`str`): chaîne à désaccentuer Returns: :class:`str` """ p = re.compile("([À-ʲΆ-ת])") # Abracadabrax, c'est moche mais ça marche (source : tkt frère) return p.sub(lambda c: unidecode.unidecode(c.group()), s)
# Évaluation d'accolades
[docs]def eval_accols(rep, globals_=None, locals_=None, debug=False): """Replace chaque bloc entouré par des ``{}`` par leur évaluation Python. Args: globals_ (:class:`dict`): variables globales du contexte d'évaluation (passé à :func:`eval`) locals_ (:class:`dict`): variables locales du contexte d'évaluation (passé à :func:`eval`) debug (:class:`bool`): si ``True``, insère le message d'erreur (type et texte de l'exception dans le message) ensuite si une exception est levée durant l'évaluation (défaut ``False``) Penser à passer les :func:`globals` et :func:`locals` si besoin. Généralement, il faut passer :func:`locals` qui contient ``ctx``, etc... mais pas :func:`globals` si on veut bénéficier de tous les modules importés dans ``tools.py`` (tous les modules du projet ou presque). """ if globals_ is None: globals_ = globals() if locals_ is None: locals_ = globals_ if "{" in rep: # Si contient des expressions evrep = "" # Réponse évaluée expr = "" # Expression à évaluer noc = 0 # Nombre de { non appariés for c in rep: if c == "{": if noc: # Expression en cours : expr += c # on garde le { noc += 1 elif c == "}": noc -= 1 if noc: # idem expr += c else: # Fin d'une expression try: # On essaie d'évaluer la chaîne evrep += str(eval(expr, globals_, locals_)) # eval("expr") = expr except Exception as e: evrep += "{" + expr + "}" # Si erreur, on laisse {expr} non évaluée if debug: evrep += code(f"->!!! {e} !!!") expr = "" elif noc: # Expression en cours expr += c else: # Pas d'expression en cours evrep += c if noc: # Si expression jamais finie (nombre impair de {) evrep += "{" + expr return evrep else: return rep
### --------------------------------------------------------------------------- ### Utilitaires de formatage de texte ### ---------------------------------------------------------------------------
[docs]def bold(s): """Formate une chaîne comme texte en **gras** dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"**{s}**"
[docs]def ital(s): """Formate une chaîne comme texte en *italique* dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"*{s}*"
[docs]def soul(s): """Formate une chaîne comme texte souligné dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"__{s}__"
[docs]def strike(s): """Formate une chaîne comme texte barré dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"~~{s}~~"
[docs]def code(s): """Formate une chaîne comme ``code`` (inline) dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"`{s}`"
[docs]def code_bloc(s, langage=""): """Formate une chaîne comme un bloc de code dans Discord Args: s (:class:`str`): chaîne à formater langage (:class:`str`): langage du code, permet la coloration syntaxique (sur ordinateur uniquement) Langages supportés (non exhaustif ?) : ``asciidoc``, ``autohotkey``, ``bash``, ``coffeescript``, ``cpp`` (C++), ``cs`` (C#), ``css``, ``diff``, ``fix``, ``glsl``, ``ini``, ``json``, ``md``, (markdown), ``ml``, ``prolog``, ``py``, ``tex``, ``xl``, ``xml`` Returns: :class:`str` """ return f"```{langage}\n{s}```"
[docs]def quote(s): """Formate une chaîne comme citation (inline) dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"> {s}"
[docs]def quote_bloc(s): """Formate une chaîne comme bloc de citation (multiline) dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f">>> {s}"
[docs]def spoiler(s): """Formate une chaîne comme spoiler (cliquer pour afficher) dans Discord Args: s (:class:`str`): chaîne à formater Returns: :class:`str` """ return f"||{s}||"