├── cogs
├── __init__.py
├── utils
│ └── chat_formatting.py
└── dev_commands.py
├── data
├── stream.json
├── tournoi.json
└── participants.json
├── config
├── auto_mode.yml
├── gamelist.yml
├── preferences.yml
└── config.yml
├── docker
└── run.sh
├── requirements.txt
├── .gitignore
├── docker-compose.yml
├── utils
├── json_stream.py
├── stream.py
├── game_specs.py
├── http_retry.py
├── json_hooks.py
├── rounds.py
├── command_checks.py
├── get_config.py
├── seeding.py
├── raw_texts.py
└── logging.py
├── Dockerfile
├── LICENSE
├── README.md
└── bot.py
/cogs/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/data/stream.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/data/tournoi.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/data/participants.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/config/auto_mode.yml:
--------------------------------------------------------------------------------
1 | My tournament:
2 | edition:
3 | capping:
4 | game:
5 | days:
6 | -
7 | start:
8 | description:
9 |
--------------------------------------------------------------------------------
/docker/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | chown -R $UID:$GID /bot
3 | if [ ! -z $TZ ]; then cp /usr/share/zoneinfo/$TZ /etc/localtime; fi
4 | exec su-exec $UID:$GID /sbin/tini -- python3 /bot/bot.py
5 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiofiles==0.5.*
2 | aiohttp[speedups]==3.6.*
3 | APScheduler==3.6.*
4 | apychal==1.9.*
5 | Babel==2.8.*
6 | discord.py==1.4.*
7 | python-dateutil==2.8.*
8 | PyYAML==5.3.*
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/settings.json
2 | utils/__pycache__/
3 | config/config.yml
4 | data/participants.json
5 | data/stream.json
6 | data/tournoi.json
7 | data/logs/atos.log
8 | data/logs/latest.log
9 | data/logs/previous.log
10 |
--------------------------------------------------------------------------------
/config/gamelist.yml:
--------------------------------------------------------------------------------
1 | Game 1:
2 | ruleset:
3 | role:
4 | # role_1v1:
5 | icon:
6 | # icon_1v1:
7 | # ranking:
8 | # league_name:
9 | # league_id:
10 | ban_instruction:
11 | starters:
12 | - foo
13 | # counterpicks:
14 | # - bar
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | atos:
5 | build: .
6 | container_name: atos
7 | stop_grace_period: 60s
8 | restart: unless-stopped
9 | environment:
10 | - TZ=Europe/Berlin
11 | volumes:
12 | - ./config:/bot/config
13 | - ./data:/bot/data
14 |
--------------------------------------------------------------------------------
/utils/json_stream.py:
--------------------------------------------------------------------------------
1 | import json
2 | from utils.json_hooks import int_keys
3 | from utils.get_config import participants_path
4 |
5 | with open(participants_path, 'r+') as f:
6 | participants = json.load(f, object_pairs_hook=int_keys)
7 |
8 | def dump_participants():
9 | with open(participants_path, 'w') as f:
10 | json.dump(participants, f, indent=4)
--------------------------------------------------------------------------------
/config/preferences.yml:
--------------------------------------------------------------------------------
1 | auto_mode: False
2 | bulk_mode: False
3 | check_channel_presence: 15
4 | check_in_closing: 15 # MINUTES before tournament
5 | check_in_opening: 60 # MINUTES before tournament
6 | full_bo3: False
7 | full_bo5: False # Has priority over full_bo3
8 | inscriptions_closing: 10 # MINUTES before tournament
9 | inscriptions_opening: 30 # HOURS before tournament, useful for auto-mode ONLY
10 | reaction_mode: True
11 | restrict_to_role: False
12 | start_bo5: 0 # When to start BO5 (0 = top 8, +1 = top 6, -1 = top 12, etc.)
13 | use_guild_name: True
--------------------------------------------------------------------------------
/utils/stream.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from utils.get_config import *
4 | from utils.json_hooks import int_keys
5 |
6 | def is_on_stream(suggested_play_order):
7 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
8 | return suggested_play_order in [stream[x]['on_stream'] for x in stream]
9 |
10 | def is_queued_for_stream(suggested_play_order):
11 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
12 | return suggested_play_order in sum([stream[x]['queue'] for x in stream], [])
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8-alpine
2 |
3 | ENV UID=1000 GID=1000
4 |
5 | WORKDIR /bot
6 |
7 | COPY requirements.txt .
8 |
9 | RUN apk -U upgrade \
10 | && apk add build-base git libffi-dev su-exec tini tzdata \
11 | && pip3 install --no-cache -r requirements.txt \
12 | && apk del build-base git libffi-dev && rm -rf /var/cache/apk/*
13 |
14 | COPY docker/run.sh /usr/local/bin/run.sh
15 |
16 | COPY bot.py .
17 | COPY utils ./utils
18 | COPY cogs ./cogs
19 |
20 | RUN chmod +x /usr/local/bin/run.sh
21 |
22 | VOLUME /bot/data /bot/config
23 |
24 | CMD ["run.sh"]
25 |
--------------------------------------------------------------------------------
/utils/game_specs.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from utils.get_config import *
4 | from utils.json_hooks import dateconverter, dateparser, int_keys
5 |
6 | ### Accès stream
7 | def get_access_stream(access):
8 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
9 |
10 | if tournoi['game'] == 'Project+':
11 | return f":white_small_square: **Accès host Dolphin Netplay** : `{access[0]}`"
12 |
13 | elif tournoi['game'] == 'Super Smash Bros. Ultimate':
14 | return f":white_small_square: **ID** : `{access[0]}`\n:white_small_square: **MDP** : `{access[1]}`"
15 |
--------------------------------------------------------------------------------
/utils/http_retry.py:
--------------------------------------------------------------------------------
1 | # Retry operation if timeout
2 | # Also asynchronize operation
3 | import asyncio
4 | from achallonge import ChallongeException
5 |
6 | async def async_http_retry(func, *args, **kwargs):
7 | for retry in range(3):
8 | try:
9 | return await func(*args, **kwargs)
10 | except ChallongeException as e:
11 | if "504" in str(e):
12 | await asyncio.sleep(1+retry)
13 | else:
14 | raise
15 | except asyncio.exceptions.TimeoutError:
16 | continue
17 | else:
18 | raise ChallongeException(f"Tried '{func.__name__}' several times without success")
--------------------------------------------------------------------------------
/utils/json_hooks.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | ### De-serialize & re-serialize datetime objects for JSON storage
4 | def dateconverter(o):
5 | if isinstance(o, datetime):
6 | return o.__str__()
7 |
8 | def dateparser(dct):
9 | for k, v in dct.items():
10 | try:
11 | dct[k] = datetime.strptime(v, "%Y-%m-%d %H:%M:%S")
12 | except:
13 | pass
14 | return dct
15 |
16 | ### Get int keys !
17 | def int_keys(ordered_pairs):
18 | result = {}
19 | for key, value in ordered_pairs:
20 | try:
21 | key = int(key)
22 | except ValueError:
23 | pass
24 | result[key] = value
25 | return result
26 |
--------------------------------------------------------------------------------
/config/config.yml:
--------------------------------------------------------------------------------
1 | # version 5.20
2 | system:
3 | debug: True
4 | greet_new_members: True
5 | manage_game_roles: True
6 | show_unknown_command: True
7 | language: fr_FR # only fr_FR is supported right now
8 |
9 | paths: # can be left as default values
10 | tournoi: data/tournoi.json
11 | participants: data/participants.json
12 | stream: data/stream.json
13 | ranking: data/ranking.csv
14 | gamelist: config/gamelist.yml
15 | auto_mode: config/auto_mode.yml
16 | preferences: config/preferences.yml
17 |
18 | discord:
19 | secret:
20 | guild:
21 | prefix:
22 | channels:
23 | blabla:
24 | annonce:
25 | check_in:
26 | inscriptions:
27 | inscriptionsvip:
28 | scores:
29 | stream:
30 | queue:
31 | tournoi:
32 | deroulement:
33 | faq:
34 | resultats:
35 | roles:
36 | to:
37 | categories:
38 | tournoi:
39 | roles:
40 | challenger:
41 | to:
42 | streamer:
43 | emojis:
44 | logo:
45 |
46 | challonge:
47 | user:
48 | api_key:
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Wonderfall
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/utils/rounds.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from utils.get_config import *
4 | from utils.json_hooks import dateconverter, dateparser, int_keys
5 |
6 | ### Determine whether a match is top 8 or not
7 | def is_top8(match_round):
8 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
9 | return (match_round >= tournoi["round_winner_top8"]) or (match_round <= tournoi["round_looser_top8"])
10 |
11 | ### Determine whether a match is top 8 or not
12 | def is_bo5(match_round):
13 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
14 | if tournoi["full_bo3"]:
15 | return False
16 | elif tournoi["full_bo5"]:
17 | return True
18 | else:
19 | return (match_round >= tournoi["round_winner_bo5"]) or (match_round <= tournoi["round_looser_bo5"])
20 |
21 | ### Retourner nom du round
22 | def nom_round(match_round):
23 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
24 | max_round_winner = tournoi["round_winner_top8"] + 2
25 | max_round_looser = tournoi["round_looser_top8"] - 3
26 |
27 | if match_round > 0:
28 | if match_round == max_round_winner:
29 | return "Grand Final"
30 | elif match_round == max_round_winner - 1:
31 | return "Winners Final"
32 | elif match_round == max_round_winner - 2:
33 | return "Winners Semi-Final"
34 | elif match_round == max_round_winner - 3:
35 | return "Winners Quarter-Final"
36 | else:
37 | return f"Winners Round {match_round}"
38 |
39 | elif match_round < 0:
40 | if match_round == max_round_looser:
41 | return "Losers Final"
42 | elif match_round == max_round_looser + 1:
43 | return "Losers Semi-Final"
44 | elif match_round == max_round_looser + 2:
45 | return "Losers Quarter-Final"
46 | else:
47 | return f"Losers Round {-match_round}"
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A.T.O.S. - Automated Tournament Organizer for Smash
2 |
3 | :warning: **A.T.O.S. is now evolving thanks to the effort of El Laggron! It should be faster, cleaner, and can be invited to several servers. It's now a [cog](https://github.com/retke/Laggrons-Dumb-Cogs) that can be a part of a [Red-Discord bot](https://github.com/Cog-Creators/Red-DiscordBot) instance. [More info here](https://atos.laggron.red/).**
4 |
5 |
6 |
7 |
8 |
9 | ## What?
10 | It stands for *Automated Tournament Organizer for Smash*, or more precisely *Automation Tools for Organizing Smash*. It's designed to run on a Discord server and it can *take it from here* by managing a Smash tournament with Challonge for the bracket stuff.
11 |
12 | While the code should be somewhat working, **keep in mind I'm not a dev** (at least I don't consider myself to be one), just a smasher with some CS skills doing that as a hobby! You're free to fork, to contribute, and to use my work in whatever way you want.
13 |
14 | ## Main requirements
15 | - `discord.py` (rewrite) : [powerful API wrapper for Discord](https://github.com/Rapptz/discord.py), credits to Danny.
16 | - `apychal` : [asynchronous fork](https://github.com/Wonderfall/apychal) of `pychal` made by myself, credits to ZEDGR.
17 |
18 | ## What does it do?
19 | - Registration
20 | - Check-in
21 | - Waiting list
22 | - Match queue
23 | - Unique channel for each set
24 | - Stream queue
25 | - Score detection
26 | - Reminders
27 | - Auto-DQ
28 | - Announcements (including results)
29 | - Greeting new members (optional)
30 | - Roles managing (optional)
31 | - Multi-game supports (Smash-focused)
32 | - Seeding (based on a braacket ranking)
33 | - Automatic tournament creation
34 | - Bunch of useful commands
35 |
36 | Did I forget something?
37 |
38 | ## Can I use it?
39 | Its primary use is, as you can see, for a french server which is organizing online weeklies for Smash Bros. Ultimate. You can use it for your own, you can enhance it, that's your call. *I'm not responsible for a thermonuclear warfare in your Discord server because you installed it!*
40 |
41 | ## How to install
42 | I'll provide some documentation as soon as I can. Take care!
43 |
44 | ## Thanks
45 | *Smash Strasbourg community (especially), Smash Void, kheyFC. And more...*
46 |
--------------------------------------------------------------------------------
/utils/command_checks.py:
--------------------------------------------------------------------------------
1 | ### Custom Discord commands checks
2 | import json
3 | from datetime import datetime
4 | from discord.ext import commands
5 |
6 | from utils.get_config import *
7 | from utils.json_hooks import dateconverter, dateparser, int_keys
8 |
9 | # Is tournament pending?
10 | def tournament_is_pending(ctx):
11 | try:
12 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
13 | return tournoi["statut"] == "pending"
14 | except (FileNotFoundError, TypeError, KeyError):
15 | return False
16 |
17 | # Is tournament pending?
18 | def tournament_is_underway(ctx):
19 | try:
20 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
21 | return tournoi["statut"] == "underway"
22 | except (FileNotFoundError, TypeError, KeyError):
23 | return False
24 |
25 | # Is tournament pending?
26 | def tournament_is_underway_or_pending(ctx):
27 | try:
28 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
29 | return tournoi["statut"] in ["underway", "pending"]
30 | except (FileNotFoundError, TypeError, KeyError):
31 | return False
32 |
33 | # Are inscriptions still open?
34 | def inscriptions_still_open(ctx):
35 | try:
36 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
37 | return datetime.now() < tournoi["fin_inscription"]
38 | except (FileNotFoundError, TypeError, KeyError):
39 | return False
40 |
41 | # In channel?
42 | def in_channel(channel_id):
43 | async def predicate(ctx):
44 | if ctx.channel.id != channel_id:
45 | await ctx.send(f"<@{ctx.author.id}> Cette commande fonctionne uniquement dans <#{channel_id}> !")
46 | return False
47 | return True
48 | return commands.check(predicate)
49 |
50 | # In combat channel?
51 | def in_combat_channel():
52 | async def predicate(ctx):
53 | if ctx.channel.category == None or ctx.channel.category.name not in ["winner bracket", "looser bracket"]:
54 | await ctx.send(f"<@{ctx.author.id}> Cette commande fonctionne uniquement dans un channel de set.")
55 | return False
56 | return True
57 | return commands.check(predicate)
58 |
59 | # Is owner or TO
60 | async def is_owner_or_to(ctx):
61 | return to_id in [y.id for y in ctx.author.roles] or await ctx.bot.is_owner(ctx.author)
62 |
63 | # Is streaming?
64 | def is_streaming(ctx):
65 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
66 | return ctx.author.id in stream
--------------------------------------------------------------------------------
/utils/get_config.py:
--------------------------------------------------------------------------------
1 | import yaml
2 |
3 | with open('config/config.yml', 'r+') as f: config = yaml.safe_load(f)
4 |
5 | ### System
6 | debug_mode = config["system"]["debug"]
7 |
8 | ### File paths
9 | tournoi_path = config["paths"]["tournoi"]
10 | participants_path = config["paths"]["participants"]
11 | stream_path = config["paths"]["stream"]
12 | gamelist_path = config["paths"]["gamelist"]
13 | auto_mode_path = config["paths"]["auto_mode"]
14 | ranking_path = config["paths"]["ranking"]
15 | preferences_path = config["paths"]["preferences"]
16 |
17 | ### Locale
18 | language = config["system"]["language"]
19 |
20 | ### Discord prefix
21 | bot_prefix = config["discord"]["prefix"]
22 |
23 | ### System preferences
24 | greet_new_members = config["system"]["greet_new_members"]
25 | manage_game_roles = config["system"]["manage_game_roles"]
26 | show_unknown_command = config["system"]["show_unknown_command"]
27 |
28 | #### Discord IDs
29 | guild_id = int(config["discord"]["guild"])
30 |
31 | ### Server channels
32 | blabla_channel_id = int(config["discord"]["channels"]["blabla"])
33 | annonce_channel_id = int(config["discord"]["channels"]["annonce"])
34 | check_in_channel_id = int(config["discord"]["channels"]["check_in"])
35 | inscriptions_channel_id = int(config["discord"]["channels"]["inscriptions"])
36 | inscriptions_vip_channel_id = int(config["discord"]["channels"]["inscriptionsvip"])
37 | scores_channel_id = int(config["discord"]["channels"]["scores"])
38 | stream_channel_id = int(config["discord"]["channels"]["stream"])
39 | queue_channel_id = int(config["discord"]["channels"]["queue"])
40 | tournoi_channel_id = int(config["discord"]["channels"]["tournoi"])
41 | resultats_channel_id = int(config["discord"]["channels"]["resultats"])
42 | roles_channel_id = int(config["discord"]["channels"]["roles"])
43 | to_channel_id = int(config["discord"]["channels"]["to"])
44 |
45 | ### Info, non-interactive channels
46 | deroulement_channel_id = int(config["discord"]["channels"]["deroulement"])
47 | faq_channel_id = int(config["discord"]["channels"]["faq"])
48 |
49 | ### Server categories
50 | tournoi_cat_id = int(config["discord"]["categories"]["tournoi"])
51 |
52 | ### Role IDs
53 | challenger_id = int(config["discord"]["roles"]["challenger"])
54 | to_id = int(config["discord"]["roles"]["to"])
55 | streamer_id = int(config["discord"]["roles"]["streamer"])
56 |
57 | ### Custom emojis
58 | server_logo = config["discord"]["emojis"]["logo"]
59 |
60 | #### Challonge
61 | challonge_user = config["challonge"]["user"]
62 |
63 | ### Tokens
64 | bot_secret = config["discord"]["secret"]
65 | challonge_api_key = config["challonge"]["api_key"]
66 |
--------------------------------------------------------------------------------
/utils/seeding.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import aiohttp
3 | import aiofiles, aiofiles.os
4 | import json
5 | import achallonge
6 | import csv
7 | from statistics import median
8 | from filecmp import cmp
9 | from pathlib import Path
10 |
11 | from utils.http_retry import async_http_retry
12 | from utils.get_config import *
13 | from utils.json_hooks import dateconverter, dateparser, int_keys
14 |
15 |
16 | async def get_ranking_csv(tournoi):
17 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
18 |
19 | async with aiohttp.ClientSession(headers={'Connection': 'close'}) as session:
20 |
21 | for page in range(1,6): # Retrieve up to 5*200 = 1000 entries (since max. CSV export is 200)
22 |
23 | url = (f"https://braacket.com/league/{gamelist[tournoi['game']]['ranking']['league_name']}/ranking/"
24 | f"{gamelist[tournoi['game']]['ranking']['league_id']}?rows=200&page={page}&export=csv")
25 |
26 | async with session.get(url) as resp:
27 | if int(resp.status) >= 400:
28 | raise ValueError("Ranking not found/accessible")
29 | async with aiofiles.open(f'{ranking_path}_{page}', mode='wb') as f:
30 | await f.write(await resp.read())
31 |
32 | if page != 1 and cmp(f'{ranking_path}_{page}', f'{ranking_path}_{page-1}'):
33 | await aiofiles.os.remove(f'{ranking_path}_{page}')
34 | break
35 |
36 |
37 | async def seed_participants(participants):
38 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
39 |
40 | ranking = {}
41 |
42 | # Open and parse the previously downloaded CSV
43 | for file in list(Path(Path(ranking_path).parent).rglob('*.csv_*')):
44 | with open(file) as f:
45 | reader = csv.DictReader(f)
46 | for row in reader:
47 | ranking[row['Player']] = int(row['Points'])
48 |
49 | # Base elo : median if ranking incomplete, put at bottom otherwise
50 | base_elo = median(list(ranking.values())) #if len(ranking) < 200 else min(list(ranking.values()))
51 |
52 | # Assign Elo ranking to each player
53 | for joueur in participants:
54 | try:
55 | participants[joueur]['elo'] = ranking[participants[joueur]['display_name']]
56 | except KeyError:
57 | participants[joueur]['elo'] = base_elo # base Elo if none found
58 |
59 | # Sort & clean & make a composite list (to avoid "414 Request-URI Too Large")
60 | sorted_participants = sorted(participants.items(), key=lambda k_v: k_v[1]['elo'], reverse=True)
61 | sorted_participants = [x[1]['display_name'] for x in sorted_participants]
62 | sorted_participants = [sorted_participants[x:x+(50)] for x in range(0, len(sorted_participants),50)]
63 |
64 | # Send to Challonge and assign IDs
65 | for chunk_participants in sorted_participants:
66 |
67 | challonge_participants = await async_http_retry(
68 | achallonge.participants.bulk_add,
69 | tournoi['id'],
70 | chunk_participants
71 | )
72 |
73 | for inscrit in challonge_participants:
74 | for joueur in participants:
75 | if inscrit['name'] == participants[joueur]['display_name']:
76 | participants[joueur]['challonge'] = inscrit['id']
77 | break
--------------------------------------------------------------------------------
/cogs/utils/chat_formatting.py:
--------------------------------------------------------------------------------
1 | from typing import Sequence, Iterator, List, Optional, Union, SupportsInt
2 | from discord.utils import escape_markdown
3 |
4 | _ = lambda x: x
5 |
6 |
7 | def escape(text: str, *, mass_mentions: bool = False, formatting: bool = False) -> str:
8 | """Get text with all mass mentions or markdown escaped.
9 | Parameters
10 | ----------
11 | text : str
12 | The text to be escaped.
13 | mass_mentions : `bool`, optional
14 | Set to :code:`True` to escape mass mentions in the text.
15 | formatting : `bool`, optional
16 | Set to :code:`True` to escpae any markdown formatting in the text.
17 | Returns
18 | -------
19 | str
20 | The escaped text.
21 | """
22 | if mass_mentions:
23 | text = text.replace("@everyone", "@\u200beveryone")
24 | text = text.replace("@here", "@\u200bhere")
25 | if formatting:
26 | text = escape_markdown(text)
27 | return text
28 |
29 |
30 | def pagify(
31 | text: str,
32 | delims: Sequence[str] = ["\n"],
33 | *,
34 | priority: bool = False,
35 | escape_mass_mentions: bool = True,
36 | shorten_by: int = 8,
37 | page_length: int = 2000,
38 | ) -> Iterator[str]:
39 | """Generate multiple pages from the given text.
40 | Note
41 | ----
42 | This does not respect code blocks or inline code.
43 | Parameters
44 | ----------
45 | text : str
46 | The content to pagify and send.
47 | delims : `sequence` of `str`, optional
48 | Characters where page breaks will occur. If no delimiters are found
49 | in a page, the page will break after ``page_length`` characters.
50 | By default this only contains the newline.
51 | Other Parameters
52 | ----------------
53 | priority : `bool`
54 | Set to :code:`True` to choose the page break delimiter based on the
55 | order of ``delims``. Otherwise, the page will always break at the
56 | last possible delimiter.
57 | escape_mass_mentions : `bool`
58 | If :code:`True`, any mass mentions (here or everyone) will be
59 | silenced.
60 | shorten_by : `int`
61 | How much to shorten each page by. Defaults to 8.
62 | page_length : `int`
63 | The maximum length of each page. Defaults to 2000.
64 | Yields
65 | ------
66 | `str`
67 | Pages of the given text.
68 | """
69 | in_text = text
70 | page_length -= shorten_by
71 | while len(in_text) > page_length:
72 | this_page_len = page_length
73 | if escape_mass_mentions:
74 | this_page_len -= in_text.count("@here", 0, page_length) + in_text.count(
75 | "@everyone", 0, page_length
76 | )
77 | closest_delim = (in_text.rfind(d, 1, this_page_len) for d in delims)
78 | if priority:
79 | closest_delim = next((x for x in closest_delim if x > 0), -1)
80 | else:
81 | closest_delim = max(closest_delim)
82 | closest_delim = closest_delim if closest_delim != -1 else this_page_len
83 | if escape_mass_mentions:
84 | to_send = escape(in_text[:closest_delim], mass_mentions=True)
85 | else:
86 | to_send = in_text[:closest_delim]
87 | if len(to_send.strip()) > 0:
88 | yield to_send
89 | in_text = in_text[closest_delim:]
90 |
91 | if len(in_text.strip()) > 0:
92 | if escape_mass_mentions:
93 | yield escape(in_text, mass_mentions=True)
94 | else:
95 | yield in_text
96 |
97 |
98 | def box(text: str, lang: str = "") -> str:
99 | """Get the given text in a code block.
100 | Parameters
101 | ----------
102 | text : str
103 | The text to be marked up.
104 | lang : `str`, optional
105 | The syntax highlighting language for the codeblock.
106 | Returns
107 | -------
108 | str
109 | The marked up text.
110 | """
111 | ret = "```{}\n{}\n```".format(lang, text)
112 | return ret
--------------------------------------------------------------------------------
/utils/raw_texts.py:
--------------------------------------------------------------------------------
1 | from utils.get_config import *
2 |
3 | ### Texts
4 | welcome_text=f"""
5 | Je t'invite à consulter le channel <#{deroulement_channel_id}>, et également <#{inscriptions_channel_id}> si tu souhaites t'inscrire à un tournoi.
6 |
7 | N'oublie pas de consulter les <#{annonce_channel_id}> régulièrement, et de poser tes questions aux TOs sur <#{faq_channel_id}>.
8 |
9 | Je te conseille de t'attribuer un rôle dans <#{roles_channel_id}> par la même occasion.
10 | Enfin, amuse-toi bien ! *Tu peux obtenir une liste de commandes avec la commande `{bot_prefix}help`.*
11 | """
12 |
13 | help_text=f"""
14 | :cd: **Commandes membre :**
15 | :white_small_square: `{bot_prefix}help` : affiche des commandes selon le statut du membre *(simple, joueur, admin, streamer)*.
16 | :white_small_square: `{bot_prefix}bracket` : obtenir le lien du bracket en cours.
17 | :white_small_square: `{bot_prefix}stream` : obtenir le ou les liens de stream du tournoi en cours.
18 | :white_small_square: `{bot_prefix}desync` : obtenir une notice d'aide en cas de desync sur Dolphin Netplay.
19 | :white_small_square: `{bot_prefix}buffer` : calcule le minimum buffer optimal pour Dolphin Netplay *(paramètre : ping)*.
20 | """
21 |
22 | challenger_help_text=f"""
23 | :video_game: **Commandes joueur :**
24 | :white_small_square: `{bot_prefix}dq` : DQ du tournoi après que celui-ci ait commencé.
25 | :white_small_square: `{bot_prefix}forfeit` : abandonner son match en cours, pour passer de winner à looser.
26 | :white_small_square: `{bot_prefix}flip` : pile/face simple, fonctionne dans tous les channels.
27 | :white_small_square: `{bot_prefix}win` : rentrer le score d'un set dans <#{scores_channel_id}> *(paramètre : score)*.
28 | :white_small_square: `{bot_prefix}stages` : obtenir la stagelist légale actuelle selon le jeu du tournoi actuel.
29 | :white_small_square: `{bot_prefix}ruleset` : obtenir un lien vers le channel du ruleset du jeu actuel.
30 | :white_small_square: `{bot_prefix}lag` : ouvrir une procédure de lag, à utiliser avec parcimonie.
31 | """
32 |
33 | admin_help_text=f"""
34 | :no_entry_sign: **Commandes administrateur :**
35 | :white_small_square: `{bot_prefix}setup` : initialiser un tournoi *(paramètre : lien challonge valide)*.
36 | :white_small_square: `{bot_prefix}rm` : désinscrire/retirer (DQ) quelqu'un du tournoi *(paramètre : @mention | liste)*.
37 | :white_small_square: `{bot_prefix}add` : ajouter quelqu'un au tournoi *(paramètre : @mention | liste)*.
38 | :white_small_square: `{bot_prefix}start/end` : démarrer/finaliser le tournoi enregistré.
39 | :white_small_square: `{bot_prefix}set` : changer une préférence *(paramètres : [paramètre] [on/off])*.
40 | :white_small_square: `{bot_prefix}settings` : afficher les préférences modifiables avec `{bot_prefix}set`.
41 | """
42 |
43 | streamer_help_text=f"""
44 | :tv: **Commandes streamer :**
45 | :white_small_square: `{bot_prefix}initstream` : initialiser son stream pour le tournoi actuel *(paramètre : lien Twitch)*.
46 | :white_small_square: `{bot_prefix}stopstream` : arrêter son stream pour le tournoi actuel.
47 | :white_small_square: `{bot_prefix}mystream` : obtenir les informations relatives au stream (IDs, on stream, queue).
48 | :white_small_square: `{bot_prefix}setstream` : mettre en place les codes d'accès au stream *(paramètres jeu-dépendant)*.
49 | :white_small_square: `{bot_prefix}addstream` : ajouter un set à la stream queue *(paramètre : n° | liste de n°)*.
50 | :white_small_square: `{bot_prefix}rmstream` : retirer un set de la stream queue *(paramètre : n° | liste de n°)*.
51 | :white_small_square: `{bot_prefix}swapstream` : interchanger deux sets de la stream queue *(paramètres : n°1 | n°2)*.
52 | """
53 |
54 | lag_text=f"""
55 | :satellite: **Un lag a été constaté**, les <@&{to_id}> sont contactés.
56 |
57 | :one: En attendant, chaque joueur peut :
58 | :white_small_square: Vérifier qu'aucune autre connexion locale ne pompe la connexion.
59 | :white_small_square: S'assurer que la connexion au réseau est, si possible, câblée.
60 | :white_small_square: S'assurer qu'il/elle n'emploie pas un partage de connexion de réseau mobile (passable de DQ).
61 |
62 | :two: Si malgré ces vérifications la connexion n'est pas toujours pas satisfaisante, chaque joueur doit :
63 | :white_small_square: Préparer un test de connexion *(Switch pour Ultimate, Speedtest pour Project+)*.
64 | :white_small_square: Décrire sa méthode de connexion actuelle *(Wi-Fi, Ethernet direct, CPL -> ADSL, FFTH, 4G...)*.
65 |
66 | :three: Si nécessaire, un TO s'occupera de votre cas et proposera une arène avec le/les joueur(s) problématique(s).
67 | """
68 |
69 | desync_text=f"""
70 | :one: **Détecter une desync sur Project+ (Dolphin Netplay) :**
71 | :white_small_square: Une desync résulte dans des inputs transmis au mauvais moment (l'adversaire SD à répétition, etc.).
72 | :white_small_square: Si Dolphin affiche qu'une desync a été détectée, c'est probablement le cas.
73 |
74 | :two: **Résoudre une desync, les 2 joueurs : **
75 | :white_small_square: Peuvent avoir recours à une __personne de tierce partie__ pour déterminer le fautif.
76 | :white_small_square: S'assurent qu'ils ont bien installé **la dernière version en date** de Project+.
77 | :white_small_square: Vérifient depuis la fenêtre netplay que leur carte SD virtuelle a un hash MD5 égal à :
78 | ```
79 | 9b1bf61cf106b70ecbc81c1e70aed0f7
80 | ```
81 | :white_small_square: Doivent vérifier que leur __ISO possède un hash MD5 inclus__ dans la liste compatible :
82 | ```
83 | d18726e6dfdc8bdbdad540b561051087
84 | d8560b021835c9234c28be7ff9bcaaeb
85 | 5052e2e15f22772ab6ce4fd078221e96
86 | 52ce7160ced2505ad5e397477d0ea4fe
87 | 9f677c78eacb7e9b8617ab358082be32
88 | 1c4d6175e3cbb2614bd805d32aea7311
89 | ```
90 | *ISO : clic droit sur \"Super Smash Bros Brawl\" > Onglet \"Info\" > Ligne \"MD5 Checksum\".
91 | SD : en haut à droite d'une fenêtre netplay, cliquer sur \"MD5 Check\" et choisir \"SD card\".*
92 |
93 | :three: **Si ces informations ne suffisent pas, contactez un TO.**
94 | """
95 |
--------------------------------------------------------------------------------
/utils/logging.py:
--------------------------------------------------------------------------------
1 | # This code is a modified version of loggers.py by Cog-Creators
2 | # https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/logging.py
3 |
4 | import logging
5 | import logging.handlers
6 | import pathlib
7 | import sys
8 | import re
9 |
10 | from typing import List, Tuple, Optional
11 |
12 | MAX_OLD_LOGS = 8
13 |
14 |
15 | class RotatingFileHandler(logging.handlers.RotatingFileHandler):
16 | """Custom rotating file handler.
17 |
18 | This file handler rotates a bit differently to the one in stdlib.
19 |
20 | For a start, this works off of a "stem" and a "directory". The stem
21 | is the base name of the log file, without the extension. The
22 | directory is where all log files (including backups) will be placed.
23 |
24 | Secondly, this logger rotates files downwards, and new logs are
25 | *started* with the backup number incremented. The stdlib handler
26 | rotates files upwards, and this leaves the logs in reverse order.
27 |
28 | Thirdly, naming conventions are not customisable with this class.
29 | Logs will initially be named in the format "{stem}.log", and after
30 | rotating, the first log file will be renamed "{stem}-part1.log",
31 | and a new file "{stem}-part2.log" will be created for logging to
32 | continue.
33 |
34 | A few things can't be modified in this handler: it must use append
35 | mode, it doesn't support use of the `delay` arg, and it will ignore
36 | custom namers and rotators.
37 |
38 | When this handler is instantiated, it will search through the
39 | directory for logs from previous runtimes, and will open the file
40 | with the highest backup number to append to.
41 | """
42 |
43 | def __init__(
44 | self,
45 | stem: str,
46 | directory: pathlib.Path,
47 | maxBytes: int = 0,
48 | backupCount: int = 0,
49 | encoding: Optional[str] = None,
50 | ) -> None:
51 | self.baseStem = stem
52 | self.directory = directory.resolve()
53 | # Scan for existing files in directory, append to last part of existing log
54 | log_part_re = re.compile(rf"{stem}-part(?P\d+).log")
55 | highest_part = 0
56 | for path in directory.iterdir():
57 | match = log_part_re.match(path.name)
58 | if match and int(match["partnum"]) > highest_part:
59 | highest_part = int(match["partnum"])
60 | if highest_part:
61 | filename = directory / f"{stem}-part{highest_part}.log"
62 | else:
63 | filename = directory / f"{stem}.log"
64 | super().__init__(
65 | filename,
66 | mode="a",
67 | maxBytes=maxBytes,
68 | backupCount=backupCount,
69 | encoding=encoding,
70 | delay=False,
71 | )
72 |
73 | def doRollover(self):
74 | if self.stream:
75 | self.stream.close()
76 | self.stream = None
77 | initial_path = self.directory / f"{self.baseStem}.log"
78 | if self.backupCount > 0 and initial_path.exists():
79 | initial_path.replace(self.directory / f"{self.baseStem}-part1.log")
80 |
81 | match = re.match(
82 | rf"{self.baseStem}(?:-part(?P\d+)?)?.log", pathlib.Path(self.baseFilename).name
83 | )
84 | latest_part_num = int(match.groupdict(default="1").get("part", "1"))
85 | if self.backupCount < 1:
86 | # No backups, just delete the existing log and start again
87 | pathlib.Path(self.baseFilename).unlink()
88 | elif latest_part_num > self.backupCount:
89 | # Rotate files down one
90 | # bot-part2.log becomes bot-part1.log etc, a new log is added at the end.
91 | for i in range(1, self.backupCount):
92 | next_log = self.directory / f"{self.baseStem}-part{i + 1}.log"
93 | if next_log.exists():
94 | prev_log = self.directory / f"{self.baseStem}-part{i}.log"
95 | next_log.replace(prev_log)
96 | else:
97 | # Simply start a new file
98 | self.baseFilename = str(
99 | self.directory / f"{self.baseStem}-part{latest_part_num + 1}.log"
100 | )
101 |
102 | self.stream = self._open()
103 |
104 |
105 | def init_loggers(level: int, location: pathlib.Path) -> None:
106 | """
107 | Initialize the logging system for ATOS.
108 |
109 | Arguments
110 | ---------
111 | level: int
112 | Level of the logger, from DEBUG (10) to CRITICAL (50)
113 | https://docs.python.org/3/library/logging.html#levels
114 | location: pathlib.Path
115 | Path to the logging directory.
116 | """
117 | dpy_logger = logging.getLogger("discord")
118 | dpy_logger.setLevel(logging.WARNING)
119 | base_logger = logging.getLogger("atos")
120 | base_logger.setLevel(level)
121 |
122 | formatter = logging.Formatter(
123 | "[{asctime}] [{levelname}] {funcName}: {message}",
124 | datefmt="%Y-%m-%d %H:%M:%S",
125 | style="{",
126 | )
127 |
128 | stdout_handler = logging.StreamHandler(sys.stdout)
129 | stdout_handler.setFormatter(formatter)
130 | base_logger.addHandler(stdout_handler)
131 | dpy_logger.addHandler(stdout_handler)
132 |
133 | if not location.exists():
134 | location.mkdir(parents=True, exist_ok=True)
135 | # Rotate latest logs to previous logs
136 | previous_logs: List[pathlib.Path] = []
137 | latest_logs: List[Tuple[pathlib.Path, str]] = []
138 | for path in location.iterdir():
139 | match = re.match(r"latest(?P-part\d+)?\.log", path.name)
140 | if match:
141 | part = match.groupdict(default="")["part"]
142 | latest_logs.append((path, part))
143 | match = re.match(r"previous(?:-part\d+)?.log", path.name)
144 | if match:
145 | previous_logs.append(path)
146 | # Delete all previous.log files
147 | for path in previous_logs:
148 | path.unlink()
149 | # Rename latest.log files to previous.log
150 | for path, part in latest_logs:
151 | path.replace(location / f"previous{part}.log")
152 |
153 | latest_fhandler = RotatingFileHandler(
154 | stem="latest",
155 | directory=location,
156 | maxBytes=1000000, # About 1MB per logfile
157 | backupCount=MAX_OLD_LOGS,
158 | encoding="utf-8",
159 | )
160 | all_fhandler = RotatingFileHandler(
161 | stem="atos",
162 | directory=location,
163 | maxBytes=1000000,
164 | backupCount=MAX_OLD_LOGS,
165 | encoding="utf-8",
166 | )
167 | for fhandler in (latest_fhandler, all_fhandler):
168 | fhandler.setFormatter(formatter)
169 | base_logger.addHandler(fhandler)
170 |
--------------------------------------------------------------------------------
/cogs/dev_commands.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import asyncio
3 | import aiohttp
4 | import inspect
5 | import io
6 | import textwrap
7 | import traceback
8 | import types
9 | import re
10 | import contextlib
11 | from contextlib import redirect_stdout
12 | from copy import copy
13 |
14 | from typing import Iterable, List
15 |
16 | import discord
17 | from discord.ext import commands
18 |
19 | from .utils.chat_formatting import box, pagify
20 |
21 | """
22 | Notice:
23 |
24 | 95% of the below code came from R.Danny which can be found here:
25 |
26 | https://github.com/Rapptz/RoboDanny/blob/master/cogs/repl.py
27 | """
28 |
29 | _ = lambda x: x
30 |
31 | START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))")
32 |
33 |
34 | class Dev(commands.Cog):
35 | """Various development focused utilities."""
36 |
37 | def __init__(self):
38 | super().__init__()
39 | self._last_result = None
40 | self.sessions = set()
41 |
42 | @staticmethod
43 | async def send_interactive(
44 | ctx: commands.Context, messages: Iterable[str], box_lang: str = None, timeout: int = 15
45 | ) -> List[discord.Message]:
46 | """Send multiple messages interactively.
47 | The user will be prompted for whether or not they would like to view
48 | the next message, one at a time. They will also be notified of how
49 | many messages are remaining on each prompt.
50 | Parameters
51 | ----------
52 | messages : `iterable` of `str`
53 | The messages to send.
54 | box_lang : str
55 | If specified, each message will be contained within a codeblock of
56 | this language.
57 | timeout : int
58 | How long the user has to respond to the prompt before it times out.
59 | After timing out, the bot deletes its prompt message.
60 | """
61 | messages = tuple(messages)
62 | ret = []
63 |
64 | for idx, page in enumerate(messages, 1):
65 | if box_lang is None:
66 | msg = await ctx.send(page)
67 | else:
68 | msg = await ctx.send(box(page, lang=box_lang))
69 | ret.append(msg)
70 | n_remaining = len(messages) - idx
71 | if n_remaining > 0:
72 | if n_remaining == 1:
73 | plural = ""
74 | is_are = "is"
75 | else:
76 | plural = "s"
77 | is_are = "are"
78 | query = await ctx.send(
79 | "There {} still {} message{} remaining. "
80 | "Type `more` to continue."
81 | "".format(is_are, n_remaining, plural)
82 | )
83 | try:
84 | def check_message(msg):
85 | return ctx.channel == msg.channel and ctx.author == msg.author and msg.content.lower() == "more"
86 | resp = await ctx.bot.wait_for(
87 | "message",
88 | check=check_message,
89 | timeout=timeout,
90 | )
91 | except asyncio.TimeoutError:
92 | with contextlib.suppress(discord.HTTPException):
93 | await query.delete()
94 | break
95 | else:
96 | try:
97 | await ctx.channel.delete_messages((query, resp))
98 | except (discord.HTTPException, AttributeError):
99 | # In case the bot can't delete other users' messages,
100 | # or is not a bot account
101 | # or channel is a DM
102 | with contextlib.suppress(discord.HTTPException):
103 | await query.delete()
104 | return ret
105 |
106 | @staticmethod
107 | async def tick(ctx : commands.Context) -> bool:
108 | """Add a tick reaction to the command message.
109 | Returns
110 | -------
111 | bool
112 | :code:`True` if adding the reaction succeeded.
113 | """
114 | try:
115 | await ctx.message.add_reaction("✅")
116 | except discord.HTTPException:
117 | return False
118 | else:
119 | return True
120 |
121 | @staticmethod
122 | def async_compile(source, filename, mode):
123 | return compile(source, filename, mode, flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, optimize=0)
124 |
125 | @staticmethod
126 | async def maybe_await(coro):
127 | for i in range(2):
128 | if inspect.isawaitable(coro):
129 | coro = await coro
130 | else:
131 | return coro
132 | return coro
133 |
134 | @staticmethod
135 | def cleanup_code(content):
136 | """Automatically removes code blocks from the code."""
137 | # remove ```py\n```
138 | if content.startswith("```") and content.endswith("```"):
139 | return START_CODE_BLOCK_RE.sub("", content)[:-3]
140 |
141 | # remove `foo`
142 | return content.strip("` \n")
143 |
144 | @staticmethod
145 | def get_syntax_error(e):
146 | """Format a syntax error to send to the user.
147 |
148 | Returns a string representation of the error formatted as a codeblock.
149 | """
150 | if e.text is None:
151 | return box("{0.__class__.__name__}: {0}".format(e), lang="py")
152 | return box(
153 | "{0.text}\n{1:>{0.offset}}\n{2}: {0}".format(e, "^", type(e).__name__), lang="py"
154 | )
155 |
156 | @staticmethod
157 | def get_pages(msg: str):
158 | """Pagify the given message for output to the user."""
159 | return pagify(msg, delims=["\n", " "], priority=True, shorten_by=10)
160 |
161 | @staticmethod
162 | def sanitize_output(ctx: commands.Context, input_: str) -> str:
163 | """Hides the bot's token from a string."""
164 | token = ctx.bot.http.token
165 | return re.sub(re.escape(token), "[EXPUNGED]", input_, re.I)
166 |
167 | @commands.command()
168 | @commands.is_owner()
169 | async def debug(self, ctx, *, code):
170 | """Evaluate a statement of python code.
171 |
172 | The bot will always respond with the return value of the code.
173 | If the return value of the code is a coroutine, it will be awaited,
174 | and the result of that will be the bot's response.
175 |
176 | Note: Only one statement may be evaluated. Using certain restricted
177 | keywords, e.g. yield, will result in a syntax error. For multiple
178 | lines or asynchronous code, see [p]repl or [p]eval.
179 |
180 | Environment Variables:
181 | ctx - command invokation context
182 | bot - bot object
183 | channel - the current channel object
184 | author - command author's member object
185 | message - the command's message object
186 | discord - discord.py library
187 | commands - redbot.core.commands
188 | _ - The result of the last dev command.
189 | """
190 | env = {
191 | "bot": ctx.bot,
192 | "ctx": ctx,
193 | "channel": ctx.channel,
194 | "author": ctx.author,
195 | "guild": ctx.guild,
196 | "message": ctx.message,
197 | "asyncio": asyncio,
198 | "aiohttp": aiohttp,
199 | "discord": discord,
200 | "commands": commands,
201 | "_": self._last_result,
202 | "__name__": "__main__",
203 | }
204 |
205 | code = self.cleanup_code(code)
206 |
207 | try:
208 | compiled = self.async_compile(code, "", "eval")
209 | result = await self.maybe_await(eval(compiled, env))
210 | except SyntaxError as e:
211 | await ctx.send(self.get_syntax_error(e))
212 | return
213 | except Exception as e:
214 | await ctx.send(box("{}: {!s}".format(type(e).__name__, e), lang="py"))
215 | return
216 |
217 | self._last_result = result
218 | result = self.sanitize_output(ctx, str(result))
219 |
220 | await self.send_interactive(ctx, self.get_pages(result), box_lang="py")
221 |
222 | @commands.command(name="eval")
223 | @commands.is_owner()
224 | async def _eval(self, ctx, *, body: str):
225 | """Execute asynchronous code.
226 |
227 | This command wraps code into the body of an async function and then
228 | calls and awaits it. The bot will respond with anything printed to
229 | stdout, as well as the return value of the function.
230 |
231 | The code can be within a codeblock, inline code or neither, as long
232 | as they are not mixed and they are formatted correctly.
233 |
234 | Environment Variables:
235 | ctx - command invokation context
236 | bot - bot object
237 | channel - the current channel object
238 | author - command author's member object
239 | message - the command's message object
240 | discord - discord.py library
241 | commands - redbot.core.commands
242 | _ - The result of the last dev command.
243 | """
244 | env = {
245 | "bot": ctx.bot,
246 | "ctx": ctx,
247 | "channel": ctx.channel,
248 | "author": ctx.author,
249 | "guild": ctx.guild,
250 | "message": ctx.message,
251 | "asyncio": asyncio,
252 | "aiohttp": aiohttp,
253 | "discord": discord,
254 | "commands": commands,
255 | "_": self._last_result,
256 | "__name__": "__main__",
257 | }
258 |
259 | body = self.cleanup_code(body)
260 | stdout = io.StringIO()
261 |
262 | to_compile = "async def func():\n%s" % textwrap.indent(body, " ")
263 |
264 | try:
265 | compiled = self.async_compile(to_compile, "", "exec")
266 | exec(compiled, env)
267 | except SyntaxError as e:
268 | return await ctx.send(self.get_syntax_error(e))
269 |
270 | func = env["func"]
271 | result = None
272 | try:
273 | with redirect_stdout(stdout):
274 | result = await func()
275 | except:
276 | printed = "{}{}".format(stdout.getvalue(), traceback.format_exc())
277 | else:
278 | printed = stdout.getvalue()
279 | await self.tick(ctx)
280 |
281 | if result is not None:
282 | self._last_result = result
283 | msg = "{}{}".format(printed, result)
284 | else:
285 | msg = printed
286 | msg = self.sanitize_output(ctx, msg)
287 |
288 | await self.send_interactive(ctx, self.get_pages(msg), box_lang="py")
289 |
290 | @commands.command()
291 | @commands.is_owner()
292 | async def repl(self, ctx):
293 | """Open an interactive REPL.
294 |
295 | The REPL will only recognise code as messages which start with a
296 | backtick. This includes codeblocks, and as such multiple lines can be
297 | evaluated.
298 | """
299 | variables = {
300 | "ctx": ctx,
301 | "bot": ctx.bot,
302 | "message": ctx.message,
303 | "guild": ctx.guild,
304 | "channel": ctx.channel,
305 | "author": ctx.author,
306 | "asyncio": asyncio,
307 | "_": None,
308 | "__builtins__": __builtins__,
309 | "__name__": "__main__",
310 | }
311 |
312 | if ctx.channel.id in self.sessions:
313 | await ctx.send(
314 | _("Already running a REPL session in this channel. Exit it with `quit`.")
315 | )
316 | return
317 |
318 | self.sessions.add(ctx.channel.id)
319 | await ctx.send(_("Enter code to execute or evaluate. `exit()` or `quit` to exit."))
320 |
321 | while True:
322 |
323 | def check_message(msg):
324 | return ctx.channel == msg.channel and ctx.author == msg.author
325 |
326 | response = await ctx.bot.wait_for("message", check=check_message)
327 |
328 | cleaned = self.cleanup_code(response.content)
329 |
330 | if cleaned in ("quit", "exit", "exit()"):
331 | await ctx.send(_("Exiting."))
332 | self.sessions.remove(ctx.channel.id)
333 | return
334 |
335 | executor = None
336 | if cleaned.count("\n") == 0:
337 | # single statement, potentially 'eval'
338 | try:
339 | code = self.async_compile(cleaned, "", "eval")
340 | except SyntaxError:
341 | pass
342 | else:
343 | executor = eval
344 |
345 | if executor is None:
346 | try:
347 | code = self.async_compile(cleaned, "", "exec")
348 | except SyntaxError as e:
349 | await ctx.send(self.get_syntax_error(e))
350 | continue
351 |
352 | variables["message"] = response
353 |
354 | stdout = io.StringIO()
355 |
356 | msg = ""
357 |
358 | try:
359 | with redirect_stdout(stdout):
360 | if executor is None:
361 | result = types.FunctionType(code, variables)()
362 | else:
363 | result = executor(code, variables)
364 | result = await self.maybe_await(result)
365 | except:
366 | value = stdout.getvalue()
367 | msg = "{}{}".format(value, traceback.format_exc())
368 | else:
369 | value = stdout.getvalue()
370 | if result is not None:
371 | msg = "{}{}".format(value, result)
372 | variables["_"] = result
373 | elif value:
374 | msg = "{}".format(value)
375 |
376 | msg = self.sanitize_output(ctx, msg)
377 |
378 | try:
379 | await self.send_interactive(ctx, self.get_pages(msg), box_lang="py")
380 | except discord.Forbidden:
381 | pass
382 | except discord.HTTPException as e:
383 | await ctx.send(_("Unexpected error: `{}`").format(e))
384 |
385 | @commands.command()
386 | @commands.is_owner()
387 | async def mock(self, ctx, user: discord.Member, *, command):
388 | """Mock another user invoking a command.
389 |
390 | The prefix must not be entered.
391 | """
392 | msg = copy(ctx.message)
393 | msg.author = user
394 | msg.content = ctx.prefix + command
395 |
396 | ctx.bot.dispatch("message", msg)
397 |
398 | @commands.command(name="mockmsg")
399 | @commands.is_owner()
400 | async def mock_msg(self, ctx, user: discord.Member, *, content: str):
401 | """Dispatch a message event as if it were sent by a different user.
402 |
403 | Only reads the raw content of the message. Attachments, embeds etc. are
404 | ignored.
405 | """
406 | old_author = ctx.author
407 | old_content = ctx.message.content
408 | ctx.message.author = user
409 | ctx.message.content = content
410 |
411 | ctx.bot.dispatch("message", ctx.message)
412 |
413 | # If we change the author and content back too quickly,
414 | # the bot won't process the mocked message in time.
415 | await asyncio.sleep(2)
416 | ctx.message.author = old_author
417 | ctx.message.content = old_content
418 |
419 | def setup(bot):
420 | bot.add_cog(Dev())
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | import discord, random, logging, os, json, re, achallonge, dateutil.parser, dateutil.relativedelta, datetime, time, asyncio, yaml, sys
2 | import aiofiles, aiofiles.os
3 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
4 | from apscheduler.jobstores.base import JobLookupError
5 | from babel.dates import format_date, format_time
6 | from discord.ext import commands
7 | from pathlib import Path
8 | from achallonge import ChallongeException
9 |
10 | # Custom modules
11 | from utils.json_hooks import dateconverter, dateparser, int_keys
12 | from utils.command_checks import tournament_is_pending, tournament_is_underway, tournament_is_underway_or_pending, in_channel, in_combat_channel, is_streaming, is_owner_or_to, inscriptions_still_open
13 | from utils.stream import is_on_stream, is_queued_for_stream
14 | from utils.rounds import is_top8, nom_round, is_bo5
15 | from utils.game_specs import get_access_stream
16 | from utils.http_retry import async_http_retry
17 | from utils.seeding import get_ranking_csv, seed_participants
18 | from utils.logging import init_loggers
19 | from utils.json_stream import participants, dump_participants
20 |
21 | # Import configuration (variables only)
22 | from utils.get_config import *
23 |
24 | # Import raw texts (variables only)
25 | from utils.raw_texts import *
26 |
27 | log = logging.getLogger("atos")
28 |
29 | #### Infos
30 | version = "5.27"
31 | author = "Wonderfall"
32 | name = "A.T.O.S."
33 |
34 | ### Cogs
35 | initial_extensions = ['cogs.dev_commands']
36 |
37 |
38 | ### Init things
39 | bot = commands.Bot(command_prefix=commands.when_mentioned_or(bot_prefix)) # Set prefix for commands
40 | bot.remove_command('help') # Remove default help command to set our own
41 | achallonge.set_credentials(challonge_user, challonge_api_key)
42 | scheduler = AsyncIOScheduler()
43 |
44 |
45 | #### Notifier de l'initialisation
46 | @bot.event
47 | async def on_ready():
48 | log.info("Bot successfully connected to Discord.")
49 | print(f"-------------------------------------")
50 | print(f" A. T. O. S. ")
51 | print(f" Automated TO for Smash ")
52 | print(f" ")
53 | print(f"Version : {version} ")
54 | print(f"discord.py : {discord.__version__} ")
55 | print(f"User : {bot.user.name} ")
56 | print(f"User ID : {bot.user.id} ")
57 | print(f"-------------------------------------")
58 | await bot.change_presence(activity=discord.Game(f'{name} • {version}')) # As of April 2020, CustomActivity is not supported for bots
59 | await reload_tournament()
60 |
61 |
62 | ### A chaque arrivée de membre
63 | @bot.event
64 | async def on_member_join(member):
65 |
66 | if greet_new_members == False: return
67 |
68 | message = random.choice([
69 | f"<@{member.id}> joins the battle!",
70 | f"Bienvenue à toi sur le serveur {member.guild.name}, <@{member.id}>.",
71 | f"Un <@{member.id}> sauvage apparaît !",
72 | f"Le serveur {member.guild.name} accueille un nouveau membre : <@{member.id}> !"
73 | ])
74 |
75 | try:
76 | await member.send(f"Bienvenue sur le serveur **{member.guild.name}** ! {welcome_text}")
77 | except discord.Forbidden:
78 | await bot.get_channel(blabla_channel_id).send(f"{message} {welcome_text}")
79 | else:
80 | await bot.get_channel(blabla_channel_id).send(message) # Avoid sending welcome_text to the channel if possible
81 |
82 |
83 | ### Récupérer informations du tournoi et initialiser tournoi.json
84 | async def init_tournament(url_or_id):
85 |
86 | with open(preferences_path, 'r+') as f: preferences = yaml.full_load(f)
87 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
88 |
89 | try:
90 | infos = await async_http_retry(achallonge.tournaments.show, url_or_id)
91 | except ChallongeException:
92 | return
93 |
94 | debut_tournoi = dateutil.parser.parse(str(infos["start_at"])).replace(tzinfo=None)
95 |
96 | tournoi = {
97 | "name": infos["name"],
98 | "game": infos["game_name"].title(), # Non-recognized games are lowercase for Challonge
99 | "url": infos["full_challonge_url"],
100 | "id": infos["id"],
101 | "limite": infos["signup_cap"],
102 | "statut": infos["state"],
103 | "début_tournoi": debut_tournoi,
104 | "début_check-in": debut_tournoi - datetime.timedelta(minutes = preferences['check_in_opening']),
105 | "fin_check-in": debut_tournoi - datetime.timedelta(minutes = preferences['check_in_closing']),
106 | "fin_inscription": debut_tournoi - datetime.timedelta(minutes = preferences['inscriptions_closing']),
107 | "use_guild_name": preferences['use_guild_name'],
108 | "bulk_mode": preferences['bulk_mode'],
109 | "reaction_mode": preferences['reaction_mode'],
110 | "restrict_to_role": preferences['restrict_to_role'],
111 | "check_channel_presence": preferences['check_channel_presence'],
112 | "start_bo5": preferences['start_bo5'],
113 | "full_bo3": preferences['full_bo3'],
114 | "full_bo5": preferences['full_bo5'],
115 | "warned": [],
116 | "timeout": []
117 | }
118 |
119 | # Checks
120 | if tournoi['game'] not in gamelist:
121 | await bot.get_channel(to_channel_id).send(f":warning: Création du tournoi *{tournoi['game']}* annulée : **jeu introuvable dans la gamelist**.")
122 | return
123 |
124 | if not (datetime.datetime.now() < tournoi["début_check-in"] < tournoi["fin_check-in"] < tournoi["fin_inscription"] < tournoi["début_tournoi"]):
125 | await bot.get_channel(to_channel_id).send(f":warning: Création du tournoi *{tournoi['game']}* annulée : **conflit des temps de check-in et d'inscriptions**.")
126 | return
127 |
128 | if tournoi['bulk_mode'] == True:
129 | try:
130 | await get_ranking_csv(tournoi)
131 | except (KeyError, ValueError):
132 | await bot.get_channel(to_channel_id).send(f":warning: Création du tournoi *{tournoi['game']}* annulée : **données de ranking introuvables**.\n"
133 | f"*Désactivez le bulk mode avec `{bot_prefix}set bulk_mode off` si vous ne souhaitez pas utiliser de ranking.*")
134 | return
135 |
136 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
137 | with open(participants_path, 'w') as f: json.dump({}, f, indent=4)
138 | with open(stream_path, 'w') as f: json.dump({}, f, indent=4)
139 |
140 | # Ensure permissions
141 | guild = bot.get_guild(id=guild_id)
142 | challenger = guild.get_role(challenger_id)
143 | await bot.get_channel(check_in_channel_id).set_permissions(challenger, read_messages=True, send_messages=False, add_reactions=False)
144 | await bot.get_channel(check_in_channel_id).edit(slowmode_delay=60)
145 | await bot.get_channel(scores_channel_id).set_permissions(challenger, read_messages=True, send_messages=False, add_reactions=False)
146 | await bot.get_channel(queue_channel_id).set_permissions(challenger, read_messages=True, send_messages=False, add_reactions=False)
147 |
148 | scheduler.add_job(start_check_in, id='start_check_in', run_date=tournoi["début_check-in"], replace_existing=True)
149 | scheduler.add_job(end_check_in, id='end_check_in', run_date=tournoi["fin_check-in"], replace_existing=True)
150 | scheduler.add_job(end_inscription, id='end_inscription', run_date=tournoi["fin_inscription"], replace_existing=True)
151 |
152 | await init_compteur()
153 |
154 | await bot.change_presence(activity=discord.Game(tournoi['name']))
155 |
156 | await purge_channels()
157 |
158 | ### Ajouter un tournoi
159 | @bot.command(name='setup')
160 | @commands.check(is_owner_or_to)
161 | async def setup_tournament(ctx, arg):
162 |
163 | if re.compile(r"^(https?\:\/\/)?(challonge.com)\/.+$").match(arg):
164 | await init_tournament(arg.replace("https://challonge.com/", ""))
165 | else:
166 | await ctx.message.add_reaction("🔗")
167 | return
168 |
169 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
170 |
171 | try:
172 | tournoi["début_tournoi"]
173 | except KeyError:
174 | await ctx.message.add_reaction("⚠️")
175 | else:
176 | await ctx.message.add_reaction("✅")
177 |
178 |
179 | ### AUTO-MODE : will take care of creating tournaments for you
180 | @scheduler.scheduled_job('interval', id='auto_setup_tournament', hours=1)
181 | async def auto_setup_tournament():
182 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
183 | with open(auto_mode_path, 'r+') as f: tournaments = yaml.full_load(f)
184 | with open(preferences_path, 'r+') as f: preferences = yaml.full_load(f)
185 |
186 | # Auto-mode won't run if at least one of these conditions is met :
187 | # - It's turned off in preferences.yml
188 | # - A tournament is already initialized
189 | # - It's "night" time
190 |
191 | if (preferences['auto_mode'] != True) or (tournoi != {}) or (not 10 <= datetime.datetime.now().hour <= 22): return
192 |
193 | for tournament in tournaments:
194 |
195 | for day in tournaments[tournament]["days"]:
196 |
197 | try:
198 | relative = dateutil.relativedelta.relativedelta(weekday = time.strptime(day, '%A').tm_wday) # It's a weekly
199 | except TypeError:
200 | relative = dateutil.relativedelta.relativedelta(day = day) # It's a monthly
201 | except ValueError:
202 | return # Neither?
203 |
204 | next_date = (datetime.datetime.now().astimezone() + relative).replace(
205 | hour = dateutil.parser.parse(tournaments[tournament]["start"]).hour,
206 | minute = dateutil.parser.parse(tournaments[tournament]["start"]).minute,
207 | second = 0,
208 | microsecond = 0 # for dateparser to work
209 | )
210 |
211 | # If the tournament is supposed to be in less than inscriptions_opening (hours), let's go !
212 | if abs(next_date - datetime.datetime.now().astimezone()) < datetime.timedelta(hours = preferences['inscriptions_opening']):
213 |
214 | new_tournament = await async_http_retry(
215 | achallonge.tournaments.create,
216 | name=f"{tournament} #{tournaments[tournament]['edition']}",
217 | url=f"{re.sub('[^A-Za-z0-9]+', '', tournament)}{tournaments[tournament]['edition']}",
218 | tournament_type='double elimination',
219 | show_rounds=True,
220 | description=tournaments[tournament]['description'],
221 | signup_cap=tournaments[tournament]['capping'],
222 | game_name=tournaments[tournament]['game'],
223 | start_at=next_date
224 | )
225 |
226 | await init_tournament(new_tournament["id"])
227 |
228 | # Check if the tournamet was configured
229 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
230 | if tournoi != {}:
231 | tournaments[tournament]["edition"] += 1
232 | with open(auto_mode_path, 'w') as f: yaml.dump(tournaments, f)
233 |
234 | return
235 |
236 |
237 | ### Démarrer un tournoi
238 | @bot.command(name='start')
239 | @commands.check(is_owner_or_to)
240 | @commands.check(tournament_is_pending)
241 | async def start_tournament(ctx):
242 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
243 |
244 | guild = bot.get_guild(id=guild_id)
245 | challenger = guild.get_role(challenger_id)
246 |
247 | if datetime.datetime.now() > tournoi["fin_inscription"]:
248 | await async_http_retry(achallonge.tournaments.start, tournoi["id"])
249 | tournoi["statut"] = "underway"
250 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
251 | await ctx.message.add_reaction("✅")
252 | else:
253 | await ctx.message.add_reaction("🕐")
254 | return
255 |
256 | await calculate_top8()
257 |
258 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser) # Refresh to get top 8
259 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
260 |
261 | await bot.get_channel(annonce_channel_id).send(f"{server_logo} Le tournoi **{tournoi['name']}** est officiellement lancé ! Voici le bracket : {tournoi['url']}\n"
262 | f":white_small_square: Vous pouvez y accéder à tout moment avec la commande `{bot_prefix}bracket`.\n"
263 | f":white_small_square: Vous pouvez consulter les liens de stream avec la commande `{bot_prefix}stream`.")
264 |
265 | score_annonce = (f":information_source: La prise en charge des scores pour le tournoi **{tournoi['name']}** est automatisée :\n"
266 | f":white_small_square: Seul **le gagnant du set** envoie le score de son set, précédé par la **commande** `{bot_prefix}win`.\n"
267 | f":white_small_square: Le message du score doit contenir le **format suivant** : `{bot_prefix}win 2-0, 3-2, 3-1, ...`.\n"
268 | f":white_small_square: Un mauvais score intentionnel, perturbant le déroulement du tournoi, est **passable de DQ et ban**.\n"
269 | f":white_small_square: Consultez le bracket afin de **vérifier** les informations : {tournoi['url']}\n"
270 | f":white_small_square: En cas de mauvais score : contactez un TO pour une correction manuelle.\n\n"
271 | f":satellite_orbital: Chaque score étant **transmis un par un**, il est probable que la communication prenne jusqu'à 30 secondes.")
272 |
273 | await bot.get_channel(scores_channel_id).send(score_annonce)
274 | await bot.get_channel(scores_channel_id).set_permissions(challenger, read_messages=True, send_messages=True, add_reactions=False)
275 |
276 | queue_annonce = (f":information_source: **Le lancement des sets est automatisé.** Veuillez suivre les consignes de ce channel, que ce soit par le bot ou les TOs.\n"
277 | f":white_small_square: Tout passage on stream sera notifié à l'avance, ici, dans votre channel (ou par DM).\n"
278 | f":white_small_square: Tout set devant se jouer en BO5 est indiqué ici, et également dans votre channel.\n"
279 | f":white_small_square: La personne qui commence les bans est indiquée dans votre channel (en cas de besoin : `{bot_prefix}flip`).\n\n"
280 | f":timer: Vous serez **DQ automatiquement** si vous n'avez pas été actif sur votre channel __dans les {tournoi['check_channel_presence']} minutes qui suivent sa création__.")
281 |
282 | await bot.get_channel(queue_channel_id).send(queue_annonce)
283 |
284 | tournoi_annonce = (f":alarm_clock: <@&{challenger_id}> On arrête le freeplay ! Le tournoi est sur le point de commencer. Veuillez lire les consignes :\n"
285 | f":white_small_square: Vos sets sont annoncés dès que disponibles dans <#{queue_channel_id}> : **ne lancez rien sans consulter ce channel**.\n"
286 | f":white_small_square: Le ruleset ainsi que les informations pour le bannissement des stages sont dispo dans <#{gamelist[tournoi['game']]['ruleset']}>.\n"
287 | f":white_small_square: Le gagnant d'un set doit rapporter le score **dès que possible** dans <#{scores_channel_id}> avec la commande `{bot_prefix}win`.\n"
288 | f":white_small_square: Vous pouvez DQ du tournoi avec la commande `{bot_prefix}dq`, ou juste abandonner votre set en cours avec `{bot_prefix}ff`.\n"
289 | f":white_small_square: En cas de lag qui rend votre set injouable, utilisez la commande `{bot_prefix}lag` pour résoudre la situation.\n"
290 | f":timer: Vous serez **DQ automatiquement** si vous n'avez pas été actif sur votre channel __dans les {tournoi['check_channel_presence']} minutes qui suivent sa création__.")
291 |
292 | if tournoi["game"] == "Project+":
293 | tournoi_annonce += f"\n{gamelist[tournoi['game']]['icon']} En cas de desync, utilisez la commande `{bot_prefix}desync` pour résoudre la situation."
294 |
295 | tournoi_annonce += (f"\n\n:fire: Le **top 8** commencera, d'après le bracket :\n"
296 | f":white_small_square: En **{nom_round(tournoi['round_winner_top8'])}**\n"
297 | f":white_small_square: En **{nom_round(tournoi['round_looser_top8'])}**\n\n")
298 |
299 | if tournoi["full_bo3"]:
300 | tournoi_annonce += ":three: L'intégralité du tournoi se déroulera en **BO3**."
301 | elif tournoi["full_bo5"]:
302 | tournoi_annonce += ":five: L'intégralité du tournoi se déroulera en **BO5**."
303 | elif tournoi["start_bo5"] != 0:
304 | tournoi_annonce += (f":five: Les **BO5** commenceront quant à eux :\n"
305 | f":white_small_square: En **{nom_round(tournoi['round_winner_bo5'])}**\n"
306 | f":white_small_square: En **{nom_round(tournoi['round_looser_bo5'])}**")
307 | else:
308 | tournoi_annonce += ":five: Les **BO5** commenceront en **top 8**."
309 |
310 | tournoi_annonce += "\n\n*L'équipe de TO et moi-même vous souhaitons un excellent tournoi !*"
311 |
312 | await bot.get_channel(tournoi_channel_id).send(tournoi_annonce)
313 |
314 | scheduler.add_job(underway_tournament, 'interval', id='underway_tournament', minutes=1, start_date=tournoi["début_tournoi"], replace_existing=True)
315 |
316 |
317 | ### Terminer un tournoi
318 | @bot.command(name='end')
319 | @commands.check(is_owner_or_to)
320 | @commands.check(tournament_is_underway)
321 | async def end_tournament(ctx):
322 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
323 |
324 | if datetime.datetime.now() > tournoi["début_tournoi"]:
325 | await async_http_retry(achallonge.tournaments.finalize, tournoi["id"])
326 | await ctx.message.add_reaction("✅")
327 | else:
328 | await ctx.message.add_reaction("🕐")
329 | return
330 |
331 | # Remove underway task
332 | try:
333 | scheduler.remove_job('underway_tournament')
334 | except JobLookupError:
335 | pass
336 |
337 | # Annoucements (including results)
338 | await annonce_resultats()
339 | await bot.get_channel(annonce_channel_id).send(
340 | f"{server_logo} Le tournoi **{tournoi['name']}** est terminé, merci à toutes et à tous d'avoir participé ! "
341 | f"J'espère vous revoir bientôt.")
342 |
343 | # Reset participants
344 | participants.clear()
345 |
346 | # Reset JSON storage
347 | with open(participants_path, 'w') as f: json.dump({}, f, indent=4)
348 | with open(tournoi_path, 'w') as f: json.dump({}, f, indent=4)
349 | with open(stream_path, 'w') as f: json.dump({}, f, indent=4)
350 |
351 | # Remove now obsolete files
352 | for file in list(Path(Path(ranking_path).parent).rglob('*.csv_*')):
353 | await aiofiles.os.remove(file)
354 | for file in list(Path(Path(participants_path).parent).rglob('*.bak')):
355 | await aiofiles.os.remove(file)
356 |
357 | # Change presence back to default
358 | await bot.change_presence(activity=discord.Game(f'{name} • {version}'))
359 |
360 | # Remove tournament roles & categories
361 | await purge_categories()
362 | await purge_roles()
363 |
364 |
365 | ### S'execute à chaque lancement, permet de relancer les tâches en cas de crash
366 | async def reload_tournament():
367 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
368 |
369 | try:
370 | await bot.change_presence(activity=discord.Game(tournoi['name']))
371 | except KeyError:
372 | log.info("No tournament had to be reloaded.")
373 | return
374 |
375 | # Relancer les tâches automatiques
376 | if tournoi["statut"] == "underway":
377 | scheduler.add_job(underway_tournament, 'interval', id='underway_tournament', minutes=1, replace_existing=True)
378 |
379 | elif datetime.datetime.now() < tournoi["fin_inscription"]:
380 | scheduler.add_job(start_check_in, id='start_check_in', run_date=tournoi["début_check-in"], replace_existing=True)
381 | scheduler.add_job(end_check_in, id='end_check_in', run_date=tournoi["fin_check-in"], replace_existing=True)
382 | scheduler.add_job(end_inscription, id='end_inscription', run_date=tournoi["fin_inscription"], replace_existing=True)
383 | scheduler.add_job(dump_participants, 'interval', id='dump_participants', seconds=10, replace_existing=True)
384 |
385 | if tournoi["début_check-in"] < datetime.datetime.now() < tournoi["fin_check-in"]:
386 | scheduler.add_job(rappel_check_in, 'interval', id='rappel_check_in', minutes=10, replace_existing=True)
387 |
388 | log.info("Scheduled tasks for a tournament have been reloaded.")
389 |
390 | # Prendre les inscriptions manquées
391 | if datetime.datetime.now() < tournoi["fin_inscription"]:
392 |
393 | if tournoi["reaction_mode"]:
394 | annonce = await bot.get_channel(inscriptions_channel_id).fetch_message(tournoi["annonce_id"])
395 |
396 | # Avoir une liste des users ayant réagi
397 | for reaction in annonce.reactions:
398 | if str(reaction.emoji) == "✅":
399 | reactors = await reaction.users().flatten()
400 | break
401 |
402 | # Inscrire ceux qui ne sont pas dans les participants
403 | id_list = []
404 |
405 | for reactor in reactors:
406 | if reactor.id != bot.user.id:
407 | id_list.append(reactor.id) # Récupérer une liste des IDs pour plus tard
408 |
409 | if reactor.id not in participants:
410 | await inscrire(reactor)
411 |
412 | # Désinscrire ceux qui ne sont plus dans la liste des users ayant réagi
413 | for inscrit in participants:
414 | if inscrit not in id_list:
415 | await desinscrire(annonce.guild.get_member(inscrit))
416 |
417 | else:
418 | async for message in bot.get_channel(inscriptions_channel_id).history(oldest_first=True):
419 | if message.author == bot.user or message.reactions != []:
420 | continue
421 |
422 | if not any([bot.user in await reaction.users().flatten() for reaction in message.reactions]):
423 | await bot.process_commands(message)
424 |
425 | log.info("Missed inscriptions were also taken care of.")
426 |
427 |
428 | ### Annonce et lance les inscriptions
429 | @bot.command(name='inscriptions')
430 | @commands.check(is_owner_or_to)
431 | @commands.check(tournament_is_pending)
432 | async def annonce_inscription(ctx):
433 |
434 | scheduler.add_job(dump_participants, 'interval', id='dump_participants', seconds=10, replace_existing=True)
435 |
436 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
437 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
438 |
439 | inscriptions_channel = bot.get_channel(inscriptions_channel_id)
440 | inscriptions_role = inscriptions_channel.guild.get_role(gamelist[tournoi['game']]['role']) if tournoi["restrict_to_role"] else inscriptions_channel.guild.default_role
441 |
442 | if tournoi['reaction_mode']:
443 | await inscriptions_channel.set_permissions(inscriptions_role, read_messages=True, send_messages=False, add_reactions=False)
444 | else:
445 | await inscriptions_channel.set_permissions(inscriptions_role, read_messages=True, send_messages=True, add_reactions=False)
446 | await inscriptions_channel.edit(slowmode_delay=60)
447 |
448 | await ctx.message.add_reaction("✅")
449 |
450 | await bot.get_channel(annonce_channel_id).send(f"{server_logo} Inscriptions pour le **{tournoi['name']}** ouvertes dans <#{inscriptions_channel_id}> ! Consultez-y les messages épinglés. <@&{gamelist[tournoi['game']]['role']}>\n"
451 | f":calendar_spiral: Ce tournoi aura lieu le **{format_date(tournoi['début_tournoi'], format='full', locale=language)} à {format_time(tournoi['début_tournoi'], format='short', locale=language)}**.")
452 |
453 | ### Initialise le compteur d'inscrits dans le salon d'inscriptions
454 | async def init_compteur():
455 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
456 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
457 |
458 | annonce = (
459 | f"{server_logo} **{tournoi['name']}** - {gamelist[tournoi['game']]['icon']} *{tournoi['game']}*\n"
460 | f":white_small_square: __Date__ : {format_date(tournoi['début_tournoi'], format='full', locale=language)} à {format_time(tournoi['début_tournoi'], format='short', locale=language)}\n"
461 | f":white_small_square: __Check-in__ : de {format_time(tournoi['début_check-in'], format='short', locale=language)} à {format_time(tournoi['fin_check-in'], format='short', locale=language)} "
462 | f"(fermeture des inscriptions à {format_time(tournoi['fin_inscription'], format='short', locale=language)})\n"
463 | f":white_small_square: __Limite__ : 0/{str(tournoi['limite'])} joueurs *(mise à jour en temps réel)*\n"
464 | f":white_small_square: __Bracket__ : {tournoi['url'] if not tournoi['bulk_mode'] else 'disponible peu de temps avant le début du tournoi'}\n"
465 | f":white_small_square: __Format__ : singles, double élimination (ruleset : <#{gamelist[tournoi['game']]['ruleset']}>)\n\n"
466 | f"Vous pouvez vous inscrire/désinscrire {'en ajoutant/retirant la réaction ✅ à ce message' if tournoi['reaction_mode'] else f'avec les commandes `{bot_prefix}in`/`{bot_prefix}out`'}.\n"
467 | f"*Note : votre **pseudonyme {'sur ce serveur' if tournoi['use_guild_name'] else 'Discord général'}** au moment de l'inscription sera celui utilisé dans le bracket.*"
468 | )
469 |
470 | inscriptions_channel = bot.get_channel(inscriptions_channel_id)
471 |
472 | await inscriptions_channel.purge(limit=None)
473 |
474 | annonce_msg = await inscriptions_channel.send(annonce)
475 | tournoi['annonce_id'] = annonce_msg.id
476 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
477 |
478 | if tournoi['reaction_mode']:
479 | await annonce_msg.add_reaction("✅")
480 |
481 | await annonce_msg.pin()
482 |
483 | ### Inscription
484 | async def inscrire(member):
485 |
486 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
487 |
488 | if (member.id not in participants) and (len(participants) < tournoi['limite']):
489 | participants[member.id] = {
490 | "display_name": member.display_name if tournoi['use_guild_name'] else str(member),
491 | "checked_in": datetime.datetime.now() > tournoi["début_check-in"]
492 | }
493 |
494 | if tournoi["bulk_mode"] == False or datetime.datetime.now() > tournoi["fin_inscription"]:
495 | try:
496 | participants[member.id]["challonge"] = (
497 | await async_http_retry(
498 | achallonge.participants.create,
499 | tournoi["id"],
500 | participants[member.id]["display_name"]
501 | )
502 | )['id']
503 | except ChallongeException:
504 | del participants[member.id]
505 | return
506 |
507 | await member.add_roles(member.guild.get_role(challenger_id))
508 | await update_annonce()
509 | try:
510 | msg = f"Tu t'es inscrit(e) avec succès pour le tournoi **{tournoi['name']}**."
511 | if datetime.datetime.now() > tournoi["début_check-in"]:
512 | msg += " Tu n'as **pas besoin de check-in** comme le tournoi commence bientôt !"
513 | await member.send(msg)
514 | except discord.Forbidden:
515 | pass
516 |
517 | elif tournoi["reaction_mode"] and len(participants) >= tournoi['limite']:
518 | try:
519 | await member.send(f"Il n'y a malheureusement plus de place pour le tournoi **{tournoi['name']}**. "
520 | f"Retente ta chance plus tard, par exemple à la fin du check-in pour remplacer les absents !")
521 | except discord.Forbidden:
522 | pass
523 |
524 | try:
525 | inscription = await bot.get_channel(inscriptions_channel_id).fetch_message(tournoi["annonce_id"])
526 | await inscription.remove_reaction("✅", member)
527 | except (discord.HTTPException, discord.NotFound):
528 | pass
529 |
530 |
531 | ### Désinscription
532 | async def desinscrire(member):
533 |
534 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
535 |
536 | if member.id in participants:
537 |
538 | if tournoi["bulk_mode"] == False or datetime.datetime.now() > tournoi["fin_inscription"]:
539 | await async_http_retry(achallonge.participants.destroy, tournoi['id'], participants[member.id]['challonge'])
540 |
541 | try:
542 | await member.remove_roles(member.guild.get_role(challenger_id))
543 | except discord.HTTPException:
544 | pass
545 |
546 | if datetime.datetime.now() < tournoi["fin_inscription"]:
547 |
548 | del participants[member.id]
549 |
550 | if tournoi['reaction_mode']:
551 | try:
552 | inscription = await bot.get_channel(inscriptions_channel_id).fetch_message(tournoi["annonce_id"])
553 | await inscription.remove_reaction("✅", member)
554 | except (discord.HTTPException, discord.NotFound):
555 | pass
556 |
557 | await update_annonce()
558 |
559 | try:
560 | await member.send(f"Tu es désinscrit(e) du tournoi **{tournoi['name']}**. À une prochaine fois peut-être !")
561 | except discord.Forbidden:
562 | pass
563 |
564 |
565 | ### Mettre à jour l'annonce d'inscription
566 | async def update_annonce():
567 |
568 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
569 | old_annonce = await bot.get_channel(inscriptions_channel_id).fetch_message(tournoi["annonce_id"])
570 | new_annonce = re.sub(r'[0-9]{1,3}\/', str(len(participants)) + '/', old_annonce.content)
571 | await old_annonce.edit(content=new_annonce)
572 |
573 |
574 | ### Début du check-in
575 | async def start_check_in():
576 |
577 | guild = bot.get_guild(id=guild_id)
578 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
579 |
580 | challenger = guild.get_role(challenger_id)
581 |
582 | scheduler.add_job(rappel_check_in, 'interval', id='rappel_check_in', minutes=10, replace_existing=True)
583 |
584 | await bot.get_channel(inscriptions_channel_id).send(f":information_source: Le check-in a commencé dans <#{check_in_channel_id}>. "
585 | f"Vous pouvez toujours vous inscrire ici jusqu'à **{format_time(tournoi['fin_inscription'], format='short', locale=language)}**.\n\n"
586 | f"*Toute personne s'inscrivant à partir de ce moment est **check-in automatiquement**.*")
587 |
588 | await bot.get_channel(check_in_channel_id).send(f"<@&{challenger_id}> Le check-in pour **{tournoi['name']}** a commencé ! "
589 | f"Vous avez jusqu'à **{format_time(tournoi['fin_check-in'], format='short', locale=language)}** pour signaler votre présence :\n"
590 | f":white_small_square: Utilisez `{bot_prefix}in` pour confirmer votre inscription\n:white_small_square: Utilisez `{bot_prefix}out` pour vous désinscrire\n\n"
591 | f"*Si vous n'avez pas check-in à temps, vous serez désinscrit automatiquement du tournoi.*")
592 |
593 | await bot.get_channel(check_in_channel_id).set_permissions(challenger, read_messages=True, send_messages=True, add_reactions=False)
594 |
595 |
596 | ### Rappel de check-in
597 | async def rappel_check_in():
598 |
599 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
600 |
601 | guild = bot.get_guild(id=guild_id)
602 |
603 | rappel_msg = ""
604 |
605 | for inscrit in participants:
606 |
607 | if participants[inscrit]["checked_in"] == False:
608 | rappel_msg += f"- <@{inscrit}>\n"
609 |
610 | if tournoi["fin_check-in"] - datetime.datetime.now() < datetime.timedelta(minutes=10):
611 | try:
612 | await guild.get_member(inscrit).send(f"**Attention !** Il te reste moins d'une dizaine de minutes pour check-in au tournoi **{tournoi['name']}**.")
613 | except discord.Forbidden:
614 | pass
615 |
616 | if rappel_msg == "": return
617 |
618 | await bot.get_channel(check_in_channel_id).send(":clock1: **Rappel de check-in !**")
619 |
620 | if len(rappel_msg) < 2000:
621 | await bot.get_channel(check_in_channel_id).send(rappel_msg)
622 | else: # Discord doesn't deal with more than 2000 characters
623 | rappel_msg = [x.strip() for x in rappel_msg.split('\n') if x.strip() != ''] # so we have to split
624 | while rappel_msg:
625 | await bot.get_channel(check_in_channel_id).send('\n'.join(rappel_msg[:50]))
626 | del rappel_msg[:50] # and send by groups of 50 people
627 |
628 | await bot.get_channel(check_in_channel_id).send(f"*Vous avez jusqu'à {format_time(tournoi['fin_check-in'], format='short', locale=language)}, sinon vous serez désinscrit(s) automatiquement.*")
629 |
630 |
631 | ### Fin du check-in
632 | async def end_check_in():
633 |
634 | guild = bot.get_guild(id=guild_id)
635 |
636 | await bot.get_channel(check_in_channel_id).set_permissions(guild.get_role(challenger_id), read_messages=True, send_messages=False, add_reactions=False)
637 | await bot.get_channel(check_in_channel_id).send(":clock1: **Le check-in est terminé :**\n"
638 | ":white_small_square: Les personnes n'ayant pas check-in vont être retirées du tournoi.\n"
639 | ":white_small_square: Rappel : une inscription après le début du check-in ne néccessite pas de check-in.")
640 |
641 | try:
642 | scheduler.remove_job('rappel_check_in')
643 | except JobLookupError:
644 | pass
645 |
646 | for inscrit in list(participants):
647 | try:
648 | if participants[inscrit]["checked_in"] == False:
649 | await desinscrire(guild.get_member(inscrit))
650 | except KeyError:
651 | pass
652 |
653 | await bot.get_channel(inscriptions_channel_id).send(":information_source: **Les absents du check-in ont été retirés** : "
654 | "des places sont peut-être libérées pour des inscriptions de dernière minute.\n")
655 |
656 |
657 | ### Fin des inscriptions
658 | async def end_inscription():
659 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
660 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
661 |
662 | if tournoi["reaction_mode"]:
663 | annonce = await bot.get_channel(inscriptions_channel_id).fetch_message(tournoi["annonce_id"])
664 | await annonce.clear_reaction("✅")
665 | else:
666 | guild = bot.get_guild(id=guild_id)
667 | inscriptions_role = guild.get_role(gamelist[tournoi['game']]['role']) if tournoi["restrict_to_role"] else guild.default_role
668 | await bot.get_channel(inscriptions_channel_id).set_permissions(inscriptions_role, read_messages=True, send_messages=False, add_reactions=False)
669 |
670 | await bot.get_channel(inscriptions_channel_id).send(":clock1: **Les inscriptions sont fermées :** le bracket est désormais en cours de finalisation.")
671 |
672 | if tournoi["bulk_mode"]:
673 | await seed_participants(participants)
674 |
675 | try:
676 | scheduler.remove_job('dump_participants')
677 | except JobLookupError:
678 | pass
679 | finally:
680 | dump_participants()
681 |
682 |
683 | async def check_in(member):
684 | participants[member.id]["checked_in"] = True
685 | try:
686 | await member.send("Tu as été check-in avec succès. Tu n'as plus qu'à patienter jusqu'au début du tournoi !")
687 | except discord.Forbidden:
688 | pass
689 |
690 |
691 | ### Prise en charge des inscriptions, désinscriptions, check-in et check-out
692 | @bot.command(aliases=['in', 'out'])
693 | @commands.check(inscriptions_still_open)
694 | @commands.max_concurrency(1, wait=True)
695 | async def participants_management(ctx):
696 |
697 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
698 |
699 | if ctx.invoked_with == 'out':
700 |
701 | if ctx.channel.id in [check_in_channel_id, inscriptions_channel_id, inscriptions_vip_channel_id] and ctx.author.id in participants:
702 | await desinscrire(ctx.author)
703 | await ctx.message.add_reaction("✅")
704 |
705 | else:
706 | await ctx.message.add_reaction("🚫")
707 |
708 | elif ctx.invoked_with == 'in':
709 |
710 | if ctx.channel.id == check_in_channel_id and ctx.author.id in participants and tournoi["fin_check-in"] > datetime.datetime.now() > tournoi["début_check-in"]:
711 | await check_in(ctx.author)
712 | await ctx.message.add_reaction("✅")
713 |
714 | elif ctx.channel.id == inscriptions_channel_id and ctx.author.id not in participants and len(participants) < tournoi['limite']:
715 | await inscrire(ctx.author)
716 | await ctx.message.add_reaction("✅")
717 |
718 | elif ctx.channel.id == inscriptions_vip_channel_id and ctx.author.id not in participants and len(participants) < tournoi['limite']:
719 | await inscrire(ctx.author)
720 | await ctx.message.add_reaction("✅")
721 |
722 | else:
723 | await ctx.message.add_reaction("🚫")
724 |
725 |
726 | ### Nettoyer les channels liés aux tournois
727 | async def purge_channels():
728 | guild = bot.get_guild(id=guild_id)
729 |
730 | for channel_id in [check_in_channel_id, queue_channel_id, scores_channel_id]:
731 | channel = guild.get_channel(channel_id)
732 | await channel.purge(limit=None)
733 |
734 |
735 | ### Nettoyer les catégories liées aux tournois
736 | async def purge_categories():
737 | guild = bot.get_guild(id=guild_id)
738 |
739 | for category in [cat for cat in guild.categories if cat.name.lower() in ["winner bracket", "looser bracket"]]:
740 | for channel in category.channels: await channel.delete() # first, delete the channels
741 | await category.delete() # then delete the category
742 |
743 |
744 | ### Nettoyer les rôles liés aux tournois
745 | async def purge_roles():
746 | guild = bot.get_guild(id=guild_id)
747 | challenger = guild.get_role(challenger_id)
748 |
749 | for member in challenger.members:
750 | try:
751 | await member.remove_roles(challenger)
752 | except (discord.HTTPException, discord.Forbidden):
753 | pass
754 |
755 |
756 | ### Affiche le bracket en cours
757 | @bot.command(name='bracket')
758 | @commands.check(tournament_is_underway_or_pending)
759 | async def post_bracket(ctx):
760 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
761 | await ctx.send(f"{server_logo} **{tournoi['name']}** : {tournoi['url']}")
762 |
763 |
764 | ### Pile/face basique
765 | @bot.command(name='flip', aliases=['flipcoin', 'coinflip', 'coin'])
766 | async def flipcoin(ctx):
767 | await ctx.send(f"<@{ctx.author.id}> {random.choice(['Tu commences à faire les bans.', 'Ton adversaire commence à faire les bans.'])}")
768 |
769 |
770 | ### Ajout manuel
771 | @bot.command(name='add')
772 | @commands.check(is_owner_or_to)
773 | @commands.check(tournament_is_pending)
774 | async def add_inscrit(ctx):
775 | for member in ctx.message.mentions:
776 | await inscrire(member)
777 | dump_participants()
778 | await ctx.message.add_reaction("✅")
779 |
780 |
781 | ### Suppression/DQ manuel
782 | @bot.command(name='rm')
783 | @commands.check(is_owner_or_to)
784 | @commands.check(tournament_is_underway_or_pending)
785 | async def remove_inscrit(ctx):
786 | for member in ctx.message.mentions:
787 | await desinscrire(member)
788 | dump_participants()
789 | await ctx.message.add_reaction("✅")
790 |
791 |
792 | ### Se DQ soi-même
793 | @bot.command(name='dq')
794 | @commands.has_role(challenger_id)
795 | @commands.check(tournament_is_underway)
796 | @commands.cooldown(1, 30, type=commands.BucketType.user)
797 | @commands.max_concurrency(1, wait=True)
798 | async def self_dq(ctx):
799 | await desinscrire(ctx.author)
800 | await ctx.message.add_reaction("✅")
801 |
802 |
803 | ### Managing sets during tournament : launch & remind
804 | ### Goal : get the bracket only once to limit API calls
805 | async def underway_tournament():
806 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
807 | guild = bot.get_guild(id=guild_id)
808 | bracket = await async_http_retry(achallonge.matches.index, tournoi["id"], state='open')
809 | await launch_matches(guild, bracket)
810 | await call_stream(guild, bracket)
811 | await rappel_matches(guild, bracket)
812 | await clean_channels(guild, bracket)
813 |
814 |
815 | ### Gestion des scores
816 | @bot.command(name='win')
817 | @in_channel(scores_channel_id)
818 | @commands.check(tournament_is_underway)
819 | @commands.has_role(challenger_id)
820 | @commands.cooldown(1, 30, type=commands.BucketType.user)
821 | @commands.max_concurrency(1, wait=True)
822 | async def score_match(ctx, arg):
823 |
824 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
825 |
826 | winner = participants[ctx.author.id]["challonge"] # Le gagnant est celui qui poste
827 |
828 | try:
829 | match = await async_http_retry(
830 | achallonge.matches.index,
831 | tournoi['id'],
832 | state='open',
833 | participant_id=winner
834 | )
835 | except ChallongeException:
836 | await ctx.message.add_reaction("🕐")
837 | await ctx.send(f"<@{ctx.author.id}> Dû à une coupure de Challonge, je n'ai pas pu récupérer les données du set. Merci de retenter dans quelques instants.")
838 | return
839 |
840 | try:
841 | if match[0]["underway_at"] == None:
842 | await ctx.message.add_reaction("⚠️")
843 | await ctx.send(f"<@{ctx.author.id}> Le set pour lequel tu as donné le score n'a **pas encore commencé** !")
844 | return
845 | except IndexError:
846 | await ctx.message.add_reaction("⚠️")
847 | await ctx.send(f"<@{ctx.author.id}> Tu n'as pas de set prévu pour le moment, il n'y a donc pas de score à rentrer.")
848 | return
849 |
850 | try:
851 | score = re.search(r'([0-9]+) *\- *([0-9]+)', arg).group().replace(" ", "")
852 | except AttributeError:
853 | await ctx.message.add_reaction("⚠️")
854 | await ctx.send(f"<@{ctx.author.id}> **Ton score ne possède pas le bon format** *(3-0, 2-1, 3-2...)*, merci de le rentrer à nouveau.")
855 | return
856 |
857 | if score[0] < score[2]: score = score[::-1] # Le premier chiffre doit être celui du gagnant
858 |
859 | if is_bo5(match[0]["round"]):
860 | aimed_score, looser_score, temps_min = 3, [0, 1, 2], 10
861 | else:
862 | aimed_score, looser_score, temps_min = 2, [0, 1], 5
863 |
864 | debut_set = dateutil.parser.parse(str(match[0]["underway_at"])).replace(tzinfo=None)
865 |
866 | if int(score[0]) != aimed_score or int(score[2]) not in looser_score:
867 | await ctx.message.add_reaction("⚠️")
868 | await ctx.send(f"<@{ctx.author.id}> **Score incorrect**, vérifiez par exemple si le set doit se jouer en BO3 ou BO5.")
869 | return
870 |
871 | if datetime.datetime.now() - debut_set < datetime.timedelta(minutes = temps_min):
872 | await ctx.message.add_reaction("⚠️")
873 | await ctx.send(f"<@{ctx.author.id}> **Temps écoulé trop court** pour qu'un résultat soit déjà rentré pour le set.")
874 | return
875 |
876 | for joueur in participants:
877 | if participants[joueur]["challonge"] == match[0]["player2_id"]:
878 | player2 = joueur
879 | break
880 |
881 | og_score = score
882 |
883 | if winner == participants[player2]["challonge"]:
884 | score = score[::-1] # Le score doit suivre le format "player1-player2" pour scores_csv
885 |
886 | try:
887 | await async_http_retry(
888 | achallonge.matches.update,
889 | tournoi['id'],
890 | match[0]['id'],
891 | scores_csv=score,
892 | winner_id=winner
893 | )
894 | await ctx.message.add_reaction("✅")
895 |
896 | except ChallongeException:
897 | await ctx.message.add_reaction("🕐")
898 | await ctx.send(f"<@{ctx.author.id}> Dû à une coupure de Challonge, je n'ai pas pu envoyer ton score. Merci de retenter dans quelques instants.")
899 |
900 | else:
901 | gaming_channel = discord.utils.get(ctx.guild.text_channels, name=str(match[0]["suggested_play_order"]))
902 |
903 | if gaming_channel != None:
904 | await gaming_channel.send(f":bell: __Score rapporté__ : **{participants[ctx.author.id]['display_name']}** gagne **{og_score}** !\n"
905 | f"*En cas d'erreur, appelez un TO ! Un mauvais score intentionnel est passable de DQ et ban du tournoi.*\n"
906 | f"*Note : ce channel sera automatiquement supprimé 5 minutes à partir de la dernière activité.*")
907 |
908 |
909 | ### Clean channels
910 | async def clean_channels(guild, bracket):
911 |
912 | play_orders = [match['suggested_play_order'] for match in bracket]
913 |
914 | for category, channels in guild.by_category():
915 | # Category must be a tournament category
916 | if category != None and category.name.lower() in ["winner bracket", "looser bracket"]:
917 | for channel in channels:
918 | # Channel names correspond to a suggested play order
919 | if int(channel.name) not in play_orders: # If the channel is not useful anymore
920 | last_message = await channel.fetch_message(channel.last_message_id)
921 | # Remove the channel if the last message is more than 5 minutes old
922 | now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
923 | if now - last_message.created_at > datetime.timedelta(minutes = 5):
924 | try:
925 | await channel.delete()
926 | except (discord.NotFound, discord.HTTPException):
927 | pass
928 |
929 |
930 | ### Forfeit
931 | @bot.command(name='forfeit', aliases=['ff', 'loose'])
932 | @commands.check(tournament_is_underway)
933 | @commands.has_role(challenger_id)
934 | @commands.cooldown(1, 120, type=commands.BucketType.user)
935 | @commands.max_concurrency(1, wait=True)
936 | async def forfeit_match(ctx):
937 |
938 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
939 |
940 | looser = participants[ctx.author.id]["challonge"]
941 |
942 | try:
943 | match = await async_http_retry(
944 | achallonge.matches.index,
945 | tournoi['id'],
946 | state='open',
947 | participant_id=looser
948 | )
949 | except ChallongeException:
950 | await ctx.message.add_reaction("⚠️")
951 | return
952 |
953 | try:
954 | for joueur in participants:
955 | if participants[joueur]["challonge"] == match[0]["player1_id"]: player1 = joueur
956 | if participants[joueur]["challonge"] == match[0]["player2_id"]: player2 = joueur
957 | except IndexError:
958 | return
959 |
960 | if looser == participants[player2]["challonge"]:
961 | winner, score = participants[player1]["challonge"], "1-0"
962 | else:
963 | winner, score = participants[player2]["challonge"], "0-1"
964 |
965 | try:
966 | await async_http_retry(
967 | achallonge.matches.update,
968 | tournoi['id'],
969 | match[0]['id'],
970 | scores_csv=score,
971 | winner_id=winner
972 | )
973 | except ChallongeException:
974 | await ctx.message.add_reaction("⚠️")
975 | else:
976 | await ctx.message.add_reaction("✅")
977 |
978 |
979 | ### Get and return a category
980 | async def get_available_category(match_round):
981 | guild = bot.get_guild(id=guild_id)
982 | desired_cat = 'winner bracket' if match_round > 0 else 'looser bracket'
983 |
984 | # by_category() doesn't return a category if it has no channels, so we use a list comprehension
985 | for category in [cat for cat in guild.categories if cat.name.lower() == desired_cat and len(cat.channels) < 50]:
986 | return category
987 |
988 | else:
989 | return await guild.create_category(
990 | name=desired_cat,
991 | reason='Since no category was available, a new one was created',
992 | position=guild.get_channel(tournoi_cat_id).position + 1
993 | )
994 |
995 |
996 | ### Lancer matchs ouverts
997 | async def launch_matches(guild, bracket):
998 |
999 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1000 |
1001 | sets = ""
1002 |
1003 | for match in [x for x in bracket if x["underway_at"] == None][:20:]: # "Only" lauch 20 max at once
1004 |
1005 | await async_http_retry(achallonge.matches.mark_as_underway, tournoi["id"], match["id"])
1006 |
1007 | for joueur in participants:
1008 | if participants[joueur]["challonge"] == match["player1_id"]: player1 = guild.get_member(joueur)
1009 | if participants[joueur]["challonge"] == match["player2_id"]: player2 = guild.get_member(joueur)
1010 |
1011 | top_8 = "(**top 8**) :fire:" if is_top8(match["round"]) else ""
1012 |
1013 | # Création d'un channel volatile pour le set
1014 | try:
1015 | gaming_channel = await guild.create_text_channel(
1016 | str(match["suggested_play_order"]),
1017 | overwrites = {
1018 | guild.default_role: discord.PermissionOverwrite(read_messages=False),
1019 | guild.get_role(to_id): discord.PermissionOverwrite(read_messages=True),
1020 | guild.get_role(streamer_id): discord.PermissionOverwrite(read_messages=True),
1021 | player1: discord.PermissionOverwrite(read_messages=True),
1022 | player2: discord.PermissionOverwrite(read_messages=True)
1023 | },
1024 | category = await get_available_category(match['round']),
1025 | topic = "Channel temporaire pour un set.",
1026 | reason = f"Lancement du set n°{match['suggested_play_order']}"
1027 | )
1028 |
1029 | except discord.HTTPException:
1030 | gaming_channel_txt = f":video_game: Je n'ai pas pu créer de channel, faites votre set en MP ou dans <#{tournoi_channel_id}>."
1031 |
1032 | if is_queued_for_stream(match["suggested_play_order"]):
1033 | await player1.send(f"Tu joueras on stream pour ton prochain set contre **{player2.display_name}** : je te communiquerai les codes d'accès quand ce sera ton tour.")
1034 | await player2.send(f"Tu joueras on stream pour ton prochain set contre **{player1.display_name}** : je te communiquerai les codes d'accès quand ce sera ton tour.")
1035 |
1036 | else:
1037 | gaming_channel_txt = f":video_game: Allez faire votre set dans le channel <#{gaming_channel.id}> !"
1038 |
1039 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1040 |
1041 | gaming_channel_annonce = (f":arrow_forward: **{nom_round(match['round'])}** : <@{player1.id}> vs <@{player2.id}> {top_8}\n"
1042 | f":white_small_square: Les règles du set doivent suivre celles énoncées dans <#{gamelist[tournoi['game']]['ruleset']}>.\n"
1043 | f":white_small_square: La liste des stages légaux à l'heure actuelle est disponible via la commande `{bot_prefix}stages`.\n"
1044 | f":white_small_square: En cas de lag qui rend la partie injouable, utilisez la commande `{bot_prefix}lag` pour résoudre la situation.\n"
1045 | f":white_small_square: **Dès que le set est terminé**, le gagnant envoie le score dans <#{scores_channel_id}> avec la commande `{bot_prefix}win`.\n\n"
1046 | f":game_die: **{random.choice([player1.display_name, player2.display_name])}** est tiré au sort pour commencer le ban des stages *({gamelist[tournoi['game']]['ban_instruction']})*.\n")
1047 |
1048 | if tournoi["game"] == "Project+":
1049 | gaming_channel_annonce += f"{gamelist[tournoi['game']]['icon']} **Minimum buffer suggéré** : le host peut le faire calculer avec la commande `{bot_prefix}buffer [ping]`.\n"
1050 |
1051 | if is_bo5(match["round"]):
1052 | gaming_channel_annonce += ":five: Vous devez jouer ce set en **BO5** *(best of five)*.\n"
1053 | else:
1054 | gaming_channel_annonce += ":three: Vous devez jouer ce set en **BO3** *(best of three)*.\n"
1055 |
1056 | if not is_top8(match["round"]):
1057 | scheduler.add_job(
1058 | check_channel_activity,
1059 | id = f'check activity of set {gaming_channel.name}',
1060 | args = [gaming_channel, player1, player2],
1061 | run_date = datetime.datetime.now() + datetime.timedelta(minutes = tournoi["check_channel_presence"])
1062 | )
1063 |
1064 | if is_queued_for_stream(match["suggested_play_order"]):
1065 | gaming_channel_annonce += ":tv: **Vous jouerez on stream**. Dès que ce sera votre tour, je vous communiquerai les codes d'accès."
1066 |
1067 | await gaming_channel.send(gaming_channel_annonce)
1068 |
1069 | on_stream = "(**on stream**) :tv:" if is_queued_for_stream(match["suggested_play_order"]) else ""
1070 | bo_type = 'BO5' if is_bo5(match['round']) else 'BO3'
1071 |
1072 | sets += f":arrow_forward: **{nom_round(match['round'])}** ({bo_type}) : <@{player1.id}> vs <@{player2.id}> {on_stream}\n{gaming_channel_txt} {top_8}\n\n"
1073 |
1074 | if sets != "":
1075 | if len(sets) < 2000:
1076 | await bot.get_channel(queue_channel_id).send(sets)
1077 | else: # Discord doesn't deal with more than 2000 characters
1078 | sets = [x.strip() for x in sets.split('\n\n') if x.strip() != ''] # so we have to split
1079 | while sets:
1080 | await bot.get_channel(queue_channel_id).send('\n\n'.join(sets[:10]))
1081 | del sets[:10] # and send by groups of ten sets
1082 |
1083 |
1084 | async def check_channel_activity(channel, player1, player2):
1085 | player1_is_active, player2_is_active = False, False
1086 |
1087 | try:
1088 | async for message in channel.history():
1089 | if message.author.id == player1.id:
1090 | player1_is_active = True
1091 | if message.author.id == player2.id:
1092 | player2_is_active = True
1093 | if player1_is_active and player2_is_active:
1094 | return
1095 | except discord.NotFound:
1096 | return
1097 |
1098 | if player1_is_active == False:
1099 | await channel.send(f":timer: **DQ automatique de <@{player1.id}> pour inactivité** : aucune manifestation à temps du joueur.")
1100 | await desinscrire(player1)
1101 | await bot.get_channel(to_channel_id).send(f":information_source: **DQ automatique** de <@{player1.id}> pour inactivité, set n°{channel.name}.")
1102 | await player1.send("Désolé, tu as été DQ automatiquement car tu n'as pas été actif sur ton channel de set dans les premières minutes qui ont suivi son lancement.")
1103 |
1104 | if player2_is_active == False:
1105 | await channel.send(f":timer: **DQ automatique de <@{player2.id}> pour inactivité** : aucune manifestation à temps du joueur.")
1106 | await desinscrire(player2)
1107 | await bot.get_channel(to_channel_id).send(f":information_source: **DQ automatique** de <@{player2.id}> pour inactivité, set n°{channel.name}.")
1108 | await player2.send("Désolé, tu as été DQ automatiquement car tu n'as pas été actif sur ton channel de set dans les premières minutes qui ont suivi son lancement.")
1109 |
1110 |
1111 | @bot.command(name='initstream', aliases=['is'])
1112 | @commands.has_role(streamer_id)
1113 | @commands.check(tournament_is_underway_or_pending)
1114 | async def init_stream(ctx, arg):
1115 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1116 |
1117 | if re.compile(r"^(https?\:\/\/)?(www.twitch.tv)\/.+$").match(arg):
1118 | stream[ctx.author.id] = {
1119 | 'channel': arg.replace("https://www.twitch.tv/", ""),
1120 | 'access': ['N/A', 'N/A'],
1121 | 'on_stream': None,
1122 | 'queue': []
1123 | }
1124 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1125 | await ctx.message.add_reaction("✅")
1126 | else:
1127 | await ctx.message.add_reaction("🔗")
1128 |
1129 |
1130 | @bot.command(name='stopstream')
1131 | @commands.has_role(streamer_id)
1132 | @commands.check(tournament_is_underway_or_pending)
1133 | @commands.check(is_streaming)
1134 | async def stop_stream(ctx):
1135 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1136 | del stream[ctx.author.id]
1137 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1138 | await ctx.message.add_reaction("✅")
1139 |
1140 |
1141 | @bot.command(name='stream', aliases=['twitch', 'tv'])
1142 | @commands.check(tournament_is_underway_or_pending)
1143 | async def post_stream(ctx):
1144 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1145 |
1146 | if len(stream) == 0:
1147 | await ctx.send(f"<@{ctx.author.id}> Il n'y a pas de stream en cours (ou prévu) pour ce tournoi à l'heure actuelle.")
1148 |
1149 | elif len(stream) == 1:
1150 | await ctx.send(f"<@{ctx.author.id}> https://www.twitch.tv/{stream[next(iter(stream))]['channel']}")
1151 |
1152 | else:
1153 | multitwitch = 'http://www.multitwitch.tv/' + '/'.join([stream[x]['channel'] for x in stream])
1154 | await ctx.send(f"<@{ctx.author.id}> {multitwitch}")
1155 |
1156 |
1157 | ### Ajout ID et MDP d'arène de stream
1158 | @bot.command(name='setstream', aliases=['ss'])
1159 | @commands.has_role(streamer_id)
1160 | @commands.check(tournament_is_underway_or_pending)
1161 | @commands.check(is_streaming)
1162 | async def setup_stream(ctx, *args):
1163 |
1164 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1165 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1166 |
1167 | if tournoi['game'] == 'Super Smash Bros. Ultimate' and len(args) == 2:
1168 | stream[ctx.author.id]["access"] = args
1169 |
1170 | elif tournoi['game'] == 'Project+' and len(args) == 1:
1171 | stream[ctx.author.id]["access"] = args
1172 |
1173 | else:
1174 | await ctx.message.add_reaction("⚠️")
1175 | await ctx.send(f"<@{ctx.author.id}> Paramètres invalides pour le jeu **{tournoi['game']}**.")
1176 | return
1177 |
1178 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1179 | await ctx.message.add_reaction("✅")
1180 |
1181 |
1182 | ### Ajouter un set dans la stream queue
1183 | @bot.command(name='addstream', aliases=['as'])
1184 | @commands.has_role(streamer_id)
1185 | @commands.check(tournament_is_underway_or_pending)
1186 | @commands.check(is_streaming)
1187 | @commands.max_concurrency(1, wait=True)
1188 | async def add_stream(ctx, *args: int):
1189 |
1190 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1191 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1192 |
1193 | # Pre-add before the tournament goes underway - BE CAREFUL!
1194 | if tournoi["statut"] == "pending":
1195 | for arg in args: stream[ctx.author.id]["queue"].append(arg)
1196 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1197 | await ctx.message.add_reaction("✅")
1198 | await ctx.send(f"<@{ctx.author.id}> Sets ajoutés à la stream queue : toutefois ils n'ont pas été vérifiés, le bracket n'ayant pas commencé.")
1199 | return
1200 |
1201 | # Otherwise we should check if the sets are open
1202 | try:
1203 | bracket = await async_http_retry(achallonge.matches.index, tournoi['id'], state=('open', 'pending'))
1204 | except ChallongeException:
1205 | await ctx.message.add_reaction("🕐")
1206 | return
1207 |
1208 | for arg in args:
1209 | for match in bracket:
1210 | if (match["suggested_play_order"] == arg) and (match["underway_at"] == None) and (not is_queued_for_stream(arg)):
1211 | stream[ctx.author.id]["queue"].append(arg)
1212 | break
1213 |
1214 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1215 | await ctx.message.add_reaction("✅")
1216 |
1217 |
1218 | ### Enlever un set de la stream queue
1219 | @bot.command(name='rmstream', aliases=['rs'])
1220 | @commands.has_role(streamer_id)
1221 | @commands.check(tournament_is_underway_or_pending)
1222 | @commands.check(is_streaming)
1223 | async def remove_stream(ctx, *args: int):
1224 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1225 |
1226 | try:
1227 | for arg in args: stream[ctx.author.id]["queue"].remove(arg)
1228 | except ValueError:
1229 | await ctx.message.add_reaction("⚠️")
1230 | else:
1231 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1232 | await ctx.message.add_reaction("✅")
1233 |
1234 |
1235 | ### Interchanger 2 sets de la stream queue
1236 | @bot.command(name='swapstream', aliases=['sws'])
1237 | @commands.has_role(streamer_id)
1238 | @commands.check(tournament_is_underway_or_pending)
1239 | @commands.check(is_streaming)
1240 | async def swap_stream(ctx, arg1: int, arg2: int):
1241 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1242 |
1243 | try:
1244 | x, y = stream[ctx.author.id]["queue"].index(arg1), stream[ctx.author.id]["queue"].index(arg2)
1245 | except ValueError:
1246 | await ctx.message.add_reaction("⚠️")
1247 | else:
1248 | stream[ctx.author.id]["queue"][y], stream[ctx.author.id]["queue"][x] = stream[ctx.author.id]["queue"][x], stream[ctx.author.id]["queue"][y]
1249 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1250 | await ctx.message.add_reaction("✅")
1251 |
1252 |
1253 | ### Infos stream
1254 | @bot.command(name='mystream', aliases=['ms'])
1255 | @commands.has_role(streamer_id)
1256 | @commands.check(tournament_is_underway_or_pending)
1257 | @commands.check(is_streaming)
1258 | @commands.max_concurrency(1, wait=True)
1259 | async def list_stream(ctx):
1260 |
1261 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1262 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1263 |
1264 | try:
1265 | bracket = await async_http_retry(achallonge.matches.index, tournoi['id'], state=('open', 'pending'))
1266 | except ChallongeException:
1267 | await ctx.message.add_reaction("🕐")
1268 | return
1269 |
1270 | msg = f":information_source: Codes d'accès au stream **{stream[ctx.author.id]['channel']}** :\n{get_access_stream(stream[ctx.author.id]['access'])}\n"
1271 |
1272 | try:
1273 | match = bracket[[x["suggested_play_order"] for x in bracket].index(stream[ctx.author.id]['on_stream'])]
1274 | except KeyError: # bracket is empty
1275 | msg += ":stop_button: Le tournoi n'est probablement pas en cours.\n"
1276 | except ValueError: # on stream not found
1277 | msg += ":stop_button: Aucun set on stream à l'heure actuelle.\n"
1278 | else:
1279 | for joueur in participants:
1280 | if participants[joueur]["challonge"] == match["player1_id"]: player1 = participants[joueur]['display_name']
1281 | if participants[joueur]["challonge"] == match["player2_id"]: player2 = participants[joueur]['display_name']
1282 |
1283 | msg += f":arrow_forward: **Set on stream actuel** *({match['suggested_play_order']})* : **{player1}** vs **{player2}**\n"
1284 |
1285 | list_stream = ""
1286 |
1287 | for order in stream[ctx.author.id]['queue']:
1288 | for match in bracket:
1289 | if match["suggested_play_order"] == order:
1290 |
1291 | player1, player2 = "(?)", "(?)"
1292 | for joueur in participants:
1293 | if participants[joueur]["challonge"] == match["player1_id"]:
1294 | player1 = participants[joueur]['display_name']
1295 | if participants[joueur]["challonge"] == match["player2_id"]:
1296 | player2 = participants[joueur]['display_name']
1297 |
1298 | list_stream += f":white_small_square: **{match['suggested_play_order']}** : *{player1}* vs *{player2}*\n"
1299 | break
1300 |
1301 | if list_stream != "":
1302 | msg += f":play_pause: Liste des sets prévus pour passer on stream :\n{list_stream}"
1303 | else:
1304 | msg += ":play_pause: Il n'y a aucun set prévu pour passer on stream."
1305 |
1306 | await ctx.send(msg)
1307 |
1308 |
1309 | ### Appeler les joueurs on stream
1310 | async def call_stream(guild, bracket):
1311 |
1312 | with open(stream_path, 'r+') as f: stream = json.load(f, object_pairs_hook=int_keys)
1313 |
1314 | play_orders = [match["suggested_play_order"] for match in bracket]
1315 |
1316 | for streamer in stream:
1317 |
1318 | # If current on stream set is still open, then it's not finished
1319 | if stream[streamer]["on_stream"] in play_orders: continue
1320 |
1321 | try:
1322 | match = bracket[play_orders.index(stream[streamer]["queue"][0])]
1323 | except (IndexError, ValueError): # stream queue is empty / match could be pending
1324 | continue
1325 | else: # wait for the match to be marked as underway
1326 | if match["underway_at"] == None: continue
1327 |
1328 | for joueur in participants:
1329 | if participants[joueur]["challonge"] == match["player1_id"]: player1 = guild.get_member(joueur)
1330 | if participants[joueur]["challonge"] == match["player2_id"]: player2 = guild.get_member(joueur)
1331 |
1332 | gaming_channel = discord.utils.get(guild.text_channels, name=str(match["suggested_play_order"]))
1333 |
1334 | if gaming_channel == None:
1335 | dm_msg = f"C'est ton tour de passer on stream ! Voici les codes d'accès :\n{get_access_stream(stream[streamer]['access'])}"
1336 | await player1.send(dm_msg)
1337 | await player2.send(dm_msg)
1338 | else:
1339 | await gaming_channel.send(f"<@{player1.id}> <@{player2.id}>\n" # ping them
1340 | f":clapper: Vous pouvez passer on stream sur la chaîne **{stream[streamer]['channel']}** ! "
1341 | f"Voici les codes d'accès :\n{get_access_stream(stream[streamer]['access'])}")
1342 |
1343 | await bot.get_channel(stream_channel_id).send(f":arrow_forward: Envoi on stream du set n°{match['suggested_play_order']} chez **{stream[streamer]['channel']}** : "
1344 | f"**{participants[player1.id]['display_name']}** vs **{participants[player2.id]['display_name']}** !")
1345 |
1346 | stream[streamer]["on_stream"] = match["suggested_play_order"]
1347 |
1348 | while match["suggested_play_order"] in stream[streamer]["queue"]:
1349 | stream[streamer]["queue"].remove(match["suggested_play_order"])
1350 |
1351 | with open(stream_path, 'w') as f: json.dump(stream, f, indent=4)
1352 |
1353 |
1354 | ### Calculer les rounds à partir desquels un set est top 8 (bracket D.E.)
1355 | async def calculate_top8():
1356 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1357 | bracket = await async_http_retry(achallonge.matches.index, tournoi['id'], state=("open", "pending"))
1358 |
1359 | # Get all rounds from bracket
1360 | rounds = [match["round"] for match in bracket]
1361 |
1362 | # Calculate top 8
1363 | tournoi["round_winner_top8"] = max(rounds) - 2
1364 | tournoi["round_looser_top8"] = min(rounds) + 3
1365 |
1366 | # Minimal values, in case of a small tournament
1367 | if tournoi["round_winner_top8"] < 1: tournoi["round_winner_top8"] = 1
1368 | if tournoi["round_looser_top8"] > -1: tournoi["round_looser_top8"] = -1
1369 |
1370 | # Calculate start_bo5
1371 | if tournoi["start_bo5"] > 0:
1372 | tournoi["round_winner_bo5"] = tournoi["round_winner_top8"] + tournoi["start_bo5"] - 1
1373 | elif tournoi["start_bo5"] in [0, -1]:
1374 | tournoi["round_winner_bo5"] = tournoi["round_winner_top8"] + tournoi["start_bo5"]
1375 | else:
1376 | tournoi["round_winner_bo5"] = tournoi["round_winner_top8"] + tournoi["start_bo5"] + 1
1377 |
1378 | if tournoi["start_bo5"] > 1:
1379 | tournoi["round_looser_bo5"] = min(rounds) # top 3 is LF anyway
1380 | else:
1381 | tournoi["round_looser_bo5"] = tournoi["round_looser_top8"] - tournoi["start_bo5"]
1382 |
1383 | # Avoid aberrant values
1384 | if tournoi["round_winner_bo5"] > max(rounds): tournoi["round_winner_bo5"] = max(rounds)
1385 | if tournoi["round_winner_bo5"] < 1: tournoi["round_winner_bo5"] = 1
1386 | if tournoi["round_looser_bo5"] < min(rounds): tournoi["round_looser_bo5"] = min(rounds)
1387 | if tournoi["round_looser_bo5"] > -1: tournoi["round_looser_bo5"] = -1
1388 |
1389 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
1390 |
1391 |
1392 | ### Lancer un rappel de matchs
1393 | async def rappel_matches(guild, bracket):
1394 |
1395 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1396 |
1397 | for match in bracket:
1398 |
1399 | if (match["underway_at"] != None) and (not is_queued_for_stream(match["suggested_play_order"])) and (not is_on_stream(match["suggested_play_order"])):
1400 |
1401 | debut_set = dateutil.parser.parse(str(match["underway_at"])).replace(tzinfo=None)
1402 |
1403 | if tournoi['game'] == 'Super Smash Bros. Ultimate':
1404 | seuil = 42 if is_bo5(match["round"]) else 28 # Calculé selon (tps max match * nb max matchs) + 7 minutes
1405 | elif tournoi['game'] == 'Project+':
1406 | seuil = 47 if is_bo5(match["round"]) else 31 # Idem
1407 | else:
1408 | return
1409 |
1410 | if datetime.datetime.now() - debut_set > datetime.timedelta(minutes = seuil):
1411 |
1412 | gaming_channel = discord.utils.get(guild.text_channels, name=str(match["suggested_play_order"]))
1413 |
1414 | if gaming_channel != None:
1415 |
1416 | for joueur in participants:
1417 | if participants[joueur]["challonge"] == match["player1_id"]: player1 = guild.get_member(joueur)
1418 | if participants[joueur]["challonge"] == match["player2_id"]: player2 = guild.get_member(joueur)
1419 |
1420 | # Avertissement unique
1421 | if match["suggested_play_order"] not in tournoi["warned"]:
1422 |
1423 | tournoi["warned"].append(match["suggested_play_order"])
1424 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
1425 |
1426 | alerte = (f":timer: **Ce set n'a toujours pas reçu de score !** <@{player1.id}> <@{player2.id}>\n"
1427 | f":white_small_square: Le gagnant du set est prié de le poster dans <#{scores_channel_id}> dès que possible.\n"
1428 | f":white_small_square: Dans une dizaine de minutes, les TOs seront alertés qu'une décision doit être prise.\n"
1429 | f":white_small_square: Si une personne est détectée comme inactive, elle sera **DQ automatiquement** du tournoi.\n")
1430 |
1431 | await gaming_channel.send(alerte)
1432 |
1433 | # DQ pour inactivité (exceptionnel...) -> fixé à 10 minutes après l'avertissement
1434 | elif (match["suggested_play_order"] not in tournoi["timeout"]) and (datetime.datetime.now() - debut_set > datetime.timedelta(minutes = seuil + 10)):
1435 |
1436 | tournoi["timeout"].append(match["suggested_play_order"])
1437 | with open(tournoi_path, 'w') as f: json.dump(tournoi, f, indent=4, default=dateconverter)
1438 |
1439 | async for message in gaming_channel.history(): # Rechercher qui est la dernière personne active du channel
1440 |
1441 | if (message.author != bot.user) and (to_id not in [y.id for y in message.author.roles]): # La personne ne doit être ni un bot ni un TO, donc un joueur
1442 |
1443 | try:
1444 | winner
1445 | except NameError:
1446 | winner, winner_last_activity = message.author, message.created_at # Le premier résultat sera assigné à winner
1447 | else:
1448 | if message.author != winner:
1449 | looser, looser_last_activity = message.author, message.created_at # Le second résultat sera assigné à looser
1450 | break
1451 |
1452 | try:
1453 | winner
1454 | except NameError: # S'il n'y a jamais eu de résultat, aucun joueur n'a donc été actif : DQ des deux
1455 | await gaming_channel.send(f"**DQ automatique des __2 joueurs__ pour inactivité : <@{player1.id}> & <@{player2.id}>**")
1456 | await async_http_retry(achallonge.participants.destroy, tournoi["id"], participants[player1.id]['challonge'])
1457 | await async_http_retry(achallonge.participants.destroy, tournoi["id"], participants[player2.id]['challonge'])
1458 | continue
1459 |
1460 | try:
1461 | looser
1462 | except NameError: # S'il n'y a pas eu de résultat pour un second joueur différent : DQ de l'inactif
1463 | looser = player2 if winner.id == player1.id else player1
1464 | await gaming_channel.send(f"**DQ automatique de <@{looser.id}> pour inactivité.**")
1465 | await async_http_retry(achallonge.participants.destroy, tournoi["id"], participants[looser.id]['challonge'])
1466 | continue
1467 |
1468 | if winner_last_activity - looser_last_activity > datetime.timedelta(minutes = 10): # Si différence d'inactivité de plus de 10 minutes
1469 | await gaming_channel.send(f"**Une DQ automatique a été executée pour inactivité :**\n-<@{winner.id}> passe au round suivant.\n-<@{looser.id}> est DQ du tournoi.")
1470 | await async_http_retry(achallonge.participants.destroy, tournoi["id"], participants[looser.id]['challonge'])
1471 |
1472 | else: # Si pas de différence notable, demander une décision manuelle
1473 | await gaming_channel.send(f"**Durée anormalement longue détectée** pour ce set, une décision d'un TO doit être prise")
1474 |
1475 | await bot.get_channel(to_channel_id).send(f":information_source: Le set du channel <#{gaming_channel.id}> prend anormalement du temps, une intervention est peut-être nécessaire.")
1476 |
1477 |
1478 | ### Obtenir stagelist
1479 | @bot.command(name='stages', aliases=['stage', 'stagelist', 'ban', 'bans', 'map', 'maps'])
1480 | @commands.check(tournament_is_underway_or_pending)
1481 | async def get_stagelist(ctx):
1482 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1483 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1484 |
1485 | msg = f":map: **Stages légaux pour {tournoi['game']} :**\n:white_small_square: __Starters__ :\n"
1486 | for stage in gamelist[tournoi['game']]['starters']: msg += f"- {stage}\n"
1487 |
1488 | if 'counterpicks' in gamelist[tournoi['game']]:
1489 | msg += ":white_small_square: __Counterpicks__ :\n"
1490 | for stage in gamelist[tournoi['game']]['counterpicks']: msg += f"- {stage}\n"
1491 |
1492 | await ctx.send(msg)
1493 |
1494 |
1495 | ### Obtenir ruleset
1496 | @bot.command(name='ruleset', aliases=['rules'])
1497 | @commands.check(tournament_is_underway_or_pending)
1498 | async def get_ruleset(ctx):
1499 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1500 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1501 | await ctx.send(f"<@{ctx.author.id}> Le ruleset est disponible ici : <#{gamelist[tournoi['game']]['ruleset']}>")
1502 |
1503 |
1504 | ### Lag
1505 | @bot.command(name='lag')
1506 | @commands.has_role(challenger_id)
1507 | @in_combat_channel()
1508 | @commands.cooldown(1, 120, type=commands.BucketType.channel)
1509 | async def send_lag_text(ctx):
1510 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1511 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1512 |
1513 | msg = lag_text
1514 |
1515 | if tournoi['game'] == 'Project+':
1516 | msg += (f"\n{gamelist[tournoi['game']]['icon']} **Spécificités Project+ :**\n"
1517 | f":white_small_square: Vérifier que le PC fait tourner le jeu de __manière fluide (60 FPS constants)__, sinon :\n"
1518 | f"- Baisser la résolution interne dans les paramètres graphiques.\n"
1519 | f"- Désactiver les textures HD, l'anti-aliasing, s'ils ont été activés.\n"
1520 | f"- Windows seulement : changer le backend pour *Direct3D9* (le + fluide) ou *Direct3D11* (+ précis que D9)\n"
1521 | f":white_small_square: Vérifier que la connexion est __stable et suffisamment rapide__ :\n"
1522 | f"- Le host peut augmenter le \"minimum buffer\" de 6 à 8 : utilisez la commande `{bot_prefix}buffer` en fournissant votre ping.\n"
1523 | f"- Suivre les étapes génériques contre le lag, citées ci-dessus.\n"
1524 | f":white_small_square: Utilisez la commande `{bot_prefix}desync` en cas de desync suspectée.")
1525 |
1526 | await bot.get_channel(to_channel_id).send(f":satellite: **Lag reporté** : les TOs sont invités à consulter le channel <#{ctx.channel.id}>")
1527 | await ctx.send(msg)
1528 |
1529 |
1530 | ### Calculate recommended minimum buffer
1531 | @bot.command(name='buffer')
1532 | async def calculate_buffer(ctx, arg: int):
1533 |
1534 | theoretical_buffer = arg // 8 + (arg % 8 > 0)
1535 | suggested_buffer = theoretical_buffer if theoretical_buffer >= 4 else 4
1536 |
1537 | await ctx.send(f"<@{ctx.author.id}> Minimum buffer (host) suggéré pour Dolphin Netplay : **{suggested_buffer}**.\n"
1538 | f"*Si du lag persiste, il y a un problème de performance : montez le buffer tant que nécessaire.*")
1539 |
1540 |
1541 | ### Annoncer les résultats
1542 | async def annonce_resultats():
1543 |
1544 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1545 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1546 |
1547 | participants, resultats = await async_http_retry(achallonge.participants.index, tournoi["id"]), []
1548 |
1549 | if len(participants) < 8:
1550 | await bot.get_channel(resultats_channel_id).send(f"{server_logo} Résultats du **{tournoi['name']}** : {tournoi['url']}")
1551 | return
1552 |
1553 | for joueur in participants:
1554 | resultats.append((joueur['final_rank'], joueur['display_name']))
1555 |
1556 | resultats.sort()
1557 | top6 = ' / '.join([y for x, y in resultats if x == 5])
1558 | top8 = ' / '.join([y for x, y in resultats if x == 7])
1559 |
1560 | ending = random.choice([
1561 | "Bien joué à tous ! Quant aux autres : ne perdez pas espoir, ce sera votre tour un jour...",
1562 | "Merci à tous d'avoir participé, on se remet ça très bientôt ! Prenez soin de vous.",
1563 | "Félicitations à eux. N'oubliez pas que la clé est la persévérance ! Croyez toujours en vous.",
1564 | "Ce fut un plaisir en tant que bot d'aider à la gestion de ce tournoi et d'assister à vos merveileux sets."
1565 | ])
1566 |
1567 | classement = (f"{server_logo} **__Résultats du tournoi {tournoi['name']}__**\n\n"
1568 | f":trophy: **1er** : **{resultats[0][1]}**\n"
1569 | f":second_place: **2e** : {resultats[1][1]}\n"
1570 | f":third_place: **3e** : {resultats[2][1]}\n"
1571 | f":medal: **4e** : {resultats[3][1]}\n"
1572 | f":reminder_ribbon: **5e** : {top6}\n"
1573 | f":reminder_ribbon: **7e** : {top8}\n\n"
1574 | f":bar_chart: {len(participants)} entrants\n"
1575 | f"{gamelist[tournoi['game']]['icon']} {tournoi['game']}\n"
1576 | f":link: **Bracket :** {tournoi['url']}\n\n"
1577 | f"{ending}")
1578 |
1579 | await bot.get_channel(resultats_channel_id).send(classement)
1580 |
1581 |
1582 | ### Ajouter un rôle
1583 | async def attribution_role(event):
1584 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1585 |
1586 | for game in gamelist:
1587 |
1588 | if event.emoji.name == re.search(r'\:(.*?)\:', gamelist[game]['icon']).group(1):
1589 | role = event.member.guild.get_role(gamelist[game]['role'])
1590 |
1591 | try:
1592 | await event.member.add_roles(role)
1593 | await event.member.send(f"Le rôle **{role.name}** t'a été attribué avec succès : tu recevras des informations concernant les tournois *{game}* !")
1594 | except (discord.HTTPException, discord.Forbidden):
1595 | pass
1596 |
1597 | elif event.emoji.name == gamelist[game]['icon_1v1']:
1598 | role = event.member.guild.get_role(gamelist[game]['role_1v1'])
1599 |
1600 | try:
1601 | await event.member.add_roles(role)
1602 | await event.member.send(f"Le rôle **{role.name}** t'a été attribué avec succès : tu seras contacté si un joueur cherche des combats sur *{game}* !")
1603 | except (discord.HTTPException, discord.Forbidden):
1604 | pass
1605 |
1606 |
1607 | ### Enlever un rôle
1608 | async def retirer_role(event):
1609 | with open(gamelist_path, 'r+') as f: gamelist = yaml.full_load(f)
1610 |
1611 | guild = bot.get_guild(id=guild_id) # due to event.member not being available
1612 |
1613 | for game in gamelist:
1614 |
1615 | if event.emoji.name == re.search(r'\:(.*?)\:', gamelist[game]['icon']).group(1):
1616 | role, member = guild.get_role(gamelist[game]['role']), guild.get_member(event.user_id)
1617 |
1618 | try:
1619 | await member.remove_roles(role)
1620 | await member.send(f"Le rôle **{role.name}** t'a été retiré avec succès : tu ne recevras plus les informations concernant les tournois *{game}*.")
1621 | except (discord.HTTPException, discord.Forbidden):
1622 | pass
1623 |
1624 | elif event.emoji.name == gamelist[game]['icon_1v1']:
1625 | role, member = guild.get_role(gamelist[game]['role_1v1']), guild.get_member(event.user_id)
1626 |
1627 | try:
1628 | await member.remove_roles(role)
1629 | await member.send(f"Le rôle **{role.name}** t'a été retiré avec succès : tu ne seras plus contacté si un joueur cherche des combats sur *{game}*.")
1630 | except (discord.HTTPException, discord.Forbidden):
1631 | pass
1632 |
1633 |
1634 | ### À chaque ajout de réaction
1635 | @bot.event
1636 | async def on_raw_reaction_add(event):
1637 | if event.user_id == bot.user.id: return
1638 |
1639 | elif (event.emoji.name == "✅") and (event.channel_id == inscriptions_channel_id):
1640 |
1641 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1642 |
1643 | if tournoi["reaction_mode"] and event.message_id == tournoi["annonce_id"]:
1644 | await inscrire(event.member) # available for REACTION_ADD only
1645 |
1646 | elif (manage_game_roles == True) and (event.channel_id == roles_channel_id):
1647 | await attribution_role(event)
1648 |
1649 |
1650 | ### À chaque suppression de réaction
1651 | @bot.event
1652 | async def on_raw_reaction_remove(event):
1653 | if event.user_id == bot.user.id: return
1654 |
1655 | elif (event.emoji.name == "✅") and (event.channel_id == inscriptions_channel_id):
1656 |
1657 | with open(tournoi_path, 'r+') as f: tournoi = json.load(f, object_hook=dateparser)
1658 |
1659 | if tournoi["reaction_mode"] and event.message_id == tournoi["annonce_id"]:
1660 | await desinscrire(bot.get_guild(id=guild_id).get_member(event.user_id)) # event.member not available for REACTION_REMOVE
1661 |
1662 | elif (manage_game_roles == True) and (event.channel_id == roles_channel_id):
1663 | await retirer_role(event)
1664 |
1665 |
1666 | ### Help message
1667 | @bot.command(name='help', aliases=['info', 'version'])
1668 | @commands.cooldown(1, 30, type=commands.BucketType.user)
1669 | async def send_help(ctx):
1670 | await ctx.send(f"**{name} {version}** - *Made by {author} with* :heart:\n{help_text}\n")
1671 | author_roles = [y.id for y in ctx.author.roles]
1672 | if challenger_id in author_roles: await ctx.send(challenger_help_text) # challenger help
1673 | if to_id in author_roles or await ctx.bot.is_owner(ctx.author): await ctx.send(admin_help_text) # admin help
1674 | if streamer_id in author_roles: await ctx.send(streamer_help_text) # streamer help
1675 |
1676 |
1677 | ### Set preference
1678 | @bot.command(name='set', aliases=['turn'])
1679 | @commands.check(is_owner_or_to)
1680 | async def set_preference(ctx, arg1, arg2):
1681 | with open(preferences_path, 'r+') as f: preferences = yaml.full_load(f)
1682 |
1683 | try:
1684 | if isinstance(preferences[arg1.lower()], bool):
1685 | if arg2.lower() in ["true", "on"]:
1686 | preferences[arg1.lower()] = True
1687 | elif arg2.lower() in ["false", "off"]:
1688 | preferences[arg1.lower()] = False
1689 | else:
1690 | raise ValueError
1691 | elif isinstance(preferences[arg1.lower()], int):
1692 | preferences[arg1.lower()] = int(arg2)
1693 |
1694 | except KeyError:
1695 | await ctx.message.add_reaction("⚠️")
1696 | await ctx.send(f"<@{ctx.author.id}> **Paramètre inconnu :** `{arg1}`.")
1697 |
1698 | except ValueError:
1699 | await ctx.message.add_reaction("⚠️")
1700 | await ctx.send(f"<@{ctx.author.id}> **Valeur incorrecte :** `{arg2}`.")
1701 |
1702 | else:
1703 | with open(preferences_path, 'w') as f: yaml.dump(preferences, f)
1704 | await ctx.message.add_reaction("✅")
1705 | await ctx.send(f"<@{ctx.author.id}> **Paramètre changé :** `{arg1} = {arg2}`.")
1706 |
1707 |
1708 | ### See preferences
1709 | @bot.command(name='settings', aliases=['preferences', 'config'])
1710 | @commands.check(is_owner_or_to)
1711 | async def check_settings(ctx):
1712 | with open(preferences_path, 'r+') as f: preferences = yaml.full_load(f)
1713 |
1714 | parametres = ""
1715 | for parametre in preferences:
1716 | parametres += f":white_small_square: **{parametre}** : *{preferences[parametre]}*\n"
1717 |
1718 | await ctx.send(f":gear: __Liste des paramètres modifiables sans redémarrage__ :\n{parametres}\n"
1719 | f"Vous pouvez modifier chacun de ces paramètres avec la commande `{bot_prefix}set [paramètre] [valeur]`.\n"
1720 | f"*Ces paramètres ne s'appliquent qu'au moment de la création d'un tournoi, et ne peuvent pas changer jusqu'à sa fin.*")
1721 |
1722 |
1723 | ### Desync message
1724 | @bot.command(name='desync')
1725 | @commands.cooldown(1, 30, type=commands.BucketType.user)
1726 | async def send_desync_help(ctx):
1727 | await ctx.send(desync_text)
1728 |
1729 |
1730 | ### On command error : invoker has not enough permissions
1731 | @bot.event
1732 | async def on_command_error(ctx, error):
1733 | if isinstance(error, (commands.CheckFailure, commands.MissingRole, commands.NotOwner)):
1734 | log.debug(f"Detected check failure for {ctx.command.name}", exc_info=error)
1735 | await ctx.message.add_reaction("🚫")
1736 | elif isinstance(error, (commands.MissingRequiredArgument, commands.ArgumentParsingError, commands.BadArgument)):
1737 | await ctx.message.add_reaction("💿")
1738 | await ctx.send(f"<@{ctx.author.id}> Les paramètres de cette commande sont mal renseignés. Utilise `{bot_prefix}help` pour en savoir plus.", delete_after=10)
1739 | elif isinstance(error, commands.CommandOnCooldown):
1740 | await ctx.message.add_reaction("❄️")
1741 | await ctx.send(f"<@{ctx.author.id}> **Cooldown** : cette commande sera de nouveau disponible pour toi dans {int(error.retry_after)} secondes.", delete_after=error.retry_after)
1742 | elif isinstance(error, commands.CommandNotFound) and show_unknown_command:
1743 | await ctx.message.add_reaction("❔")
1744 | await ctx.send(f"<@{ctx.author.id}> Voulais-tu écrire autre chose ? Utilise `{bot_prefix}help` pour avoir la liste des commandes.", delete_after=10)
1745 | elif isinstance(error, commands.CommandInvokeError):
1746 | log.error(f"Error while executing command {ctx.command.name}", exc_info=error)
1747 | await ctx.message.add_reaction("⚠️")
1748 |
1749 | @bot.event
1750 | async def on_error(event, *args, **kwargs):
1751 | exception = sys.exc_info()
1752 | log.error(f"Unhandled exception with {event}", exc_info=exception)
1753 |
1754 |
1755 | if __name__ == '__main__':
1756 | # loggers initialization
1757 | if debug_mode:
1758 | level = 10
1759 | else:
1760 | level = 20
1761 | init_loggers(level, Path("./data/logs/"))
1762 | log.info(f"A.T.O.S. Version {version}")
1763 | #### Scheduler
1764 | scheduler.start()
1765 | ### Add base cogs
1766 | for extension in initial_extensions:
1767 | bot.load_extension(extension)
1768 | #### Lancement du bot
1769 | try:
1770 | bot.run(bot_secret, bot = True, reconnect = True)
1771 | except KeyboardInterrupt:
1772 | log.info("Crtl-C detected, shutting down...")
1773 | bot.logout()
1774 | except Exception as e:
1775 | log.critical("Unhandled exception.", exc_info=e)
1776 | finally:
1777 | log.info("Shutting down...")
1778 | dump_participants()
--------------------------------------------------------------------------------