1
0
mirror of https://gitlab.com/ddorn/tfjm-discord-bot.git synced 2024-12-26 05:02:22 +00:00

🚧 new tirages working without save

This commit is contained in:
ddorn 2020-05-06 17:38:47 +02:00
parent 5ed508be10
commit a51f2130ec
4 changed files with 227 additions and 232 deletions

View File

@ -1,5 +1,8 @@
import asyncio
import random
import sys
import traceback
from functools import wraps
from pprint import pprint
from io import StringIO
@ -57,12 +60,14 @@ class Team:
return "\n".join(f"`{n.rjust(width)}`: {v}" for n, v in info.items())
return f""" - Accepté: {self.accepted_problems[round]}
- Refusés: {", ".join(p[0] for p in self.rejected[round]) if self.rejected[round] else "aucun"}
- Coefficient: {self.coeff(round)}
- Ordre au tirage: {self.tirage_order[round]}
- Ordre de passage: {self.passage_order[round]}
"""
#
# return f""" - Accepté: {self.accepted_problems[round]}
# - Refusés: {", ".join(p[0] for p in self.rejected[round]) if self.rejected[round] else "aucun"}
# - Coefficient: {self.coeff(round)}
# - Ordre au tirage: {self.tirage_order[round]}
# - Ordre de passage: {self.passage_order[round]}
# """
class Poule:
@ -110,6 +115,9 @@ class BaseTirage:
]
return await self.event(Event(trigram, random.choice(available)))
async def accept(self, trigram, yes: bool):
return await self.event(Event(trigram, yes))
async def next(self, typ, team=None):
while True:
event = await self.queue.get()
@ -148,6 +156,7 @@ class BaseTirage:
# TODO: avoid KeyError
if dices[event.team] is None:
dices[event.team] = event.value
await self.info_dice(event.team, event.value)
else:
await self.warn_twice(int)
@ -173,6 +182,9 @@ class BaseTirage:
return poules
async def draw_poule(self, poule):
await self.start_draw_poule(poule)
# Trigrams in draw order
trigrams = await self.draw_order(poule)
@ -184,25 +196,27 @@ class BaseTirage:
if team.accepted_problems[poule.rnd] is not None:
# The team already accepted a problem
current += 1
current %= len(teams)
continue
# Choose problem
await self.start_select_pb(team)
event = await self.next(str, team.name)
pevent = await self.next(str, team.name)
# TODO: Add check for already selected / taken by someone else
# This is not a bug for now, since it cannot happen yet
await self.info_draw_pb(team, event.value, rnd)
await self.info_draw_pb(team, pevent.value, poule.rnd)
# Accept it
accept = await self.next(bool, team.name)
if accept:
team.accepted_problems[poule.rnd] = event.value
await self.info_accepted(team, event.value)
if accept.value:
team.accepted_problems[poule.rnd] = pevent.value
await self.info_accepted(team, pevent.value)
else:
await self.info_rejected(team, event.value, rnd=poule.rnd)
team.rejected[poule.rnd].add(event.value)
await self.info_rejected(team, pevent.value, rnd=poule.rnd)
team.rejected[poule.rnd].add(pevent.value)
current += 1
current %= len(teams)
await self.annonce_poule(poule)
@ -212,7 +226,7 @@ class BaseTirage:
teams = self.poules[poule]
dices = await self.get_dices(teams)
order = sorted(self.teams, key=lambda t: dices[t], reverse=True)
order = sorted(teams, key=lambda t: dices[t], reverse=True)
await self.annonce_draw_order(order)
return order
@ -232,6 +246,9 @@ class BaseTirage:
async def start_make_poule(self, rnd):
"""Called when it starts drawing the poules for round `rnd`"""
async def start_draw_poule(self, poule):
"""Called when we start a poule."""
async def start_draw_order(self, poule):
"""Called when we start to draw the order."""
@ -253,6 +270,9 @@ class BaseTirage:
async def info_finish(self):
"""Called when the tirage has ended."""
async def info_dice(self, team, dice):
"""Called on a dice roll."""
async def info_draw_pb(self, team, pb, rnd):
"""Called when a team draws a problem."""
@ -262,3 +282,7 @@ class BaseTirage:
async def info_rejected(self, team, pb, rnd):
"""Called when a team rejects a problem,
before it is added to the rejected set."""
def setup(_):
pass

