Invabot
Table of Contents
This file is part of Invabot.
Invabot is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version.
Invabot is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with Invabot. If not, see https://www.gnu.org/licenses/
1. Algorithme d'attribution des rôles
Cette section définit l'algorithme d'attribution des rôles, qui est indépendant de Discord.
1.1. Principe
Attribuer un rôle à chaque joueur, en optimisant la pertinence totale des attributions.
1.2. Licence
# This file is part of Invabot. # Invabot is free software: you can redistribute it # and/or modify it under the terms of the GNU General # Public License as published by the Free Software # Foundation, either version 3 of the License, or # any later version. # Invabot is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the # implied warranty of MERCHANTABILITY or FITNESS FOR A # PARTICULAR PURPOSE. See the GNU General Public License # for more details. # You should have received a copy of the GNU General # Public License along with Invabot. If not, see # <https://www.gnu.org/licenses/>
1.3. Dépendances
import numpy as np import pandas as pd from scipy.optimize import linear_sum_assignment from typing import Dict, List, Tuple import numpy.typing as npt NDArrayInt = npt.NDArray[np.int_]
1.4. Données d'entrée
Les données d'entrées sont :
1.4.1. Données de test
def get_test_roster(): url = "https://docs.google.com/spreadsheets/d/1FgtMvUQbLxP7qvXt2tnVwFFyX9KWnuREHu7o0RtX13M/gviz/tq?tqx=out:csv&sheet=Roster" return pd.read_csv(url).set_index('dtag')
def get_test_strat(): url = "https://docs.google.com/spreadsheets/d/1FgtMvUQbLxP7qvXt2tnVwFFyX9KWnuREHu7o0RtX13M/gviz/tq?tqx=out:csv&sheet=Strat_principale" page = pd.read_csv(url) return page.iloc[0:5, 0:10]
1.5. Identification des rôles à attribuer
La première étape consiste à extraire, depuis la stratégie d'entrée, les rôles à attribuer.
def extraction_roles(strat: pd.DataFrame) -> Dict[str, int]: roles = {} for index, row in strat.iterrows(): for v in row: v = v.split(' ')[0] if v in roles: roles[v] += 1 else: roles[v] = 1 return roles
1.6. Matrice de coûts
Maintenant que les rôles sont déterminés, l'objectif est d'affecter pour chacun un joueur. C'est un classique d'optimisation combinatoire, connu sous le terme de problème d'affectation.
La programmation linéaire est une technique éprouvée pour résoudre ce problème. L'idée est de représenter le problème par une matrice \(C\) de pertinence (ou de coût si minimisation), où chaque élément \(C_{i,j}\) représente la pertinence d'attribuer le rôle \(i\) au joueur \(j\).
Nous avons besoin de créer la matrice \(C\) ainsi que des labels de lignes rows et de colonnes cols faisant le lien entre les index \(i\) et \(j\) et les noms de rôles/joueurs.
def matrice_cout(roles: pd.DataFrame, roster: pd.DataFrame) -> Tuple[List[str], List[str], NDArrayInt]: cols = [k for k, v in roles.items() for _ in range(v)] rows = roster.index C = np.zeros((len(rows), len(cols)), dtype=int) for i, r in enumerate(rows): for j, c in enumerate(cols): C[i, j] = int(roster.at[r, c]) return rows, cols, C
1.7. Résolution
Puis, il faut trouver la matrice d'assignation \(X\) qui va maximiser la pertinence totale de la manière suivante :
\[max \sum_{i} \sum_{j} C_{i,j} X_{i,j}\]
Nous utiliserons pour cela la fonction linear_sum_assignment de Scipy. Ensuite, nous créons un dictionnaire avec les assignations choisies.
def solve(rows: List[str], cols: List[str], C: NDArrayInt) -> Dict[str, str]: row_ind, X = linear_sum_assignment(C, maximize=True) # score = C[row_ind, X].sum() return {p: cols[t] for p, t in zip(rows, X)}
1.8. Exploitation des résultats
Remplissage de la composition des groupes en fonciton des assignations calculées.
def creation_compo(assignations: Dict[str, str], strat: pd.DataFrame, roster: pd.DataFrame) -> pd.DataFrame: filed_roles = {} for k, v in assignations.items(): if v not in filed_roles: filed_roles[v] = [k] else: filed_roles[v] += [k] max_roles = {k: v.max() for k,v in roster.iteritems()} comp = strat.copy() for i, row in comp.iterrows(): for j, v in row.items(): label = '' if len(v.split(' ')) > 1: v, label = v.split(' ') if v in filed_roles and filed_roles[v]: p = filed_roles[v].pop() role_txt = f"[{comp.at[i, j]}]({config['doc']['url']})" comp.at[i, j] = f'{p} *{role_txt}* {roster.at[p, v] / max_roles[v]:.1f}' return comp
1.9. Processus complet
def build_comp(roster: pd.DataFrame, strat: pd.DataFrame, config): roles = extraction_roles(strat) assignation = solve(*matrice_cout(roles, roster)) return creation_compo(assignation, strat, roster)
1.10. Test algorithme
import json <<config>> if __name__ == '__main__': with open('config.json', 'r') as datafile: config = json.load(datafile) roster = get_test_roster() roster.to_csv('roster.csv') print(roster) strat = get_test_strat() strat.to_csv('strat.csv') print(strat) print(build_comp(roster, strat, config))
2. Bot
2.1. Concept :
Le bot à pour objectif de générer une composition d'armée à partir des 50 joueurs sélectionnés aléatoirement par le jeu.
2.2. Configuration
La configuration du bot passera par un fichier json unique, contenant le corps des différents messages à transmettre ainsi que les adresses des Gdoc à lire.
{ "ids": { "role": 917546013248606238, "leads": 928316217297612810 }, "doc":{ "url": "https://virgile-dauge.github.io/invabot/" }, "imgs": { "layout": "https://cdn.discordapp.com/attachments/917547596640296990/922901028519706674/layout.png" }, "embeds": { "panneau": { "title": "Confirme ta séléction à l'invasion de", "color": 2003199, "description": "Réagis avec :ballot_box_with_check: à ce message **uniquement si tu es déjà dans le fort**. Attention, retourne vite en jeu, l'auto-AFK kick est rapide (2min). \n\n Si tu as réagi par erreur, merci de décocher ta réaction. :wink: \n\n*(Et si vous aussi vous pensez que bakhu est la meilleure guilde)*" }, "dm": { "title": "Dis nous tout !", "color": 2003199, "description": "Tu es inscrit en invasion, mais tu n'as pas renseigné **les rôles** que tu peux jouer. Il n'est donc par conséquent pas possible de t'assigner un poste automatiquement. Allez, file remplir [ce document](" }, "change": { "title": "Tu as changé ton discord TAG :/", "color": 2003199, "description": "Il faut mettre à jour tes infos. Allez, file remplir [ce document](" }, "help": { "title": "Manuel d'utilisation d'invabot", "color": 2003199, "fields": [{"name": "Blue Team", "value": "Something", "inline" : "True"}] } }, "gdoc": { "url": "https://docs.google.com/spreadsheets/d/1FgtMvUQbLxP7qvXt2tnVwFFyX9KWnuREHu7o0RtX13M/", "strats": { "Principale": "Strat_principale", "Secours": "Strat_secours" }, "page_roster": "Roster", "form": "https://forms.gle/G9LizybajjVxCw6JA" } }
2.3. Utilitaires
2.3.1. Licence
# This file is part of Invabot. # Invabot is free software: you can redistribute it # and/or modify it under the terms of the GNU General # Public License as published by the Free Software # Foundation, either version 3 of the License, or # any later version. # Invabot is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the # implied warranty of MERCHANTABILITY or FITNESS FOR A # PARTICULAR PURPOSE. See the GNU General Public License # for more details. # You should have received a copy of the GNU General # Public License along with Invabot. If not, see # <https://www.gnu.org/licenses/>
2.3.2. Dépendances
import json import pandas as pd from algo import build_comp
2.3.3. Chargement de la configuration du bot
Le chargement de cette config se fera pour l'instant une seule fois au démarrage du bot.
with open('config.json', 'r') as datafile: config = json.load(datafile)
2.3.4. Chargement du token
Le token discord est nécessaire pour lier le programme à l'identité Discord du bot. Il est évidemment privé et ne dois jamais être inclus dans le dépôt Git.
# Chargement du token with open('bot.token', 'r') as datafile: token = datafile.read()
2.3.5. Identifier de manière unique les utilisateurs
Il est nécessaire d'avoir un identifiant unique entre discord et le gdoc pour faire le lien. Discord gère les identifiants sous forme d'un entier appelé snowflake. Toutefois, il est difficilement accessible pour les joueurs, et ce sont les joueurs qui vont devoir renseigner leur identifiant unique sur le gdoc. Il est donc plus simple d'utiliser ici le discord tag, par exemple `Virgile#1234`.
Je propose ici une rapide fonction d'aide pour récupérer le dtag depuis un objet discord.User :
def dtag(user): return f'{user.name}#{user.discriminator}'
2.3.6. Récupération du roster
def get_roster(config): url = f"{config['gdoc']['url']}gviz/tq?tqx=out:csv&sheet={config['gdoc']['page_roster']}" #return pd.read_csv(url).iloc[1:, 0:6].dropna().set_index('dtag') return pd.read_csv(url).set_index('dtag')
Pseudo 0 1 2 3
dtag
Chopekk#1234 Chopekk DPS-Debuff Mousquet Heal Anti-trash
Virgile#2345 virgilio Mousquet Anti-trash Répa Arti
Virgile#3456 Tezig Lance-flamme Anti-trash Arc Arti
Carlito#4567 Slua Anti-trash Arti Répa Aucun
2.3.7. Récupération de la strat
def get_strat(config, strat=None): if strat is None: url = f"{config['gdoc']['url']}gviz/tq?tqx=out:csv&sheet={config['gdoc']['page_strat']}" else: url = f"{config['gdoc']['url']}gviz/tq?tqx=out:csv&sheet={config['gdoc']['page_strat']}" return pd.read_csv(url).iloc[0:5, 0:12]
Groupe 1 Groupe 2 Groupe 3 Groupe 4 Groupe 5 Groupe 6 Groupe 7 Groupe 8 Groupe 9 Groupe 10
0 Anti-Trash Arc Anti-Trash Arc Anti-Trash Mousquet Anti-Trash Mousquet Répa Lance-flamme
1 Heal Arc Heal Arc Heal Mousquet Heal Mousquet Répa Lance-flamme
2 DPS-Debuff Arc DPS-Debuff Arc DPS-Debuff Mousquet DPS-Debuff Mousquet Répa Lance-flamme
3 DPS-Debuff Arc DPS-Debuff Arc DPS-Debuff DPS-Debuff DPS-Debuff DPS-Debuff Répa Lance-flamme
4 Arti Arti Arti Arti Arti Arti Arti Arti Répa Lance-flamme
2.4. Corp
import pandas as pd from algo import build_comp import disnake from disnake.ext import commands from disnake import Embed, Emoji import re <<dtag>> <<get_roster>> <<config>> def list_to_field(l): f = 'aucun' if l: f = '' for u in l: f += u + '\n' if len(f)>1023: return f[:1000]+'...' return f def unique(l): u = [] for r in l: if r not in u: u +=[r] return u def main(): bot = commands.Bot( intents=disnake.Intents().all(), test_guilds=[906630964703289434], # Optional sync_commands_debug=True ) <<data>> <<slash_transfert>> <<slash_invasion>> <<slash_instance>> <<slash_verif>> <<slash_collecte>> <<check_user>> <<update_instance_add>> <<update_instance_rm>> <<on_reaction>> <<on_reaction_rm>> <<on_voice_state_update>> bot.run(token=open("bot.token").read()[:-1]) if __name__ == "__main__": main()
2.5. Slash commands
2.5.1. Commande invasion
villes = ["Bief de Nérécaille", "Boisclair", "Eaux Fétides", "Gré du vent", "Ile des Lames", "Levant", "Haute-Chute", "Marais des Trames", "Rive tourmentée", "Val des Larmes", "Falaise du roy"] async def autocomp_villes(inter: disnake.ApplicationCommandInteraction, user_input: str): return [ville for ville in villes if ville.lower().startswith(user_input.lower())] async def tags_from_id(ctx, msg_id): msg = await ctx.channel.fetch_message(msg_id) selected = await msg.reactions[0].users().flatten() return [dtag(u) for u in selected][1:] class CompTrigger(disnake.ui.View): options = [disnake.SelectOption(label=k, value=v) for k, v in config['gdoc']['strats'].items()] options[0].default = True def __init__(self, origin, **kwargs): super().__init__(**kwargs) self.origin = origin self.strat = config['gdoc']['strats']['Principale'] @disnake.ui.select(options=options) async def compo(self, select: disnake.ui.Select, inter: disnake.MessageInteraction): self.strat=select.values[0] await inter.response.defer() @disnake.ui.button(label='Composition', style=disnake.ButtonStyle.blurple) async def trigger(self, button: disnake.ui.Button, inter: disnake.MessageInteraction): #await interaction.delete_original_message() await inter.response.defer() self.stop() @bot.slash_command( scope=906630964703289434, ) async def invasion( ctx: disnake.ApplicationCommandInteraction, ville: str=commands.Param(autocomplete=autocomp_villes)): """ Génére un tableau d'inscription dans le channel actuel Parameters ---------- ville: La ville où se déroule l'invasion """ embed = Embed( title=f'Invasion de {ville}', color=2003199, description="Réagis avec :ballot_box_with_check: à ce message **uniquement si tu es déjà dans le fort**. Attention, retourne vite en jeu, l'auto-AFK kick est rapide (2min). \n\n Si tu as réagi par erreur, merci de décocher ta réaction. :wink:", ) embed.set_footer(text='Invasion') # Envoi du message et ajout de la réaction await ctx.send(embed=embed) msg = await ctx.original_message() await msg.add_reaction('☑') # Création du vocal invasions_cat = 906648457824051252 category = disnake.utils.find(lambda c: c.id == invasions_cat, ctx.guild.categories) await category.create_voice_channel(f'🔩 Réparations {ville}') await category.create_voice_channel(f'{ville}', user_limit=99) # Envoi d'un message caché à l'invocateur trigger = CompTrigger(msg.id, timeout=10*60) await ctx.send("Calcul", view=trigger, ephemeral=True) # Attente du déclanchement pas l'invocateur await trigger.wait() # Récupération du message originel complet msg = await ctx.channel.fetch_message(msg.id) selected = await msg.reactions[0].users().flatten() selected_tags = [dtag(u) for u in selected if dtag(u) != 'Invasion#5489'] # Récupération des donénes du Gdoc roster roster = get_roster(config) # Joueurs séléctionnés n'ayant pas rempli le Gdoc not_registered = [u for u in selected_tags if u not in roster.index] # Filtrage du roster avec les joueurs séléctionnés roster = roster.filter(items=selected_tags, axis=0).set_index('Pseudo IG') strat=trigger.strat # Récupération de la strat url = f"{config['gdoc']['url']}gviz/tq?tqx=out:csv&sheet={strat}" page = pd.read_csv(url) strat_df = page.iloc[0:5, 0:10] img_url = page.iat[5, 1] comp = build_comp(roster, strat_df, config) def gen_embed(comp): e = Embed(title="Composition d'armée") for k, v in comp.iteritems(): f = '' for p in v.to_list(): f += str(p) + '\n' e.add_field(name=k, value=f, inline=True) return e embed = gen_embed(comp) embed.title = f"Invasion de {ville}, {strat} :" #embed.set_footer(text=ctx.author.name, icon_url = ctx.author.avatar_url) embed.color = 2003199 embed.set_thumbnail(url=img_url) await ctx.send(embed=embed, delete_after=60*25)
2.5.2. Commande verif
@bot.slash_command( description="Vérification du Gdoc et mise à jour des rôles", scope=906630964703289434, ) async def verif(ctx): """ Attribue les rôles """ guild = ctx.guild lead_role = guild.get_role(config["ids"]["leads"]) # Récupération des donénes du Gdoc roster roster_df = get_roster(config) roster = list(roster_df.index) members = {dtag(m): m for m in guild.members} role = guild.get_role(config["ids"]["role"]) verified = [dtag(m) for m in role.members] added = [] wrong = [] for u in roster: if u not in members: wrong += [u] elif u not in verified: if role is None: # Make sure the role still exists and is valid. return try: # Finally, add the role. await members[u].add_roles(role) added += [u] except disnake.HTTPException: # If we want to do something in case of errors we'd do it here. pass removed = [] for u in verified: if u not in roster: try: # Finally, remove the role. await members[u].remove_roles(role) removed += [u] # Send DM to user embed = Embed.from_dict(config["embeds"]["change"]) embed.description += f'{config["gdoc"]["form"]}) ***!***' await members[u].send(embed=embed) except disnake.HTTPException: # If we want to do something in case of errors we'd do it here. pass def add_IG(l, roster_df): return [f"{i} | {roster_df.loc[i]['Pseudo IG']}" for i in l] wrong = add_IG(wrong, roster_df) added = add_IG(added, roster_df) embed = Embed() embed.title = f'Vérification du gdoc' embed.color = 2003199 #embed.set_footer(text=ctx.author.name, icon_url = ctx.author.avatar_url) embed.add_field(name="Joueurs nouvellement vérifiés", value=list_to_field(added), inline=False) embed.add_field(name="Discord tag dans le Gdoc ne correspondant à aucun membre du discord", value=list_to_field(wrong), inline=False) embed.add_field(name=f"Joueurs ayant changé de Discord tag (rôle {role} supprimé)", value=list_to_field(removed), inline=False) await ctx.channel.send(embed=embed, delete_after=20*60) #print(added, wrong)
2.5.3. Commande transfert
async def autocomp_sources(ctx: disnake.ApplicationCommandInteraction, user_input: str): voices = [v for v in ctx.guild.voice_channels if v.members] return [v.name for v in voices if v.name.lower().startswith(user_input.lower())] async def autocomp_destinations(ctx: disnake.ApplicationCommandInteraction, user_input: str): voices = ctx.guild.voice_channels return [v.name for v in voices if v.name.lower().startswith(user_input.lower())] #return [ville for ville in villes if user_input.lower() in ville] @bot.slash_command() async def transfert(ctx: disnake.ApplicationCommandInteraction, source: str=commands.Param(autocomplete=autocomp_sources), destination: str=commands.Param(autocomplete=autocomp_destinations)): """Déplace les joueurs d'un salon vocal source à un salon destinatoin Parameters ---------- source: La ville où se déroule l'invasion destination: La ville où se déroule l'invasion """ voices = {v.name: v for v in ctx.guild.voice_channels} dest = voices[destination] users = voices[source].members await ctx.send(content=f'{len(users)} Utilisateurs vont être déplacés dans le salon ***{destination}***', ephemeral=True) [await u.move_to(dest) for u in users]
2.5.4. Commande Instance
donjons = ["Lazarus", "Gènese", "Dynastie", "Etoile", "Profondeur", "Amrine", "Tempête"] zones_elites = ["Sirène", "Palais", "Mines", "Myrk", "Malveillance"] lieux = donjons + zones_elites async def autocomp_lieux(inter: disnake.ApplicationCommandInteraction, user_input: str): return [l for l in lieux if l.lower().startswith(user_input.lower())] @bot.slash_command() async def instance(ctx: disnake.ApplicationCommandInteraction, lieu: str=commands.Param(autocomplete=autocomp_lieux), heure: str=commands.Param(default=None), prix: str=commands.Param(default=None), tanks: int=commands.Param(default=None), dps: int=commands.Param(default=None), heals: int=commands.Param(default=None), mutation: int=commands.Param(default=None)): """Crée un panneau d'inscription à une instance Parameters ---------- lieu: La ville où se déroule l'instance heure: L'heure à laquelle se déroule l'instance prix: Le prix par personne, gratuit c'est cool aussi ;) tanks: Le nombre de tanks attendus dps: Le nombre de dps attendus heals: Le nombre de heals attendus mutation: Le niveau de mutation (si mutation) """ embed = Embed() embed.title = f'Instance {lieu}' if mutation: embed.title += f' M{mutation}' embed.description = '' if heure: embed.description += f' 🕐 {heure}\n' if prix: embed.description += f' 💰 {prix}\n' embed.description += f'Proposée par {ctx.user.mention}\n' embed.color = 2003199 # Si nombre custom if tanks or dps or heals: if not tanks: tanks = 1 if not dps: dps = 3 if not heals: heals = 1 roles = ['🛡']*tanks + ['⚔']*dps + ['⛑']*heals else: roles = ['🛡', '⚔', '⚔', '⚔','⛑'] players = ['libre']*len(roles) embed.add_field(name="Rôles", value=list_to_field(roles), inline=True) embed.add_field(name="Joueurs", value=list_to_field(players), inline=True) embed.set_footer(text='Instance') # Envoi du message et ajout de la réaction await ctx.send(embed=embed) msg = await ctx.original_message() for r in unique(roles): await msg.add_reaction(r) await msg.add_reaction('✅') await msg.add_reaction('❌')
2.5.5. Commande event
import math def progress_bar(percent, full='🟩', empty='⬜'): done = math.floor(percent*10) return full* done + empty*(10-done) + ' ' + f'{percent*100:.1f}%' def display_progress(actuel, objectif): def display_num(n): s = str(n) if s.endswith('000'): s = s[:-3] + 'k' return s return display_num(actuel)+ ' ̸ ' + display_num(objectif) + ' ' + progress_bar(actuel/objectif, full='▆', empty='▁') def progress_embed(title, actuel, objectif, contrib={}, top=5): embed = disnake.Embed() embed.title = title embed.description = display_progress(actuel, objectif) contrib_str='' if contrib: s = dict(sorted(contrib.items(), key=lambda item: item[1], reverse=True)) for k, v in s.items(): contrib_str += f'{k} {v}\n' embed.add_field('Contributeurs', contrib_str) return embed class Dropdown(disnake.ui.Select): def __init__(self, **kwargs): # Set the options that will be presented inside the dropdown options = [ disnake.SelectOption( label="1", ), disnake.SelectOption( label="10", ), disnake.SelectOption( label="100", ), disnake.SelectOption( label="1000", ), ] # The placeholder is what will be shown when no option is chosen # The min and max values indicate we can only pick one of the three options # The options parameter defines the dropdown options. We defined this above super().__init__( placeholder="Valeur", min_values=1, max_values=1, options=options, **kwargs ) async def callback(self, interaction: disnake.MessageInteraction): await interaction.response.defer() class ProgressView(disnake.ui.View): def __init__(self, title, objectif): super().__init__(timeout=None) self.title = title self.actual = 0 self.objectif = objectif self.contributors = {} #self.add_item(disnake.ui.Button(style=disnake.ButtonStyle.secondary, # label=display_progress(0, objectif), # row=0, disabled=True)) #self.add_item(Dropdown(row=0)) async def update(self, val, interaction: disnake.MessageInteraction): #self.actual = self.actual + int(self.children[-1].values[0]) self.actual = self.actual + val author = interaction.author.mention if author not in self.contributors: self.contributors[author] = val else: self.contributors[author] += val await interaction.response.edit_message(embed=progress_embed(self.title, self.actual, self.objectif, self.contributors)) @disnake.ui.button(label="+1", style=disnake.ButtonStyle.green, row=0) async def ajout1(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await self.update(1, interaction) @disnake.ui.button(label="+10", style=disnake.ButtonStyle.green, row=0) async def ajout10(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await self.update(10, interaction) @disnake.ui.button(label="+100", style=disnake.ButtonStyle.green, row=0) async def ajout100(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await self.update(100, interaction) @disnake.ui.button(label="+1000", style=disnake.ButtonStyle.green, row=0) async def ajout1000(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction): await self.update(1000, interaction) @bot.slash_command() async def collecte(ctx: disnake.CommandInteraction, item: str, objectif: int): """Créé un événement Parameters ---------- item: Item désiré objectif: Qauntité totale désirée """ title = f'Collecte de {item}' #await ctx.send('coucou', view=ProgressView(objectif=objectif)) await ctx.send(embed=progress_embed(title, 0, objectif), view=ProgressView(title=title, objectif=objectif))
2.6. Gestion des réactions aux messages
CertainP des messages issus des commandes du bot nécessitent une analyse des réactions des utilisateurs.
Les réactions aux messages sont centralisées, et un callback unique est appelé dès qu'une utilisateur réagit, indépendamment du message. Or,nous ne voulons pas analyser les réactions aux messages qui ne sont pas issus d'une commande du bot.
Il nous faut donc un mécanisme pour identifier si le message attaché est d'un type intéressant et si oui, comment l'analyser. Il serait possible de stocker en mémoire tous les messages issus de commandes envoyés, mais pour me simplifier la vie je préfére embarquer cette information dans les messages.
Malheureusement, je n'ai pas trouvé de champ non utilisé dans la classe message. J'ai donc décidé de stocker le type directement dans le contenu du message. Plus particulièrement dans le footer de l'embed du message.
2.6.1. On reaction add
@bot.event async def on_reaction_add(reaction, user): if user == bot.user: return if not reaction.message.embeds: return msg_type = reaction.message.embeds[-1].footer.text if msg_type == 'Invasion': await check_user_role(reaction, user) return if msg_type == 'Instance': await update_instance(reaction, user) return
2.6.2. On reaction remove
@bot.event async def on_reaction_remove(reaction, user): if user == bot.user: return if not reaction.message.embeds: return msg_type = reaction.message.embeds[-1].footer.text if msg_type == 'Invasion': await update_invasion(reaction) return if msg_type == 'Instance': await update_instance_rm(reaction, user) return
2.6.3. On s'assure que le joueur soit enregistré
async def update_invasion(reaction): selected = await reaction.message.reactions[0].users().flatten() embed = reaction.message.embeds[-1] embed.clear_fields() embed.add_field(name="Enregistrés :", value=len(selected)-1, inline=True) await reaction.message.edit(embed=embed) async def check_user_role(reaction, user): # Récupération des donénes du Gdoc roster df = get_roster(config) if dtag(user) not in df.index: embed = Embed.from_dict(config["embeds"]["dm"]) embed.description += f'{config["gdoc"]["form"]}) ***!***' try: await user.send(embed=embed) except disnake.errors.HTTPException as e: pass else: guild = reaction.message.guild role = guild.get_role(config["ids"]["role"]) await update_invasion(reaction) await user.edit(nick=df.at[dtag(user), "Pseudo IG"]) if role is None: # Make sure the role still exists and is valid. return try: # Finally, add the role. await user.add_roles(role) except disnake.HTTPException: # If we want to do something in case of errors we'd do it here. ...
2.6.4. Gestion ajout réaction message Instance
def instance_data(reaction): fields = {e.name: e.value.split('\n') for e in reaction.message.embeds[-1].fields} return fields['Rôles'], fields['Joueurs'] async def update_instance_embed(reaction, roles, joueurs): # Récupération et mise à jour du message initial embed = reaction.message.embeds[-1] embed.clear_fields() embed.add_field(name="Rôles", value=list_to_field(roles), inline=True) embed.add_field(name="Joueurs", value=list_to_field(joueurs), inline=True) await reaction.message.edit(embed=embed) async def update_instance(reaction, user, add=True): message = reaction.message cmd_author = message.interaction.user roles, joueurs = instance_data(reaction) if reaction.emoji == '✅': if user == cmd_author: # Create vocal and swap users categories = message.guild.categories category = disnake.utils.find(lambda c: c.id == 948167052722573322, categories) voice = await category.create_voice_channel(f'Instance de {user.display_name}') ids = [int(re.sub("[^0-9]", "", j)) for j in joueurs if j != 'libre'] users = await message.guild.getch_members(ids) for u in users: if u.voice: await u.move_to(voice) else: invite = await voice.create_invite() await u.send(f"L'instance a démarré, rejoins ici : {invite} ", embed=message.embeds[-1]) await message.delete(delay=60) return if reaction.emoji == '❌': if user == cmd_author: await message.delete() return if user.mention not in joueurs: for i, r in enumerate(roles): if reaction.emoji == r and joueurs[i] == 'libre': joueurs[i] = user.mention break await update_instance_embed(reaction, roles, joueurs)
2.6.5. Gestion annulation réaction message Instance
async def update_instance_rm(reaction, user): roles, joueurs = instance_data(reaction) # Le joueur est-il séléctionné ? if user.mention in joueurs: # On le supprime de la liste des joueurs en le remplacant par libre for i, r in enumerate(roles): if joueurs[i] == user.mention and r==reaction.emoji: joueurs[i] = 'libre' break for i, r in enumerate(roles): if joueurs[i] == 'libre': disponibles = [d for d in reaction.message.reactions if d.emoji == r][-1] # On récupére les utilisateurs en prennat soint de filtrer le bot disponibles = [u for u in await disponibles.users().flatten() if u != bot.user and u.mention not in joueurs] if disponibles: joueurs[i] = disponibles[0].mention await update_instance_embed(reaction, roles, joueurs)
2.7. Gestion des changement d'état vocaux
(aka quand un user change de channel vocal)
@bot.event async def on_voice_state_update(member, before, after): instances_cat = 948167052722573322 invasions_cat = 906648457824051252 to_purge = [instances_cat, invasions_cat] invasions_waiting = 948204662086058076 instances_waiting = 948198317983146034 protected = [invasions_waiting, instances_waiting] if before.channel and before.channel.category_id in to_purge: if not before.channel.members and before.channel.id not in protected: await before.channel.delete()
3. Test ui
3.1. Modal (sorte de formulaire)
class MyModal(disnake.ui.Modal): def __init__(self) -> None: components = [ disnake.ui.TextInput( label="Name", placeholder="The name of the tag", custom_id="name", style=disnake.TextInputStyle.short, max_length=50, ), disnake.ui.TextInput( label="Description", placeholder="The description of the tag", custom_id="description", style=disnake.TextInputStyle.short, min_length=5, max_length=50, ), disnake.ui.TextInput( label="Content", placeholder="The content of the tag", custom_id="content", style=disnake.TextInputStyle.paragraph, min_length=5, max_length=1024, ), ] super().__init__(title="Create Tag", custom_id="create_tag", components=components) async def callback(self, inter: disnake.ModalInteraction) -> None: embed = disnake.Embed(title="Tag Creation") for key, value in inter.text_values.items(): embed.add_field(name=key.capitalize(), value=value, inline=False) await inter.response.send_message(embed=embed) async def on_error(self, error: Exception, inter: disnake.ModalInteraction) -> None: await inter.response.send_message("Oops, something went wrong.", ephemeral=True) @bot.slash_command() async def testmodal(ctx: disnake.CommandInteraction): """Créé un événement Parameters ---------- """ await ctx.response.send_modal(modal=MyModal())