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 :

un roster
un ensemble de joueurs représenté par une DataFrame. Chaque ligne de cette structure représente un joueur ainsi que sa compétence (un entier) pour chaque rôle prédéfini.
une stratégie
un ensemble de rôles à attribuer, représenté par une DataFrame, et structuré sous forme de groupes.

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())

Author: Virgile

Created: 2022-05-04 mer. 16:42

Validate