View File

@ -18,6 +18,7 @@ from src.constants import *
from src.core import CustomBot
COGS_SHORTCUTS = {
"bt": "src.base_tirage",
"c": "src.constants",
"d": "tirages",
"e": "errors",

View File

@ -2,6 +2,8 @@
import asyncio
import random
import sys
import traceback
from collections import defaultdict, namedtuple
from dataclasses import dataclass
from functools import wraps
@ -15,7 +17,7 @@ from discord.ext import commands
from discord.ext.commands import group, Cog, Context
from discord.utils import get
from src.base_tirage import BaseTirage
from src.base_tirage import BaseTirage, Event
from src.constants import *
from src.core import CustomBot
from src.errors import TfjmError, UnwantedCommand
@ -34,7 +36,7 @@ Record = namedtuple("Record", ["name", "pb", "penalite"])
def delete_and_pm(f):
@wraps(f)
def wrapper(self, *args, **kwargs):
async def wrapper(self, *args, **kwargs):
await self.ctx.message.delete()
await self.ctx.author.send(
"J'ai supprimé ton message:\n> "
@ -47,6 +49,8 @@ def delete_and_pm(f):
if msg:
await self.ctx.author.send(f"Raison: {msg}")
return wrapper
def send_all(f):
@wraps(f)
@ -57,12 +61,33 @@ def send_all(f):
return wrapper
def safe(f):
@wraps(f)
async def wrapper(*args, **kwargs):
try:
return await f(*args, **kwargs)
except Exception as e:
traceback.print_tb(e.__traceback__, file=sys.stderr)
print(e)
return wrapper
class DiscordTirage(BaseTirage):
def __init__(self, ctx, *teams, fmt):
super(DiscordTirage, self).__init__(*teams, fmt=fmt)
self.ctx = ctx
self.captain_mention = get(ctx.guild.roles, name=Role.CAPTAIN).mention
def team_for(self, author):
for team in self.teams:
if get(author.roles, name=team):
return team
return None
def mention(self, trigram):
return get(self.ctx.guild.roles, name=trigram).mention
def records(self, teams, rnd):
"""Get the strings needed for show the tirage in a list of Records"""
@ -75,6 +100,36 @@ class DiscordTirage(BaseTirage):
for team in teams
]
async def dice(self, ctx, n):
self.ctx = ctx
trigram = self.team_for(ctx.author)
if trigram is None:
await self.warn_wrong_team(None, None)
elif n == 100:
await super().dice(trigram)
else:
await self.warn_unwanted(int, int)
async def rproblem(self, ctx):
self.ctx = ctx
trigram = self.team_for(ctx.author)
if trigram is None:
await self.warn_wrong_team(None, None)
else:
await super().rproblem(trigram)
async def accept(self, ctx, yes):
self.ctx = ctx
trigram = self.team_for(ctx.author)
if trigram is None:
await self.warn_wrong_team(None, None)
else:
await super().accept(trigram, yes)
@safe
@delete_and_pm
async def warn_unwanted(self, wanted: Type, got: Type):
@ -91,6 +146,8 @@ class DiscordTirage(BaseTirage):
else "Halte là ! Ce serait bien de tirer un problème d'abord... "
"et peut-être qu'il te plaira :) ",
(bool, int): "Il tirer un dé avec `!dice 100` d'abord.",
(int, int): "Il faut lancer un dé à 100 faces.",
(str, str): f"'{got}' n'est pas un problème valide.",
}
reason = texts.get((type(got), wanted))
@ -100,10 +157,12 @@ class DiscordTirage(BaseTirage):
reason = "Je sais pas, le code ne devrait pas venir ici..."
return reason
@safe
@delete_and_pm
async def warn_wrong_team(self, expected, got):
return "ce n'était pas à ton tour."
@safe
async def warn_colisions(self, collisions: List[str]):
await self.ctx.send(
f"Les equipes {french_join(collisions)} ont fait le même résultat "
@ -111,6 +170,7 @@ class DiscordTirage(BaseTirage):
"Le nouveau lancer effacera l'ancien."
)
@safe
@delete_and_pm
async def warn_twice(self, typ: Type):
@ -120,6 +180,7 @@ class DiscordTirage(BaseTirage):
print("Weird, DiscordTirage.warn_twice was called with", typ)
return "Je sais pas, le code ne devrait pas venir ici..."
@safe
@send_all
async def start_make_poule(self, rnd):
if rnd == 0:
@ -135,50 +196,82 @@ class DiscordTirage(BaseTirage):
f"afin de déterminer les poules du second tour."
)
async def start_draw_order(self, poule):
print(poule)
@safe
@send_all
async def start_draw_poule(self, poule):
yield (
f"Nous allons commencer le tirage pour la poule **{poule}** entre les "
f"équipes {french_join('**%s**' %p for p in self.poules[poule])}. Les autres équipes peuvent "
f"quitter le salon si elles le souhaitent et revenir quand elles seront mentionnées."
)
@safe
@send_all
async def start_draw_order(self, poule):
mentions = [self.mention(tri) for tri in self.poules[poule]]
yield (
f"Les capitaines de {french_join(mentions)}, vous pouvez à nouveau lancer un dé 100, "
f"qui déterminera l'ordre de tirage des problèmes. Le plus grand lancer tirera en premier "
f"les problèmes."
)
@safe
async def start_select_pb(self, team):
await self.ctx.send(f"C'est au tour de {team.mention} de choisir un problème.")
@safe
@send_all
async def annonce_poules(self, poules):
print(poules)
first = "\n".join(
f"{p}: {french_join(t)}" for p, t in poules.items() if p.rnd == 0
)
second = "\n".join(
f"{p}: {french_join(t)}" for p, t in poules.items() if p.rnd == 1
)
yield (
f"Les poules sont donc, pour le premier tour :"
f"```{first}```\n"
f"Et pour le second tour :"
f"```{second}```"
)
@safe
@send_all
async def annonce_draw_order(self, order):
order_str = "\n ".join(f"{i}) {tri}" for i, tri in enumerate(order))
yield "L'ordre de tirage des problèmes pour ce tour est donc: \n" + order_str
order_str = "\n".join(f"{i+1}) {tri}" for i, tri in enumerate(order))
yield f"L'ordre de tirage des problèmes pour ce tour est donc: ```{order_str}```"
@safe
async def annonce_poule(self, poule):
teams = [self.teams[tri] for tri in self.poules[poule]]
if len(teams) == 3:
table = """```
+-----+---------+---------+---------+
| | Phase 1 | Phase 2 | Phase 3 |
| | Pb {0.pb} | Pb {1.pb} | Pb {2.pb} |
+-----+---------+---------+---------+
| {0.name} | Déf | Rap | Opp |
+-----+---------+---------+---------+
| {1.name} | Opp | Déf | Rap |
+-----+---------+---------+---------+
| {2.name} | Rap | Opp | Déf |
+-----+---------+---------+---------+
+-----+---------+---------+---------+
| | Phase 1 | Phase 2 | Phase 3 |
| | Pb {0.pb} | Pb {1.pb} | Pb {2.pb} |
+-----+---------+---------+---------+
| {0.name} | Déf | Rap | Opp |
+-----+---------+---------+---------+
| {1.name} | Opp | Déf | Rap |
+-----+---------+---------+---------+
| {2.name} | Rap | Opp | Déf |
+-----+---------+---------+---------+
```"""
else:
table = """```
+-----+---------+---------+---------+---------+
| | Phase 1 | Phase 2 | Phase 3 | Phase 4 |
| | Pb {0.pb} | Pb {1.pb} | Pb {2.pb} | Pb {3.pb} |
+-----+---------+---------+---------+---------+
| {0.name} | Déf | | Rap | Opp |
+-----+---------+---------+---------+---------+
| {1.name} | Opp | Déf | | Rap |
+-----+---------+---------+---------+---------+
| {2.name} | Rap | Opp | Déf | |
+-----+---------+---------+---------+---------+
| {3.name} | | Rap | Opp | Déf |
+-----+---------+---------+---------+---------+
+-----+---------+---------+---------+---------+
| | Phase 1 | Phase 2 | Phase 3 | Phase 4 |
| | Pb {0.pb} | Pb {1.pb} | Pb {2.pb} | Pb {3.pb} |
+-----+---------+---------+---------+---------+
| {0.name} | Déf | | Rap | Opp |
+-----+---------+---------+---------+---------+
| {1.name} | Opp | Déf | | Rap |
+-----+---------+---------+---------+---------+
| {2.name} | Rap | Opp | Déf | |
+-----+---------+---------+---------+---------+
| {3.name} | | Rap | Opp | Déf |
+-----+---------+---------+---------+---------+
```"""
embed = discord.Embed(
@ -205,6 +298,7 @@ class DiscordTirage(BaseTirage):
await self.ctx.send(embed=embed)
@safe
@send_all
async def info_start(self):
yield (
@ -225,6 +319,7 @@ class DiscordTirage(BaseTirage):
"l'ordre de tirage pour le tour et les problèmes."
)
@safe
@send_all
async def info_finish(self):
yield "Le tirage est fini, merci à tout le monde !"
@ -235,9 +330,19 @@ class DiscordTirage(BaseTirage):
# TODO: Save it
# TODO: make them available with the api
@safe
@send_all
async def info_dice(self, team, dice):
yield f"L'équipe {team} a lancé un... {dice} :game_die:"
@safe
@send_all
async def info_draw_pb(self, team, pb, rnd):
yield (f"L'équipe {self.mention(team.name)} a tiré... **{pb}**")
if pb in team.rejected[rnd]:
await self.ctx.send(
yield (
f"Vous avez déjà refusé **{pb}**, "
f"vous pouvez le refuser à nouveau (`!non`) et "
f"tirer immédiatement un nouveau problème "
@ -245,19 +350,20 @@ class DiscordTirage(BaseTirage):
)
else:
if len(team.rejected[rnd]) >= MAX_REFUSE:
await self.ctx.send(
yield (
f"Vous pouvez accepter ou refuser **{pb}** "
f"mais si vous choisissez de le refuser, il y "
f"aura une pénalité de 0.5 sur le multiplicateur du "
f"défenseur."
)
else:
await self.ctx.send(
f"Vous pouvez accepter (`!oui`) ou refuser (`!non`) **{pb}**. "
yield (
f"Vous pouvez l'accepter (`!oui`) ou le refuser (`!non`). "
f"Il reste {MAX_REFUSE - len(team.rejected[rnd])} refus sans pénalité "
f"pour {team.mention}."
)
@safe
async def info_accepted(self, team, pb):
await self.ctx.send(
f"L'équipe {team.mention} a accepté "
@ -265,6 +371,7 @@ class DiscordTirage(BaseTirage):
f"ne peuvent plus l'accepter."
)
@safe
async def info_rejected(self, team, pb, rnd):
msg = f"{team.mention} a refusé **{pb}** "
if pb in team.rejected[rnd]:
@ -274,152 +381,7 @@ class DiscordTirage(BaseTirage):
await self.ctx.send(msg)
class Tirage(yaml.YAMLObject):
yaml_tag = "Tirage"
def __init__(self, ctx, channel, teams):
assert len(teams) in (3, 4)
self.channel: int = channel
self.teams = [Team(team) for team in teams]
self.phase = TirageOrderPhase(self, round=0)
async def update_phase(self, ctx):
if self.phase.finished():
next_class = await self.phase.next(ctx)
if next_class is None:
await self.end(ctx)
else:
# Continue on the same round.
# If a Phase wants to change the round
# it needs to change its own round.
self.phase = next_class(self, self.phase.round)
await self.phase.start(ctx)
async def end(self, ctx):
self.phase = None
if False:
# Allow everyone to send messages again
send = discord.PermissionOverwrite() # reset
await ctx.channel.edit(overwrites={ctx.guild.default_role: send})
tl = {}
if File.TIRAGES.exists():
with open(File.TIRAGES) as f:
tl = yaml.load(f)
else:
File.TIRAGES.touch()
key = max(0, *tl.keys()) + 1
tl[key] = self
with open(File.TIRAGES, "w") as f:
yaml.dump(tl, f)
await ctx.send(
f"A tout moment, ce rapport peut " f"être envoyé avec `!draw show {key}`"
)
from src.tfjm_discord_bot import tirages
if self.channel in tirages:
del tirages[self.channel]
async def show_tex(self, ctx):
if len(self.teams) == 3:
table = r"""
\begin{{table}}[]
\begin{{tabular}}{{|c|c|c|c|}}
\hline
& Phase 1 - {0.pb} & Phase 2 - {1.pb} & Phase {2.pb} \\\\ \hline
{0.name} & Déf & Rap & Opp \\ \hline
{1.name} & Opp & Déf & Rap \\ \hline
{2.name} & Rap & Opp & Déf \\ \hline
\end{{tabular}}
\end{{table}}
"""
else:
table = r"""
\begin{{table}}[]
\begin{{tabular}}{{|c|c|c|c|c|}}
\hline
& Phase 1 - {0.pb} & Phase 2 - {1.pb} & Phase 3 - {2.pb} & Phase 4 - {3.pb} \\\\ \hline
{0.name} & Déf & & Rap & Opp \\ \hline
{1.name} & Opp & Déf & & Rap \\ \hline
{2.name} & Rap & Opp & Déf & \\ \hline
{3.name} & & Rap & Opp & Déf \\ \hline
\end{{tabular}}
\end{{table}}
"""
msg = ",tex "
for i in (0, 1):
msg += rf"\section{{ {ROUND_NAMES[i].capitalize()} }}"
msg += table.format(*self.records(i))
await ctx.send(msg)
class Phase:
...
class OrderPhase(Phase):
def __init__(self, tirage, round, name, order_name, reverse=False):
super().__init__(tirage, round)
self.name = name
self.reverse = reverse
self.order_name = order_name
def order_for(self, team):
return getattr(team, self.order_name)[self.round]
def set_order_for(self, team, order):
getattr(team, self.order_name)[self.round] = order
async def dice(self, ctx, author, dice):
team = self.team_for(author)
if self.order_for(team) is None:
self.set_order_for(team, dice)
await ctx.send(f"L'équipe {team.mention} a obtenu... **{dice}**")
else:
raise UnwantedCommand("tu as déjà lancé un dé !")
def finished(self) -> bool:
return all(self.order_for(team) is not None for team in self.teams)
async def next(self, ctx) -> "Type[Phase]":
orders = [self.order_for(team) for team in self.teams]
if len(set(orders)) == len(orders):
# All dice are different: good
self.teams.sort(key=self.order_for, reverse=self.reverse)
await ctx.send(
f"L'ordre {self.name} pour ce tour est donc :\n"
" - "
+ "\n - ".join(
f"{team.mention} ({self.order_for(team)})" for team in self.teams
)
)
return self.NEXT
else:
# Find dice that are the same
count = defaultdict(list)
for team in self.teams:
count[self.order_for(team)].append(team)
re_do = []
for teams in count.values():
if len(teams) > 1:
re_do.extend(teams)
teams_str = ", ".join(team.mention for team in re_do)
for team in re_do:
self.set_order_for(team, None)
# We need to do this phase again.
return self.__class__
class TiragePhase(Phase):
class TiragePhase:
"""The phase where captains accept or refuse random problems."""
def __init__(self, tirage, round=0):
@ -654,6 +616,15 @@ class TirageCog(Cog, name="Tirages"):
dice = random.randint(1, n)
return f"{ctx.author.mention} : {Emoji.DICE} {dice}"
@commands.command(name="dice-all", aliases=["da"], hidden=True)
@commands.has_role(Role.DEV)
async def dice_all_cmd(self, ctx, *teams):
channel = ctx.channel.id
if channel in self.tirages:
for t in teams:
d = random.randint(1, 100)
await self.tirages[channel].event(Event(t, d))
@commands.command(
name="random-problem",
aliases=["rp", "problème-aléatoire", "probleme-aleatoire", "pa"],
@ -663,7 +634,7 @@ class TirageCog(Cog, name="Tirages"):
channel = ctx.channel.id
if channel in self.tirages:
await self.tirages[channel].choose_problem(ctx)
await self.tirages[channel].rproblem(ctx)
else:
problem = random.choice(PROBLEMS)
await ctx.send(f"Le problème tiré est... **{problem}**")
@ -712,7 +683,7 @@ class TirageCog(Cog, name="Tirages"):
name="start", usage="équipe1 équipe2 équipe3 (équipe4)",
)
@commands.has_any_role(*Role.ORGAS)
async def start(self, ctx: Context, *teams: discord.Role):
async def start(self, ctx: Context, fmt, *teams: discord.Role):
"""
(orga) Commence un tirage avec 3 ou 4 équipes.
@ -730,28 +701,25 @@ class TirageCog(Cog, name="Tirages"):
"il est possible d'en commencer un autre sur une autre channel."
)
if len(teams) not in (3, 4):
try:
fmt = list(map(int, fmt.split("+")))
except ValueError:
raise TfjmError(
"Il faut 3 ou 4 équipes pour un tirage. "
"Exemple: `!draw start @AAA @BBB @CCC`"
"Le premier argument doit être le format du tournoi, "
"par exemple `3+3` pour deux poules à trois équipes"
)
if not set(fmt).issubset({3, 4}):
raise TfjmError("Seuls les poules à 3 ou 4 équipes sont suportées.")
# Here all data should be valid
# Prevent everyone from writing except Capitaines, Orga, CNO, Benevole
if False:
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(Role.CAPTAIN): send,
r(Role.BENEVOLE): send,
}
await channel.edit(overwrites=overwrites)
self.tirages[channel_id] = DiscordTirage(ctx, *teams, fmt=fmt)
await self.tirages[channel_id].run()
self.tirages[channel_id] = Tirage(ctx, channel_id, teams)
await self.tirages[channel_id].phase.start(ctx)
if self.tirages[channel_id]:
# Check if aborted in an other way
del self.tirages[channel_id]
@draw_group.command(name="abort")
@commands.has_any_role(*Role.ORGAS)
@ -768,32 +736,32 @@ class TirageCog(Cog, name="Tirages"):
if channel_id in self.tirages:
print(self.tirages, channel_id)
print(self.tirages[channel_id])
await self.tirages[channel_id].end(ctx)
del self.tirages[channel_id]
await ctx.send("Le tirage est annulé.")
else:
await ctx.send("Il n'y a pas de tirage en cours.")
@draw_group.command(name="skip", aliases=["s"])
@commands.has_role(Role.DEV)
async def draw_skip(self, ctx, *teams: discord.Role):
"""(dev) Passe certaines phases du tirage."""
channel = ctx.channel.id
self.tirages[channel] = tirage = Tirage(ctx, channel, teams)
#
# @draw_group.command(name="skip", aliases=["s"])
# @commands.has_role(Role.DEV)
# async def draw_skip(self, ctx, *teams: discord.Role):
# """(dev) Passe certaines phases du tirage."""
# channel = ctx.channel.id
# self.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)
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)
def get_tirages(self) -> Dict[int, Tirage]:
def get_tirages(self) -> Dict[int, BaseTirage]:
if not File.TIRAGES.exists():
return {}
@ -812,6 +780,7 @@ class TirageCog(Cog, name="Tirages"):
`!draw show 42` - Affiche le tirage n°42
"""
return
tirages = self.get_tirages()
if not tirages:

View File

@ -5,6 +5,7 @@ from discord.ext.commands import Bot
def french_join(l):
l = list(l)
start = ", ".join(l[:-1])
return f"{start} et {l[-1]}"