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