Code source de lgrez.features.taches

"""lg-rez / features / Tâches planifiées

Planification, liste, annulation, exécution de tâches planifiées

"""

import datetime
import asyncio
import time

from discord.ext import commands

from lgrez.blocs import env, webhook, bdd, tools
from lgrez.blocs.bdd import Taches, Actions, Joueurs


_last_time = None       # Temps (time.time) du derner envoi de webhook

[docs]def execute(tache, loop): """Exécute une tâche planifiée Args: tache (:class:`.bdd.Taches`): tâche planifiée arrivée à exécution loop (:class:`asyncio.AbstractEventLoop`): boucle d'évènements du bot Envoie un webhook (variable d'environnement ``LGREZ_WEBHOOK_URL``) avec la commande (:attr:`.bdd.Taches.commande`) et nettoie Limitation interne de 2 secondes minimum entre deux appels (reprogramme si appelé trop tôt), pour se conformer à la rate limit Discord (30 messages / minute) et ne pas engoncer la loop """ global _last_time if _last_time and (time.time() - _last_time) < 2: # Moins de deux secondes depuis le dernier envoi loop.call_later(2, execute, tache, loop) # on interdit l'envoi du webhook return _last_time = time.time() LGREZ_WEBHOOK_URL = env.load("LGREZ_WEBHOOK_URL") if webhook.send(tache.commande, url=LGREZ_WEBHOOK_URL): # envoi webhook OK bdd.session.delete(tache) bdd.session.commit() else: # On réessaie dans 2 secondes loop.call_later(2, execute, tache, loop)
[docs]def add_task(bot, timestamp, commande, action=None): """Crée une nouvelle tâche planifiée sur le bot + en base, renvoie l'objet Tâche Args: bot (:class:`.LGBot`): bot connecté timestamp (:class:`datetime.datetime`): timestamp de la tâche à ajouter commande (:class:`str`): commande à exécuter à ``timestamp`` action (:class:`int`): si tâche planifiée liée à une commande, son ID (:attr:`.bdd.Actions.id`) Returns: :class:`.bdd.Taches` (fonction pour usage ici et dans d'autres features) """ now = datetime.datetime.now() tache = Taches(timestamp=timestamp, commande=commande, action=action) bdd.session.add(tache) # Enregistre la tâche en BDD bdd.session.commit() TH = bot.loop.call_later((timestamp - now).total_seconds(), execute, tache, bot.loop) # Programme la tâche (appellera execute(tache, loop) à timestamp) bot.tasks[tache.id] = TH # TaskHandler, pour pouvoir cancel return tache
[docs]def cancel_task(bot, tache): """Supprime (annule) une tâche (fonction pour usage ici et dans d'autres features) Args: bot (:class:`.LGBot`): bot connecté tache (:class:`.bdd.Taches`): tâche à annuler """ bot.tasks[tache.id].cancel() # Annulation (objet TaskHandler) bdd.session.delete(tache) # Suppression en base bdd.session.commit() del bot.tasks[tache.id] # Suppression TaskHandler
[docs]class GestionTaches(commands.Cog): """GestionTaches - Commandes de planification, exécution, annulation de tâches""" @commands.command() @tools.mjs_only async def taches(self, ctx): """Liste les tâches en attente (COMMANDE MJ) Affiche les commandes en attente d'exécution (dans la table :class:`.bdd.Taches`) et le timestamp d'exécution associé. Lorsque la tâche est liée à une action, affiche le nom de l'action et du joueur concerné. """ async with ctx.typing(): taches = Taches.query.order_by(Taches.timestamp).all() LT = "" for tache in taches: LT += f"\n{str(tache.id).ljust(5)} {tache.timestamp.strftime('%d/%m/%Y %H:%M:%S')} {tache.commande.ljust(25)} " if tache.action and (action := Actions.query.get(tache.action)): joueur = Joueurs.query.get(action.player_id) assert joueur, f"!taches : joueur d'ID {action.player_id} introuvable" LT += f"{action.action.ljust(20)} {joueur.nom}" mess = ("Tâches en attente : \n\nID Timestamp Commande Action Joueur" f"\n{'-'*105}{LT}\n\n" "Utilisez !cancel <ID> pour annuler une tâche.") if LT else "Aucune tâche en attente." await tools.send_code_blocs(ctx, mess) @commands.command(aliases=["doat"]) @tools.mjs_only async def planif(self, ctx, quand, *, commande): """Planifie une tâche au moment voulu (COMMANDE MJ) Args: quand: format ``[<J>/<M>[/<AAAA>]-]<H>:<M>[:<S>]``, avec ``<J>`` (jours), ``<M>`` (mois), ``<AAAA>`` (année sur 4 chiffres), ``<H>`` (heures) et ``<M>`` (minutes) des entiers et ``<S>`` (secondes) un entier ou un flottant, optionnel (défaut : ``0``) La date est optionnelle (défaut : date du jour). Si elle est précisée, elle doit être **séparée de l'heure par un tiret** et l'année peut être omise (défaut : année actuelle) ; commande: commande à exécuter (commençant par un ``!``). La commande sera exécutée PAR UN WEBHOOK dans LE CHAN ``#logs`` : toutes les commandes qui sont liées au joueur ou réservées au chan privé sont à proscrire (ou doivent a minima être précédées de ``!doas cible``) Cette commande repose sur l'architecture en base de données, ce qui garantit l'exécution de la tâche même si le bot plante entre temps. Si le bot est down à l'heure d'exécution prévue, la commande sera exécutée dès le bot de retour en ligne. Si la date est dans le passé, la commande est exécutée immédiatement. Examples: - ``!planif 18:00 !close maire`` - ``!planif 13/06-10:00 !open maire`` - ``!planif 13/06/2020-10:00 !open maire`` - ``!planif 23:25:12 !close maire`` """ now = datetime.datetime.now() if "/" in quand: # Date précisée date, time = quand.split("-") J, MA = date.split("/", maxsplit=1) day = int(J) if "/" in MA: # Année précisée M, A = MA.split("/") month = int(M) year = int(A) else: month = int(MA) year = now.year date = datetime.date(year=year, month=month, day=day) else: date = now.date() time = quand H, MS = time.split(":", maxsplit=1) hour = int(H) if ":" in MS: # Secondes précisées M, S = MS.split(":") minute = int(M) second = int(S) else: minute = int(MS) second = 0 time = datetime.time(hour=hour, minute=minute, second=second) ts = datetime.datetime.combine(date, time) action_id = None # ID de l'action associée à la tâche (utile pour propagation à la suppression de l'action) try: quoi, id = commande.split(" ") if quoi in ["!open", "!close", "!remind"]: action_id = int(id) except ValueError: pass if ts < datetime.datetime.now(): mess = await ctx.send("Date dans le passé ==> exécution immédiate ! On valide ?") if not await tools.yes_no(ctx.bot, mess): await ctx.send("Mission aborted.") return tache = add_task(ctx.bot, ts, commande, action=action_id) await ctx.send(f"{tools.code(commande)} planifiée pour le {tools.code(ts.strftime('%d/%m/%Y %H:%M:%S'))}.\n{tools.code(f'!cancel {tache.id}')} pour annuler.") @commands.command(aliases=["retard", "doin"]) @tools.mjs_only async def delay(self, ctx, duree, *, commande): """Exécute une commande après XhYmZs (COMMANDE MJ) Args: quand: format ``[<X>h][<Y>m][<Z>s]``, avec ``<X>`` (heures) et ``<Y>`` (minutes) des entiers et ``<Z>`` (secondes) un entier ou un flottant. Chacune des trois composantes est optionnelle, mais au moins une d'entre elle doit être présente ; commande: commande à exécuter (commençant par un ``!``). La commande sera exécutée PAR UN WEBHOOK dans LE CHAN ``#logs`` : toutes les commandes qui sont liées au joueur ou réservées au chan privé sont à proscrire (ou doivent a minima être précédées d'un ``!doas <cible>``) Cette commande repose sur l'architecture en base de données, ce qui garantit l'exécution de la commande même si le bot plante entre temps. Si le bot est down à l'heure d'exécution prévue, la commande sera exécutée dès le bot de retour en ligne. Examples: - ``!delay 2h !close maire`` - ``!delay 1h30m !doas @moi !vote Yacine Oussar`` """ secondes = 0 try: if "h" in duree.lower(): h, duree = duree.split("h") secondes += 3600*int(h) if "m" in duree.lower(): m, duree = duree.split("m") secondes += 60*int(m) if "s" in duree.lower(): s, duree = duree.split("s") secondes += float(s) except Exception as e: raise commands.BadArgument("<duree>") from e if duree or not secondes: raise commands.BadArgument("<duree>") ts = datetime.datetime.now() + datetime.timedelta(seconds=secondes) action_id = None # ID de l'action associée à la tâche (utile pour propagation à la suppression de l'action) try: quoi, id = commande.split(" ") if quoi in ["!open", "!close", "!remind"]: action_id = int(id) except ValueError: pass add_task(ctx.bot, ts, commande, action=action_id) await ctx.send(f"Commande {tools.code(commande)} planifiée pour le {tools.code(ts.strftime('%d/%m/%Y %H:%M:%S'))}") @commands.command() @tools.mjs_only async def cancel(self, ctx, *ids): """Annule une ou plusieurs tâche(s) planifiée(s) (COMMANDE MJ) Args: *ids: IDs des tâches à annuler, séparées par des espaces. Utiliser ``!taches`` pour voir la liste des IDs. """ taches = [tache for id in ids if id.isdigit() and (tache := Taches.query.get(int(id)))] if taches: message = await ctx.send("Annuler les tâches :\n" + "\n".join([f" - {tools.code(tache.timestamp.strftime('%d/%m/%Y %H:%M:%S'))} > {tools.code(tache.commande)}" for tache in taches])) if await tools.yes_no(ctx.bot, message): for tache in taches: cancel_task(ctx.bot, tache) await ctx.send("Tâche(s) annulée(s).") else: await ctx.send("Mission aborted.") else: await ctx.send(f"Aucune tâche trouvée.")