"""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...
"""
import asyncio
import datetime
import functools
import re
import string
import warnings
import discord
import discord.utils
from discord.ext import commands
import unidecode
from lgrez import config, bdd
from lgrez.blocs import one_command
from lgrez.bdd import *
# 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 ValueError 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 and not item:
if raiser is None:
raiser = "tools._find_by_mention_or_name"
raise ValueError(f"{raiser} : Élément '{val}' introuvable")
return item
[docs]def channel(nom, must_be_found=True):
"""Renvoie l'objet associé au salon ``#nom``.
Args:
nom (str): nom du channel (texte/vocal/catégorie) ou sa
mention (détection directe par regex)
must_be_found (bool): si ``True`` (défaut), raise une
:exc:`ValueError` si le channel ``#nom`` n'existe pas
(si ``False``, renvoie ``None``)
Returns:
:class:`discord.abc.GuildChannel`
"""
return _find_by_mention_or_name(
config.guild.channels, nom, pattern="<#([0-9]{18})>",
must_be_found=must_be_found, raiser="tools.channel"
)
[docs]def role(nom, must_be_found=True):
"""Renvoie l'objet associé au rôle ``@&nom``.
Args:
nom (str): nom du rôle ou sa mention
(détection directe par regex)
must_be_found (bool): si ``True`` (défaut), raise une
:exc:`ValueError` si le channel ``@&nom`` n'existe pas
(si ``False``, renvoie ``None``)
Returns:
:class:`discord.Role`
"""
return _find_by_mention_or_name(
config.guild.roles, nom, pattern="<@&([0-9]{18})>",
must_be_found=must_be_found, raiser="tools.role"
)
[docs]def member(nom, must_be_found=True):
"""Renvoie l'objet associé au membre ``@nom``.
Args:
nom (str): nom du joueur ou sa mention
(détection directe par regex)
must_be_found (bool): si ``True`` (défaut),
raise une :exc:`ValueError` si le membre ``@nom`` n'existe pas
(si ``False``, renvoie ``None``)
Returns:
:class:`discord.Member`
"""
return _find_by_mention_or_name(
config.guild.members, nom, pattern="<@!([0-9]{18})>",
must_be_found=must_be_found, raiser="tools.member"
)
[docs]def emoji(nom, must_be_found=True):
"""Renvoie l'objet associé à l'emoji ``:nom:``.
Args:
nom (str): nom de l'emoji (texte/vocal/catégorie)
ou son utilisation (détection directe par regex)
must_be_found (bool): si ``True`` (défaut), raise une
:exc:`ValueError` si l'emoji ``:nom:`` n'existe pas
(si ``False``, renvoie ``None``)
Returns:
:class:`discord.Emoji`
"""
return _find_by_mention_or_name(
config.guild.emojis, nom, pattern="<:.*:([0-9]{18})>",
must_be_found=must_be_found, raiser="tools.emoji"
)
# Appel aux MJs
[docs]def mention_MJ(arg):
"""Renvoie la mention ou le nom du rôle MJ
- Si le joueur n'est pas un MJ, renvoie la mention de
:attr:`config.Role.mj`
- Sinon, renvoie son nom (pour ne pas rameuter tout le monde).
Args:
arg (:class:`~discord.Member` |\
:class:`~discord.ext.commands.Context`):
membre ou contexte d'un message envoyé par un membre
Returns:
:class:`str`
"""
member = arg.author if isinstance(arg, commands.Context) else arg
if (config.is_setup
and isinstance(member, discord.Member)
and member.top_role == config.Role.mj):
# Pas un webhook et (au moins) MJ
return f"@{config.Role.mj.name}"
else:
return config.Role.mj.mention
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
[docs]class CommandExit(RuntimeError):
"""Fin de commande demandée.
Lever cette exception force l'arrêt immédiat d'une commande,
et empêche le bot de réagir à nouveau.
Dérive 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`) :
#: commande exécutable uniquement par un :attr:`MJ <.config.Role.mj>`
#: ou un webhook (tâche planifiée)
mjs_only = commands.check_any(
commands.check(lambda ctx: ctx.message.webhook_id),
commands.has_role(config.Role.get_raw("mj")) # nom du rôle
)
#: Décorateur pour commandes d'IA (:func:`discord.ext.commands.check`) :
#: commande exécutable par un :attr:`MJ <.config.Role.mj>`, un
#: :attr:`Rédacteur <.config.Role.redacteur>` ou un webhook (tâche planifiée)
mjs_et_redacteurs = commands.check_any(
mjs_only,
commands.has_role(config.Role.get_raw("redacteur"))
)
#: Décorateur pour commande (:func:`discord.ext.commands.check`) :
#: commande exécutable uniquement par un joueur,
#: :attr:`vivant <.config.Role.joueur_en_vie>` ou
#: :attr:`mort <.config.Role.joueur_mort>`.
joueurs_only = commands.has_any_role(
config.Role.get_raw("joueur_en_vie"),
config.Role.get_raw("joueur_mort")
)
#: Décorateur pour commande (:func:`discord.ext.commands.check`) :
#: commande exécutable uniquement par un
#: :attr:`joueur vivant <.config.Role.joueur_en_vie>`
vivants_only = commands.has_role(config.Role.get_raw("joueur_en_vie"))
[docs]def private(callback):
"""Décorateur : commande utilisable dans son chan privé uniquement.
Lors d'une invocation de la commande décorée hors d'un channel privé
(commençant par :attr:`config.private_chan_prefix`), supprime le
message d'invocation et exécute la commande dans le channel privé
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:`ValueError`.
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é).
"""
@functools.wraps(callback)
async def new_callback(cog, ctx, *args, **kwargs):
if not ctx.channel.name.startswith(config.private_chan_prefix):
await ctx.message.delete()
await one_command.remove_from_in_command(ctx)
# chan dans le contexte d'appel = chan privé
ctx.channel = Joueur.from_member(ctx.author).private_chan
await ctx.send(
f"{quote(ctx.message.content)}\n"
f"{ctx.author.mention} :warning: Cette commande est "
f"interdite en dehors de ta conv privée ! :warning:\n"
f"J'ai supprimé ton message, et j'exécute la commande ici :"
)
await one_command.add_to_in_command(ctx)
# Dans tous les cas, appelle callback (avec le contexte modifié)
return await callback(cog, ctx, *args, **kwargs)
return new_callback
# ---------------------------------------------------------------------------
# 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(check, trigger_on_commands=False, chan=None):
"""Attend 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:
check (Callable[:class:`discord.Message` -> :class:`bool`]):
fonction validant ou non chaque message.
trigger_on_commands (bool): si ``False`` (défaut), un message
respectant ``check`` sera ignoré si c'est une commande.
chan (discord.TextChannel): le channel dans lequel le message
est attendu (si applicable). Si ``None``, les messages
d'arrêt (:obj:`config.stop_keywords`) peuvent ne pas être
détectés (la fonction émettera un warning en ce sens).
Returns:
:class:`discord.Message`
Raises:
.CommandExit: si le message est un des :obj:`.config.stop_keywords`
(insensible à la casse), même si il respecte ``check``
"""
stop_keywords = [kw.lower() for kw in config.stop_keywords]
if chan:
def stop_check(m):
return (m.channel == chan and m.content.lower() in stop_keywords)
else:
warnings.warn("lgrez.tools.wait_for_message called with `chan=None`, "
"stop messages may be ignored.", stacklevel=2)
def stop_check(m):
return False
if trigger_on_commands:
# on trigger en cas de STOP
def trig_check(m):
return (check(m) or stop_check(m))
else:
def trig_check(m):
# on ne trigger pas sur les commandes et on trigger en cas de STOP
return ((check(m)
and not m.content.startswith(config.bot.command_prefix))
or stop_check(m))
message = await config.bot.wait_for('message', check=trig_check)
if message.content.lower() in stop_keywords:
if message.author == config.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 (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(check=trig_check, chan=ctx.channel,
trigger_on_commands=trigger_on_commands)
return message
# Permet de boucler question -> réponse tant que la réponse ne
# vérifie pas les critères nécessaires
[docs]async def boucle_message(chan, in_message, condition_sortie, rep_message=None):
"""Boucle question/réponse jusqu'à qu'une condition soit vérifiée.
Args:
chan (discord.TextChannel): salon dans lequel lancer la boucle.
condition_sortie (Callable[:class:`discord.Message` -> :class:`bool`]):
fonction validant ou non chaque message.
in_message (str): si défini, message à envoyer avant la boucle.
rep_message (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):
# Message envoyé pas par le bot et dans le bon chan
return m.channel == chan and m.author != config.bot.user
if in_message:
await chan.send(in_message)
rep = await wait_for_message(check_chan, chan=chan)
while not condition_sortie(rep):
await chan.send(rep_message)
rep = await wait_for_message(check_chan, chan=chan)
return rep
[docs]async def boucle_query(ctx, table, col=None, cible=None, filtre=None,
sensi=0.5, direct_detector=None, message=None):
"""Fait trouver à l'utilisateur une entrée de BDD d'après son nom.
Args:
ctx (discord.ext.commands.Context): contexte d'une commande.
table (.bdd.base.TableMeta): table dans laquelle rechercher.
col (~sqlalchemy.schema.Column): colonne dans laquelle rechercher
(passé à :meth:`~.bdd.base.TableMeta.find_nearest`).
cible (str): premier essai de cible (donnée par le joueur dans
l'appel à une commande, par exemple).
filtre: passé à :meth:`~.bdd.base.TableMeta.find_nearest`.
sensi (float): sensibilité de la recherche (voir
:meth:`~.bdd.TableMeta.find_nearest`).
direct_detector (Callable[str] -> :attr:`table` | ``None``):
pré-détecteur éventuel, appellé sur l'entrée utilisateur
avant :meth:`~.bdd.TableMeta.find_nearest` ; si cette
fonction renvoie un résultat, il est immédiatement renvoyé.
message (str): si défini (et ``cible`` non défini), message à
envoyer avant la boucle.
Returns:
Instance de :attr:`table` sélectionnée
Attend que le joueur entre un nom, et boucle 5 fois au max
(avant de l'insulter et de raise une erreur) pour chercher
l'entrée la plus proche.
"""
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("()[]{}<>") # dézèlificateur
# Détection directe
if direct_detector:
dir = direct_detector(rep)
if dir:
return dir
# Sinon, recherche au plus proche
nearest = table.find_nearest(rep, col=col, sensi=sensi, filtre=filtre,
solo_si_parfait=False,
match_first_word=True)
if not nearest:
await ctx.send("Aucune entrée trouvée, merci de réessayer : "
+ ital("(`stop` pour annuler)"))
elif len(nearest) == 1: # Une seule correspondance
result, score = nearest[0]
if score == 1: # parfait
return result
mess = await ctx.send("Je n'ai trouvé qu'une correspondance : "
f"{bold(result)}.\nÇa part ?")
if await yes_no(mess):
return result
else:
await ctx.send("Bon d'accord, alors qui ? "
+ ital("(`stop` pour annuler)"))
else:
text = ("Les résultats les plus proches de ton entrée "
"sont les suivants : \n")
for i, (result, score) in enumerate(nearest[:10]):
text += f"{emoji_chiffre(i + 1)}. {result} \n"
mess = await ctx.send(
text + ital("Tu peux les choisir en réagissant à ce "
"message, ou en répondant au clavier. "
"(`stop` pour annuler)")
)
n = await choice(mess, min(10, len(nearest)))
return nearest[n - 1][0]
await ctx.send("Et puis non, tiens !\nhttps://giphy.com/gifs/fuck-you-"
"middle-finger-ryan-stiles-x1kS7NRIcIigU")
raise RuntimeError("Le joueur est trop con, je peux rien faire")
[docs]async def boucle_query_joueur(ctx, cible=None, message=None,
sensi=0.5, filtre=None):
"""Retourne un joueur (entrée de BDD) d'après son nom.
Args:
ctx (discord.ext.commands.Context): contexte d'une commande.
cible (str): premier essai de cible (donnée par le joueur dans
l'appel à une commande, par exemple).
message (str): si défini (et ``cible`` non défini), message à
envoyer avant la boucle.
sensi (float): sensibilité de la recherche (voir
:meth:`~.bdd.TableMeta.find_nearest`).
filtre: passé à :meth:`~.bdd.TableMeta.find_nearest`.
Returns:
:class:`.bdd.Joueur`
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.Joueur`.
"""
# Détection directe par ID / nom exact
def direct_detector(rep):
mem = member(rep, must_be_found=False)
if mem:
try: # Récupération du joueur
return Joueur.from_member(mem)
except ValueError: # pas inscrit en base
pass
return None
res = await boucle_query(ctx, Joueur, col=Joueur.nom, cible=cible,
sensi=sensi, filtre=filtre,
direct_detector=direct_detector,
message=message)
return res
# Récupère un input par réaction
[docs]async def wait_for_react_clic(message, emojis={}, *, process_text=False,
text_filter=None, first_text=None,
post_converter=None,
trigger_all_reacts=False,
trigger_on_commands=False):
"""Ajoute des reacts à un message et attend une interaction.
Args:
message (discord.Message): message où ajouter les réactions.
emojis (:class:`list` | :class:`dict`): reacts à ajouter,
éventuellement associés à une valeur qui sera retournée
si clic sur l'emoji.
process_text (bool): si ``True``, détecte aussi la réponse par
message et retourne le texte du message (défaut : ``False``).
text_filter (Callable[:class:`str` -> :class:`bool`]): si
``process_text``, ne réagit qu'aux messages pour lesquels
``text_filter(message)`` renvoie ``True`` (défaut : tous).
first_text (str): si ``process_text``, texte considéré comme la
première réponse textuelle reçue (si il vérifie
``text_filter``, les emojis ne sont pas ajoutés et cette
fonction retourne directement).
post_converter (Callable[:class:`str` -> Any]): 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 (bool): si ``True``, détecte l'ajout de
toutes les réactions (pas seulement celles dans ``emojis``)
et renvoie l'emoji directement si il n'est pas dans
``emojis`` (défaut : ``False``).
trigger_on_commands (bool): passé à :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 ;
- Any -- 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}
if text_filter is None:
def text_filter(text):
return True
if process_text and first_text:
if text_filter(first_text): # passe le filtre
return post_converter(first_text) if post_converter else first_text
try:
# Si une erreur dans ce bloc, on supprime les emojis
# du message (sinon c'est moche)
for emoji in emojis:
try:
await message.add_reaction(emoji)
except discord.errors.HTTPException:
await message.channel.send(f"*Emoji {emoji} inconnu, ignoré*")
emojis_names = {emoji.name if hasattr(emoji, "name")
else emoji: emoji for emoji in emojis}
def react_check(react):
# Check REACT : bon message, bon emoji, et pas react du bot
name = react.emoji.name
return (react.message_id == message.id
and react.user_id != config.bot.user.id
and (trigger_all_reacts or name in emojis_names))
react_task = asyncio.create_task(
config.bot.wait_for('raw_reaction_add', check=react_check)
)
if process_text:
# Check MESSAGE : bon channel, pas du bot, et filtre
def message_check(mess):
return (mess.channel == message.channel
and mess.author != config.bot.user
and text_filter(mess.content))
else:
# On process DANS TOUS LES CAS, mais juste pour détecter
# les stop_keywords si process_text == False
def message_check(mess):
return False
mess_task = asyncio.create_task(
wait_for_message(check=message_check, chan=message.channel,
trigger_on_commands=trigger_on_commands)
)
done, pending = await asyncio.wait([react_task, mess_task],
return_when=asyncio.FIRST_COMPLETED)
# Le bot attend ici qu'une des deux tâches aboutisse
for task in pending:
task.cancel()
done_task = next(iter(done)) # done = tâche aboutie
if done_task == react_task: # Réaction
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)))
for emoji in emojis:
# On finit par supprimer les emojis mis par le bot
await message.remove_reaction(emoji, config.bot.user)
else: # Réponse par message / STOP
mess = done_task.result().content
ret = post_converter(mess) if post_converter else mess
await message.clear_reactions()
except Exception:
await message.clear_reactions()
raise
return ret
[docs]async def yes_no(message, first_text=None):
"""Demande une confirmation / question fermée à l'utilisateur.
Surcouche de :func:`wait_for_react_clic` : ajoute les reacts
✅ et ❎ à un message et renvoie ``True`` ou ``False`` en fonction
de l'emoji cliqué OU de la réponse textuelle détectée.
Args:
message (discord.Message): message où ajouter les réactions.
first_text (str): passé à :func:`wait_for_react_clic`.
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(
message, emojis={"✅": True, "❎": False}, process_text=True,
first_text=first_text,
text_filter=lambda s: s.lower() in yes_no_words,
post_converter=lambda s: s.lower() in yes_words,
)
yes_no_maybe_i_dont_know_can_you_repeat_the_question = yes_no
[docs]async def choice(message, N, start=1, *, additionnal={}):
"""Demande à l'utilisateur de choisir entre plusieurs options numérotées.
Surcouche de :func:`wait_for_react_clic` : ajoute des reacts
chiffres (1️⃣, 2️⃣, 3️⃣...) et renvoie le numéro cliqué OU détecté
par réponse textuelle.
Args:
message (discord.Message): message où ajouter les réactions.
N (int): chiffre jusqu'auquel aller, inclus (``<= 10``).
start (int): chiffre auquel commencer (entre ``0`` et ``N``,
défaut ``1``).
additionnal (dict[:class:`discord.Emoji` | :class:`str`, Any]):
emojis optionnels à ajouter après les chiffres et valeur
renvoyée si cliqué.
Réponses textuelles reconnues : chiffres entre ``start`` et ``N``.
Returns:
:class:`int` (ou la valeur associée si emoji choisi dans
``additionnal``)
"""
emojis = {emoji_chiffre(i): i for i in range(start, N + 1)}
emojis.update(additionnal)
return await wait_for_react_clic(
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, tps):
"""Attend un temps donné en avertissant l'utilisateur.
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 (discord.abc.Messageable): salon / contexte /... sur lequel
attendre.
tps (float): temps à attendre, en secondes.
"""
async with chan.typing():
await asyncio.sleep(tps)
# ---------------------------------------------------------------------------
# Utilitaires d'emojis
# ---------------------------------------------------------------------------
[docs]def montre(heure=None):
"""Renvoie l'emoji horloge le plus proche d'une heure donnée.
Args:
heure (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 minute >= 45:
heure = (heure + 1) % 12
if 15 < minute < 45: # Demi heure
L = ["\N{CLOCK FACE TWELVE-THIRTY}", "\N{CLOCK FACE ONE-THIRTY}",
"\N{CLOCK FACE TWO-THIRTY}", "\N{CLOCK FACE THREE-THIRTY}",
"\N{CLOCK FACE FOUR-THIRTY}", "\N{CLOCK FACE FIVE-THIRTY}",
"\N{CLOCK FACE SIX-THIRTY}", "\N{CLOCK FACE SEVEN-THIRTY}",
"\N{CLOCK FACE EIGHT-THIRTY}", "\N{CLOCK FACE NINE-THIRTY}",
"\N{CLOCK FACE TEN-THIRTY}", "\N{CLOCK FACE ELEVEN-THIRTY}"]
else: # Heure pile
L = ["\N{CLOCK FACE TWELVE OCLOCK}", "\N{CLOCK FACE ONE OCLOCK}",
"\N{CLOCK FACE TWO OCLOCK}", "\N{CLOCK FACE THREE OCLOCK}",
"\N{CLOCK FACE FOUR OCLOCK}", "\N{CLOCK FACE FIVE OCLOCK}",
"\N{CLOCK FACE SIX OCLOCK}", "\N{CLOCK FACE SEVEN OCLOCK}",
"\N{CLOCK FACE EIGHT OCLOCK}", "\N{CLOCK FACE NINE OCLOCK}",
"\N{CLOCK FACE TEN OCLOCK}", "\N{CLOCK FACE ELEVEN OCLOCK}"]
return L[heure]
[docs]def emoji_chiffre(chiffre, multi=False):
"""Renvoie l'emoji / les emojis chiffre correspondant à un chiffre/nombre.
Args:
chiffre (int): chiffre/nombre à représenter.
multi (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` (0️⃣, 1️⃣, 2️⃣...)
"""
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(chr)) for chr 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) exposant correspondant à un chiffre/nombre.
Args:
chiffre (int): chiffre/nombre à représenter.
multi (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(chr)) for chr 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) indice correspondant à un chiffre/nombre.
Args:
chiffre (int): chiffre/nombre à représenter.
multi (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 (str): heure au format ``HHh``, ``HHhMM`` ou ``HH:MM``.
Returns:
:class:`datetime.time`
Raises:
ValueError: conversion impossible (mauvais format)
"""
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 (datetime.time): temps à convertir.
sep (str): séparateur heures / minutes à utiliser
(défaut ``"h"``).
force_minutes (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 ""
[docs]def next_occurence(tps):
"""Renvoie la prochaine occurence temporelle d'une heure donnée.
Renvoie le prochain timestamp arrivant DANS LES HORAIRES DU JEU :
entre :func:`.tools.fin_pause` et :func:`.tools.debut_pause`.
Args:
tps (datetime.time): heure dont on veut connaître
la prochaine occurence.
Returns:
:class:`datetime.datetime`
"""
now = datetime.datetime.now()
jour = now.date()
if tps < now.time():
# Si plus tôt dans la journée que l'heure actuelle,
# on réfléchit comme si on était demain
jour += datetime.timedelta(days=1)
test_dt = datetime.datetime.combine(jour, tps)
if test_dt < debut_pause() and not en_pause():
# Prochaine occurence avant la pause : OK
return test_dt
# Sinon, programmer après la pause
finp = fin_pause()
jour = finp.date()
if tps < finp.time():
# Si plus tôt dans la journée que l'heure de reprise,
# on réfléchit comme si on était le lendemain
jour += datetime.timedelta(days=1)
return datetime.datetime.combine(jour, tps)
[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,
# on réfléchit comme si on était demain
jour += datetime.timedelta(days=1)
ddays = (pause_wday - jour.weekday()) % 7
# Jour décalé du nombre de jours avant vendredi
pause_jour = jour + datetime.timedelta(days=ddays)
return datetime.datetime.combine(pause_jour, pause_time)
[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,
# on réfléchit comme si on était demain
jour += datetime.timedelta(days=1)
ddays = (reprise_wday - jour.weekday()) % 7
# Jour décalé du nombre de jours avant vendredi
reprise_jour = jour + datetime.timedelta(days=ddays)
return datetime.datetime.combine(reprise_jour, reprise_time)
[docs]def en_pause():
"""Détermine si le jeu est actuellement en pause hebdomadaire.
Si il n'y a pas de pause (:func:`.fin_pause` = :func:`.debut_pause`),
renvoie toujours ``False``.
Returns:
:class:`bool`
"""
return fin_pause() < debut_pause()
# ---------------------------------------------------------------------------
# Split et log
# ---------------------------------------------------------------------------
[docs]def smooth_split(mess, N=1990, sep='\n', rep=''):
"""Sépare un message en une blocs moins longs qu'une limite donnée.
Très utile pour envoyer des messages de (potentiellement) plus de
2000 caractères (limitation Discord).
Args:
mess (str): message à découper.
N (int): taille maximale des messages formés (défaut ``1990``,
pour avoir un peu de marge par rapport à la limitation, et
permettre d'entourer de ``````` par exemple)
sep (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 sera tronqué à la limite.
rep (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] + rep)
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 message en le coupant en blocs si nécaissaire.
Surcouche de :func:`.smooth_split` envoyant directement
les messages formés.
Args:
messageable (discord.abc.Messageable): objet où envoyer le
message (:class:`~discord.ext.commands.Context` ou
:class:`~discord.TextChannel`).
mess (str): message à envoyer
N, sep, rep: passé à :func:`.smooth_split`.
Returns:
list[discord.Message]: La liste des messages envoyés.
"""
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.
Équivalent de :func:`.send_blocs` avec formatage de chaque bloc
dans un bloc de code.
Args:
messageable, mess, N, sep, rep: voir :func:`.send_blocs`.
prefixe (str): texte optionnel à mettre hors des code blocs,
au début du premier message.
language (str): voir :func:`.code_bloc`.
Returns:
list[discord.Message]: La liste des messages envoyés.
"""
mess = str(mess)
if prefixe:
prefixe = prefixe.rstrip() + "\n"
blocs = smooth_split(prefixe + mess, N=N, sep=sep, rep=rep)
messages = []
for i, bloc in enumerate(blocs):
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
[docs]async def log(message, *, code=False, N=1990, sep='\n', rep='',
prefixe="", langage=""):
"""Envoie un message dans le channel :attr:`config.Channel.logs`.
Surcouche de :func:`.send_blocs` / :func:`.send_code_blocs`.
Args:
message (str): message à log.
code (bool): si ``True``, log sous forme de bloc(s) de code
(défaut ``False``).
N, sep, rep: passé à :func:`.send_blocs` /
:func:`.send_code_blocs`.
prefixe: voir :func:`.send_code_blocs`, simplement ajouté avant
``message`` si ``code`` vaut ``False``.
language: *identique à* :func:`.send_code_blocs`, ignoré
si `code` vaut ``False``.
Returns:
list[discord.Message]: La liste des messages envoyés.
"""
logchan = config.Channel.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(member, content):
"""Génère le contexte associé au message d'un membre dans son chan privé.
Args:
member (discord.Member): membre dont on veut simuler l'action.
**Doit être inscrit en base** (pour avoir un chan privé).
content (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 = Joueur.from_member(member).private_chan
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
ctx = await config.bot.get_context(message)
return ctx
[docs]def remove_accents(text):
"""Enlève les accents d'un chaîne, mais conserve les caractères spéciaux.
Version plus douce de ``unidecode.unidecode``, conservant notemment
les emojis, ...
Args:
text (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()), text)
# É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_ (dict): variables globales du contexte d'évaluation
(passé à :func:`eval`).
locals_ (dict): variables locales du contexte d'évaluation
(passé à :func:`eval`).
debug (bool): si ``True``, insère le message d'erreur (type et
texte de l'exception) dans le message à l'endroit où 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``.
"""
if globals_ is None:
globals_ = globals()
globals_.update(tools=__import__(__name__, fromlist=("tools")))
if locals_ is None:
locals_ = globals_
if "{" not in rep: # Si pas d'expressions, on renvoie direct
return rep
evrep = "" # Réponse évaluée
expr = "" # Expression à évaluer
noc = 0 # Nombre de { non appariés
for car in rep:
if car == "{":
if noc: # Expression en cours :
expr += car # on garde le {
noc += 1
elif car == "}":
noc -= 1
if noc: # idem
expr += car
else: # Fin d'une expression
try: # On essaie d'évaluer la chaîne
evrep += str(eval(expr, globals_, locals_))
except Exception as e:
# Si erreur, on laisse {expr} non évaluée
evrep += "{" + expr + "}"
if debug:
evrep += code(f"->!!! {e} !!!")
expr = ""
elif noc: # Expression en cours
expr += car
else: # Pas d'expression en cours
evrep += car
if noc: # Si expression jamais finie (nombre impair de {)
evrep += "{" + expr
return evrep
[docs]async def multicateg(base_name: str) -> discord.CategoryChannel:
"""Permet de gérer des groupes de catégories (plus de 50 salons).
Renvoie la première catégorie pouvant accueillir un nouveau
salon ; en crée une nouvelle si besoin.
Args:
base_name (str): le nom du groupe de catégorie (nom de la
première catégorie, puis sera suivi de 2, 3...)
Warning:
Une catégorie appellée ``base_name`` doit exister au préalable
dans le serveur (:attr:`config.guild`).
Returns:
:class:`discord.CategoryChannel`
"""
categ = channel(base_name)
nb = 1
while len(categ.channels) >= 50:
# Limitation Discord : 50 channels par catégorie
nb += 1
next_name = f"{base_name} {nb}"
next_categ = channel(next_name, must_be_found=False)
if not next_categ:
next_categ = await categ.clone(name=next_name)
categ = next_categ
return categ
[docs]def in_multicateg(categ: discord.CategoryChannel, base_name: str) -> bool:
"""Détecte si une catégorie appartient à un groupe de catégories.
Args:
categ (discord.CategoryChannel): la catégorie à tester.
base_name (str): le nom de base du groupe de catégories
(voir :func:`.multicateg`).
Returns:
:class:`bool`
"""
stripped_name = categ.name.rstrip(string.digits + " ")
return (stripped_name == base_name)
# ---------------------------------------------------------------------------
# Utilitaires de formatage de texte
# ---------------------------------------------------------------------------
[docs]def bold(text):
"""Formate une chaîne comme texte en **gras** dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"**{text}**"
[docs]def ital(text):
"""Formate une chaîne comme texte en *italique* dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"*{text}*"
[docs]def soul(text):
"""Formate une chaîne comme texte souligné dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"__{text}__"
[docs]def strike(text):
"""Formate une chaîne comme texte barré dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"~~{text}~~"
[docs]def code(text):
"""Formate une chaîne comme ``code`` (inline) dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"`{text}`"
[docs]def code_bloc(text, langage=""):
"""Formate une chaîne comme un bloc de code dans Discord.
Args:
text (str): chaîne à formater.
langage (str): langage du code, pour coloration syntaxique.
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{text}```"
[docs]def quote(text):
"""Formate une chaîne comme citation (inline) dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"> {text}"
[docs]def quote_bloc(text):
"""Formate une chaîne comme bloc de citation (multiline) dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f">>> {text}"
[docs]def spoiler(text):
"""Formate une chaîne comme spoiler (cliquer pour afficher) dans Discord.
Args:
text (str): chaîne à formater.
Returns:
:class:`str`
"""
return f"||{text}||"