diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..565ef24 --- /dev/null +++ b/bot.py @@ -0,0 +1,5 @@ +from src import bot +from src.constants import TOKEN + +if __name__ == "__main__": + bot.run(TOKEN) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..d19a14b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +from src.tfjm_discord_bot import bot diff --git a/src/cogs/__init__.py b/src/cogs/__init__.py new file mode 100644 index 0000000..bb404a4 --- /dev/null +++ b/src/cogs/__init__.py @@ -0,0 +1,4 @@ +""" +This package contains all the cogs (groups of commands) +of the TFJM² bot. +""" diff --git a/tfjm-discord-bot.py b/src/cogs/tirages.py similarity index 66% rename from tfjm-discord-bot.py rename to src/cogs/tirages.py index 7d68b4d..a60aa99 100644 --- a/tfjm-discord-bot.py +++ b/src/cogs/tirages.py @@ -1,64 +1,22 @@ #!/bin/python import asyncio -import code -import os import random -import sys -import traceback from collections import defaultdict, namedtuple -from pathlib import Path -from pprint import pprint -from typing import Dict, Type +from typing import Type import discord -from discord.ext import commands +import yaml from discord.ext.commands import Context from discord.utils import get -import yaml -TOKEN = os.environ.get("TFJM_DISCORD_TOKEN") - -if TOKEN is None: - print("No token for the bot were found.") - print("You need to set the TFJM_DISCORD_TOKEN variable in your environement") - print("Or just run:\n") - print(f' TFJM_DISCORD_TOKEN="your token here" python tfjm-discord-bot.py') - print() - quit(1) - -GUILD = "690934836696973404" -ORGA_ROLE = "Orga" -CNO_ROLE = "CNO" -BENEVOLE_ROLE = "Bénévole" -CAPTAIN_ROLE = "Capitaine" - -with open("problems") as f: - PROBLEMS = f.read().splitlines() -MAX_REFUSE = len(PROBLEMS) - 5 - -ROUND_NAMES = ["premier tour", "deuxième tour"] -TIRAGES_FILE = Path(__file__).parent / "tirages.yaml" +from src.constants import * +from src.errors import TfjmError, UnwantedCommand def in_passage_order(teams, round=0): return sorted(teams, key=lambda team: team.passage_order[round] or 0, reverse=True) -class TfjmError(Exception): - def __init__(self, msg): - self.msg = msg - - def __repr__(self): - return self.msg - - -class UnwantedCommand(TfjmError): - def __init__(self, msg=None): - if msg is None: - msg = "Cette commande n'était pas attendu à ce moment." - super(UnwantedCommand, self).__init__(msg) - - class Team(yaml.YAMLObject): yaml_tag = "Team" @@ -146,6 +104,8 @@ class Tirage(yaml.YAMLObject): async def end(self, ctx): + from src.tfjm_discord_bot import tirages + del tirages[self.channel] # Allow everyone to send messages again @@ -559,247 +519,3 @@ class TirageOrderPhase(OrderPhase): "comme ceci `!dice 100`. " "L'ordre des tirages suivants sera l'ordre croissant des lancers. " ) - - -bot = commands.Bot( - "!", help_command=commands.DefaultHelpCommand(no_category="Commandes") -) - -tirages: Dict[int, Tirage] = {} - - -@bot.command( - name="start-draw", - help="Commence un tirage avec 3 ou 4 équipes.", - usage="équipe1 équipe2 équipe3 (équipe4)", -) -@commands.has_role(ORGA_ROLE) -async def start_draw(ctx: Context, *teams): - channel: discord.TextChannel = ctx.channel - channel_id = channel.id - if channel_id in tirages: - raise TfjmError("Il y a déjà un tirage en cours sur cette Channel.") - - if len(teams) not in (3, 4): - raise TfjmError("Il faut 3 ou 4 équipes pour un tirage.") - - roles = {role.name for role in ctx.guild.roles} - for team in teams: - if team not in roles: - raise TfjmError("Le nom de l'équipe doit être exactement celui du rôle.") - - # Here all data should be valid - - # Prevent everyone from writing except Capitaines, Orga, CNO, Benevole - read = discord.PermissionOverwrite(send_messages=False) - send = discord.PermissionOverwrite(send_messages=True) - r = lambda role_name: get(ctx.guild.roles, name=role_name) - overwrites = { - ctx.guild.default_role: read, - r(CAPTAIN_ROLE): send, - r(BENEVOLE_ROLE): send, - } - await channel.edit(overwrites=overwrites) - - await ctx.send( - "Nous allons commencer le tirage du premier tour. " - "Seuls les capitaines de chaque équipe peuvent désormais écrire ici. " - "Merci de d'envoyer seulement ce que est nécessaire et suffisant au " - "bon déroulement du tournoi. Vous pouvez à tout moment poser toute question " - "si quelque chose n'est pas clair ou ne va pas. \n\n" - "Pour plus de détails sur le déroulement du tirgae au sort, le règlement " - "est accessible sur https://tfjm.org/reglement." - ) - - tirages[channel_id] = Tirage(ctx, channel_id, teams) - await tirages[channel_id].phase.start(ctx) - - -@bot.command( - name="abort-draw", help="Annule le tirage en cours.", -) -@commands.has_role(ORGA_ROLE) -async def abort_draw_cmd(ctx): - channel_id = ctx.channel.id - if channel_id in tirages: - await tirages[channel_id].end(ctx) - await ctx.send("Le tirage est annulé.") - - -@bot.command(name="draw-skip", aliases=["skip"]) -@commands.has_role(CNO_ROLE) -async def draw_skip(ctx, *teams): - channel = ctx.channel.id - tirages[channel] = tirage = Tirage(ctx, channel, teams) - - tirage.phase = TiragePhase(tirage, round=1) - for i, team in enumerate(tirage.teams): - team.tirage_order = [i + 1, i + 1] - team.passage_order = [i + 1, i + 1] - team.accepted_problems = [PROBLEMS[i], PROBLEMS[-i - 1]] - tirage.teams[0].rejected = [{PROBLEMS[3]}, set(PROBLEMS[4:8])] - tirage.teams[1].rejected = [{PROBLEMS[7]}, set()] - - await ctx.send(f"Skipping to {tirage.phase.__class__.__name__}.") - await tirage.phase.start(ctx) - await tirage.update_phase(ctx) - - -@bot.event -async def on_ready(): - print(f"{bot.user} has connected to Discord!") - - -@bot.command( - name="dice", - help="Lance un dé à `n` faces. ", - aliases=["de", "dé", "roll"], - usage="n", -) -async def dice(ctx: Context, n: int): - channel = ctx.channel.id - if channel in tirages: - await tirages[channel].dice(ctx, n) - else: - if n < 1: - raise TfjmError(f"Je ne peux pas lancer un dé à {n} faces, désolé.") - - dice = random.randint(1, n) - await ctx.send(f"Le dé à {n} face{'s'*(n>1)} s'est arrêté sur... **{dice}**") - - -@bot.command( - name="choose", - help="Choisit une option parmi tous les arguments.", - usage="choix1 choix2...", - aliases=["choice", "choix", "ch"], -) -async def choose(ctx: Context, *args): - choice = random.choice(args) - await ctx.send(f"J'ai choisi... **{choice}**") - - -@bot.command( - name="random-problem", - help="Choisit un problème parmi ceux de cette année.", - aliases=["rp", "problème-aléatoire", "probleme-aleatoire", "pa"], -) -async def random_problem(ctx: Context): - channel = ctx.channel.id - if channel in tirages: - await tirages[channel].choose_problem(ctx) - else: - problem = random.choice(PROBLEMS) - await ctx.send(f"Le problème tiré est... **{problem}**") - - -@bot.command( - name="accept", - help="Accepte le problème qui vient d'être tiré. \n Ne fonctionne que lors d'un tirage.", - aliases=["oui", "yes", "o", "accepte", "ouiiiiiii"], -) -async def accept_cmd(ctx): - channel = ctx.channel.id - if channel in tirages: - await tirages[channel].accept(ctx, True) - else: - await ctx.send(f"{ctx.author.mention} approuve avec vigeur !") - - -@bot.command( - name="refuse", - help="Refuse le problème qui vient d'être tiré. \n Ne fonctionne que lors d'un tirage.", - aliases=["non", "no", "n", "nope", "jaaamais"], -) -async def refuse_cmd(ctx): - channel = ctx.channel.id - if channel in tirages: - await tirages[channel].accept(ctx, False) - else: - await ctx.send(f"{ctx.author.mention} nie tout en block !") - - -@bot.command(name="show") -async def show_cmd(ctx: Context, arg: str): - if not TIRAGES_FILE.exists(): - await ctx.send("Il n'y a pas encore eu de tirages.") - return - - with open(TIRAGES_FILE) as f: - tirages = list(yaml.load_all(f)) - - if arg.lower() == "all": - msg = "\n".join( - f"{i}: {', '.join(team.name for team in tirage.teams)}" - for i, tirage in enumerate(tirages) - ) - await ctx.send( - "Voici in liste de tous les tirages qui ont été faits. " - "Vous pouvez en consulter un en particulier avec `!show ID`." - ) - await ctx.send(msg) - else: - try: - n = int(arg) - if n < 0: - raise ValueError - tirage = tirages[n] - except (ValueError, IndexError): - await ctx.send( - f"`{arg}` n'est pas un identifiant valide. " - f"Les identifiants valides sont visibles avec `!show all`" - ) - else: - await tirage.show(ctx) - - -@bot.command(name="interrupt") -@commands.has_role(CNO_ROLE) -async def interrupt_cmd(ctx): - await ctx.send( - "J'ai été arrêté et une console interactive a été ouverte là où je tourne. " - "Toutes les commandes rateront tant que cette console est ouverte.\n" - "Soyez rapides, je déteste les opérations à coeur ouvert... :confounded:" - ) - - # Utility function - - local = { - **globals(), - **locals(), - "pprint": pprint, - "_show": lambda o: print(*dir(o), sep="\n"), - "__name__": "__console__", - "__doc__": None, - } - - code.interact(banner="Ne SURTOUT PAS FAIRE Ctrl+C !\n(TFJM² debugger)", local=local) - await ctx.send("Tout va mieux !") - - -@bot.event -async def on_command_error(ctx: Context, error, *args, **kwargs): - if isinstance(error, commands.CommandInvokeError): - if isinstance(error.original, UnwantedCommand): - await ctx.message.delete() - author: discord.Message - await ctx.author.send( - "J'ai supprimé ton message:\n> " - + ctx.message.clean_content - + "\nC'est pas grave, c'est juste pour ne pas encombrer " - "le chat lors du tirage." - ) - await ctx.author.send("Raison: " + error.original.msg) - return - else: - msg = str(error.original) or str(error) - traceback.print_tb(error.original.__traceback__, file=sys.stderr) - else: - msg = str(error) - - print(repr(error), dir(error), file=sys.stderr) - await ctx.send(msg) - - -if __name__ == "__main__": - bot.run(TOKEN) diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..ed45a08 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +__all__ = [ + "TOKEN", + "ORGA_ROLE", + "CNO_ROLE", + "BENEVOLE_ROLE", + "CAPTAIN_ROLE", + "PROBLEMS", + "MAX_REFUSE", + "ROUND_NAMES", + "TIRAGES_FILE", +] + +TOKEN = os.environ.get("TFJM_DISCORD_TOKEN") + +if TOKEN is None: + print("No token for the bot were found.") + print("You need to set the TFJM_DISCORD_TOKEN variable in your environement") + print("Or just run:") + print() + print(f' TFJM_DISCORD_TOKEN="your token here" python tfjm-discord-bot.py') + print() + quit(1) + +GUILD = "690934836696973404" +ORGA_ROLE = "Orga" +CNO_ROLE = "CNO" +BENEVOLE_ROLE = "Bénévole" +CAPTAIN_ROLE = "Capitaine" + +with open("problems") as f: + PROBLEMS = f.read().splitlines() +MAX_REFUSE = len(PROBLEMS) - 5 + +ROUND_NAMES = ["premier tour", "deuxième tour"] + +TOP_LEVEL_DIR = Path(__file__).parent.parent +TIRAGES_FILE = TOP_LEVEL_DIR / "data" / "tirages.yaml" diff --git a/src/errors.py b/src/errors.py new file mode 100644 index 0000000..81087c8 --- /dev/null +++ b/src/errors.py @@ -0,0 +1,28 @@ +""" +This module defines all the custom Exceptions used in this project. +""" + +__all__ = ["TfjmError", "UnwantedCommand"] + + +class TfjmError(Exception): + def __init__(self, msg): + self.msg = msg + + def __repr__(self): + return self.msg + + +class UnwantedCommand(TfjmError): + """ + Exception to throw during when a command was not intended. + + This exception is handled specially in `on_command_error`: + - The message is deleted + - A private message is send to the sender with the reason. + """ + + def __init__(self, reason=None): + if reason is None: + reason = "Cette commande n'était pas attendu à ce moment." + super(UnwantedCommand, self).__init__(reason) diff --git a/src/tfjm_discord_bot.py b/src/tfjm_discord_bot.py new file mode 100644 index 0000000..fff9e1f --- /dev/null +++ b/src/tfjm_discord_bot.py @@ -0,0 +1,264 @@ +#!/bin/python +import asyncio +import code +import random +import sys +import traceback +from collections import defaultdict, namedtuple +from pprint import pprint +from typing import Dict, Type + +import discord +import yaml +from discord.ext import commands +from discord.ext.commands import Context +from discord.utils import get + +from src.cogs.tirages import Tirage, TiragePhase +from src.constants import * +from src.errors import TfjmError, UnwantedCommand + + +bot = commands.Bot( + "!", help_command=commands.DefaultHelpCommand(no_category="Commandes") +) + +# global variable to hold every running tirage +tirages: Dict[int, Tirage] = {} + + +@bot.command( + name="start-draw", + help="Commence un tirage avec 3 ou 4 équipes.", + usage="équipe1 équipe2 équipe3 (équipe4)", +) +@commands.has_role(ORGA_ROLE) +async def start_draw(ctx: Context, *teams): + channel: discord.TextChannel = ctx.channel + channel_id = channel.id + if channel_id in tirages: + raise TfjmError("Il y a déjà un tirage en cours sur cette Channel.") + + if len(teams) not in (3, 4): + raise TfjmError("Il faut 3 ou 4 équipes pour un tirage.") + + roles = {role.name for role in ctx.guild.roles} + for team in teams: + if team not in roles: + raise TfjmError("Le nom de l'équipe doit être exactement celui du rôle.") + + # Here all data should be valid + + # Prevent everyone from writing except Capitaines, Orga, CNO, Benevole + read = discord.PermissionOverwrite(send_messages=False) + send = discord.PermissionOverwrite(send_messages=True) + r = lambda role_name: get(ctx.guild.roles, name=role_name) + overwrites = { + ctx.guild.default_role: read, + r(CAPTAIN_ROLE): send, + r(BENEVOLE_ROLE): send, + } + await channel.edit(overwrites=overwrites) + + await ctx.send( + "Nous allons commencer le tirage du premier tour. " + "Seuls les capitaines de chaque équipe peuvent désormais écrire ici. " + "Merci de d'envoyer seulement ce que est nécessaire et suffisant au " + "bon déroulement du tournoi. Vous pouvez à tout moment poser toute question " + "si quelque chose n'est pas clair ou ne va pas. \n\n" + "Pour plus de détails sur le déroulement du tirgae au sort, le règlement " + "est accessible sur https://tfjm.org/reglement." + ) + + tirages[channel_id] = Tirage(ctx, channel_id, teams) + await tirages[channel_id].phase.start(ctx) + + +@bot.command( + name="abort-draw", help="Annule le tirage en cours.", +) +@commands.has_role(ORGA_ROLE) +async def abort_draw_cmd(ctx): + channel_id = ctx.channel.id + if channel_id in tirages: + await tirages[channel_id].end(ctx) + await ctx.send("Le tirage est annulé.") + + +@bot.command(name="draw-skip", aliases=["skip"]) +@commands.has_role(CNO_ROLE) +async def draw_skip(ctx, *teams): + channel = ctx.channel.id + tirages[channel] = tirage = Tirage(ctx, channel, teams) + + tirage.phase = TiragePhase(tirage, round=1) + for i, team in enumerate(tirage.teams): + team.tirage_order = [i + 1, i + 1] + team.passage_order = [i + 1, i + 1] + team.accepted_problems = [PROBLEMS[i], PROBLEMS[-i - 1]] + tirage.teams[0].rejected = [{PROBLEMS[3]}, set(PROBLEMS[4:8])] + tirage.teams[1].rejected = [{PROBLEMS[7]}, set()] + + await ctx.send(f"Skipping to {tirage.phase.__class__.__name__}.") + await tirage.phase.start(ctx) + await tirage.update_phase(ctx) + + +@bot.event +async def on_ready(): + print(f"{bot.user} has connected to Discord!") + + +@bot.command( + name="dice", + help="Lance un dé à `n` faces. ", + aliases=["de", "dé", "roll"], + usage="n", +) +async def dice(ctx: Context, n: int): + channel = ctx.channel.id + if channel in tirages: + await tirages[channel].dice(ctx, n) + else: + if n < 1: + raise TfjmError(f"Je ne peux pas lancer un dé à {n} faces, désolé.") + + dice = random.randint(1, n) + await ctx.send(f"Le dé à {n} face{'s'*(n>1)} s'est arrêté sur... **{dice}**") + + +@bot.command( + name="choose", + help="Choisit une option parmi tous les arguments.", + usage="choix1 choix2...", + aliases=["choice", "choix", "ch"], +) +async def choose(ctx: Context, *args): + choice = random.choice(args) + await ctx.send(f"J'ai choisi... **{choice}**") + + +@bot.command( + name="random-problem", + help="Choisit un problème parmi ceux de cette année.", + aliases=["rp", "problème-aléatoire", "probleme-aleatoire", "pa"], +) +async def random_problem(ctx: Context): + channel = ctx.channel.id + if channel in tirages: + await tirages[channel].choose_problem(ctx) + else: + problem = random.choice(PROBLEMS) + await ctx.send(f"Le problème tiré est... **{problem}**") + + +@bot.command( + name="accept", + help="Accepte le problème qui vient d'être tiré. \n Ne fonctionne que lors d'un tirage.", + aliases=["oui", "yes", "o", "accepte", "ouiiiiiii"], +) +async def accept_cmd(ctx): + channel = ctx.channel.id + if channel in tirages: + await tirages[channel].accept(ctx, True) + else: + await ctx.send(f"{ctx.author.mention} approuve avec vigeur !") + + +@bot.command( + name="refuse", + help="Refuse le problème qui vient d'être tiré. \n Ne fonctionne que lors d'un tirage.", + aliases=["non", "no", "n", "nope", "jaaamais"], +) +async def refuse_cmd(ctx): + channel = ctx.channel.id + if channel in tirages: + await tirages[channel].accept(ctx, False) + else: + await ctx.send(f"{ctx.author.mention} nie tout en block !") + + +@bot.command(name="show") +async def show_cmd(ctx: Context, arg: str): + if not TIRAGES_FILE.exists(): + await ctx.send("Il n'y a pas encore eu de tirages.") + return + + with open(TIRAGES_FILE) as f: + tirages = list(yaml.load_all(f)) + + if arg.lower() == "all": + msg = "\n".join( + f"{i}: {', '.join(team.name for team in tirage.teams)}" + for i, tirage in enumerate(tirages) + ) + await ctx.send( + "Voici in liste de tous les tirages qui ont été faits. " + "Vous pouvez en consulter un en particulier avec `!show ID`." + ) + await ctx.send(msg) + else: + try: + n = int(arg) + if n < 0: + raise ValueError + tirage = tirages[n] + except (ValueError, IndexError): + await ctx.send( + f"`{arg}` n'est pas un identifiant valide. " + f"Les identifiants valides sont visibles avec `!show all`" + ) + else: + await tirage.show(ctx) + + +@bot.command(name="interrupt") +@commands.has_role(CNO_ROLE) +async def interrupt_cmd(ctx): + await ctx.send( + "J'ai été arrêté et une console interactive a été ouverte là où je tourne. " + "Toutes les commandes rateront tant que cette console est ouverte.\n" + "Soyez rapides, je déteste les opérations à coeur ouvert... :confounded:" + ) + + # Utility function + + local = { + **globals(), + **locals(), + "pprint": pprint, + "_show": lambda o: print(*dir(o), sep="\n"), + "__name__": "__console__", + "__doc__": None, + } + + code.interact(banner="Ne SURTOUT PAS FAIRE Ctrl+C !\n(TFJM² debugger)", local=local) + await ctx.send("Tout va mieux !") + + +@bot.event +async def on_command_error(ctx: Context, error, *args, **kwargs): + if isinstance(error, commands.CommandInvokeError): + if isinstance(error.original, UnwantedCommand): + await ctx.message.delete() + author: discord.Message + await ctx.author.send( + "J'ai supprimé ton message:\n> " + + ctx.message.clean_content + + "\nC'est pas grave, c'est juste pour ne pas encombrer " + "le chat lors du tirage." + ) + await ctx.author.send("Raison: " + error.original.msg) + return + else: + msg = str(error.original) or str(error) + traceback.print_tb(error.original.__traceback__, file=sys.stderr) + else: + msg = str(error) + + print(repr(error), dir(error), file=sys.stderr) + await ctx.send(msg) + + +if __name__ == "__main__": + bot.run(TOKEN)