diff options
Diffstat (limited to 'bot')
-rw-r--r-- | bot/__main__.py | 5 | ||||
-rw-r--r-- | bot/constants.py | 45 | ||||
-rw-r--r-- | bot/exts/christmas/advent_of_code/_cog.py | 20 | ||||
-rw-r--r-- | bot/exts/evergreen/cheatsheet.py | 107 | ||||
-rw-r--r-- | bot/exts/evergreen/connect_four.py | 450 | ||||
-rw-r--r-- | bot/exts/evergreen/conversationstarters.py | 4 | ||||
-rw-r--r-- | bot/exts/evergreen/error_handler.py | 9 | ||||
-rw-r--r-- | bot/exts/evergreen/issues.py | 162 | ||||
-rw-r--r-- | bot/exts/evergreen/status_cats.py | 33 | ||||
-rw-r--r-- | bot/exts/evergreen/status_codes.py | 71 | ||||
-rw-r--r-- | bot/exts/evergreen/tic_tac_toe.py | 323 | ||||
-rw-r--r-- | bot/exts/evergreen/wikipedia.py | 164 | ||||
-rw-r--r-- | bot/exts/evergreen/wolfram.py | 11 | ||||
-rw-r--r-- | bot/exts/evergreen/xkcd.py | 2 | ||||
-rw-r--r-- | bot/exts/halloween/hacktoberstats.py | 8 | ||||
-rw-r--r-- | bot/exts/valentines/be_my_valentine.py | 80 | ||||
-rw-r--r-- | bot/exts/valentines/lovecalculator.py | 11 | ||||
-rw-r--r-- | bot/resources/evergreen/py_topics.yaml | 53 | ||||
-rw-r--r-- | bot/resources/evergreen/starter.yaml | 11 | ||||
-rw-r--r-- | bot/utils/decorators.py | 119 |
20 files changed, 1375 insertions, 313 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index e9b14a53..c6e5fa57 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,10 +6,9 @@ from sentry_sdk.integrations.redis import RedisIntegration from bot.bot import bot from bot.constants import Client, GIT_SHA, STAFF_ROLES, WHITELISTED_CHANNELS -from bot.utils.decorators import in_channel_check +from bot.utils.decorators import whitelist_check from bot.utils.extensions import walk_extensions - sentry_logging = LoggingIntegration( level=logging.DEBUG, event_level=logging.WARNING @@ -26,7 +25,7 @@ sentry_sdk.init( log = logging.getLogger(__name__) -bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES)) +bot.add_check(whitelist_check(channels=WHITELISTED_CHANNELS, roles=STAFF_ROLES)) for ext in walk_extensions(): bot.load_extension(ext) diff --git a/bot/constants.py b/bot/constants.py index d63a063e..db34b55a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -9,6 +9,7 @@ __all__ = ( "AdventOfCode", "Branding", "Channels", + "Categories", "Client", "Colours", "Emojis", @@ -100,20 +101,17 @@ class Channels(NamedTuple): big_brother_logs = 468507907357409333 bot = 267659945086812160 checkpoint_test = 422077681434099723 + organisation = 551789653284356126 devalerts = 460181980097675264 devlog = int(environ.get("CHANNEL_DEVLOG", 622895325144940554)) dev_contrib = 635950537262759947 dev_branding = 753252897059373066 - help_0 = 303906576991780866 - help_1 = 303906556754395136 - help_2 = 303906514266226689 - help_3 = 439702951246692352 - help_4 = 451312046647148554 - help_5 = 454941769734422538 helpers = 385474242440986624 message_log = 467752170159079424 mod_alerts = 473092532147060736 modlog = 282638479504965634 + mod_meta = 775412552795947058 + mod_tools = 775413915391098921 off_topic_0 = 291284109232308226 off_topic_1 = 463035241142026251 off_topic_2 = 463035268514185226 @@ -126,6 +124,15 @@ class Channels(NamedTuple): hacktoberfest_2020 = 760857070781071431 voice_chat_0 = 412357430186344448 voice_chat_1 = 799647045886541885 + staff_voice = 541638762007101470 + + +class Categories(NamedTuple): + help_in_use = 696958401460043776 + development = 411199786025484308 + devprojects = 787641585624940544 + media = 799054581991997460 + staff = 364918151625965579 class Client(NamedTuple): @@ -160,6 +167,7 @@ class Emojis: envelope = "\U0001F4E8" trashcan = "<:trashcan:637136429717389331>" ok_hand = ":ok_hand:" + hand_raised = "\U0001f64b" dice_1 = "<:dice_1:755891608859443290>" dice_2 = "<:dice_2:755891608741740635>" @@ -174,6 +182,25 @@ class Emojis: pull_request_closed = "<:PRClosed:629695470519713818>" merge = "<:PRMerged:629695470570176522>" + number_emojis = { + 1: "\u0031\ufe0f\u20e3", + 2: "\u0032\ufe0f\u20e3", + 3: "\u0033\ufe0f\u20e3", + 4: "\u0034\ufe0f\u20e3", + 5: "\u0035\ufe0f\u20e3", + 6: "\u0036\ufe0f\u20e3", + 7: "\u0037\ufe0f\u20e3", + 8: "\u0038\ufe0f\u20e3", + 9: "\u0039\ufe0f\u20e3" + } + + confirmation = "\u2705" + decline = "\u274c" + incident_unactioned = "<:incident_unactioned:719645583245180960>" + + x = "\U0001f1fd" + o = "\U0001f1f4" + status_online = "<:status_online:470326272351010816>" status_idle = "<:status_idle:470326266625785866>" status_dnd = "<:status_dnd:470326272082313216>" @@ -221,7 +248,6 @@ class Roles(NamedTuple): announcements = 463658397560995840 champion = 430492892331769857 contributor = 295488872404484098 - developer = 352427296948486144 devops = 409416496733880320 jammer = 423054537079783434 moderator = 267629731250176001 @@ -232,6 +258,7 @@ class Roles(NamedTuple): rockstars = 458226413825294336 core_developers = 587606783669829632 events_lead = 778361735739998228 + everyone_role = 267624335836053506 class Tokens(NamedTuple): @@ -259,10 +286,6 @@ class RedisConfig(NamedTuple): use_fakeredis = environ.get("USE_FAKEREDIS", "false").lower() == "true" -class Wikipedia: - total_chance = 3 - - class Source: github = "https://github.com/python-discord/sir-lancebot" github_avatar_url = "https://avatars1.githubusercontent.com/u/9919" diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index c3b87f96..466edd48 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -11,7 +11,7 @@ from bot.constants import ( AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, ) from bot.exts.christmas.advent_of_code import _helpers -from bot.utils.decorators import InChannelCheckFailure, in_month, override_in_channel, with_role +from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role log = logging.getLogger(__name__) @@ -50,7 +50,7 @@ class AdventOfCode(commands.Cog): self.status_task.add_done_callback(_helpers.background_task_callback) @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" if not ctx.invoked_subcommand: @@ -61,7 +61,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -82,7 +82,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -94,7 +94,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -123,13 +123,13 @@ class AdventOfCode(commands.Cog): await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def about_aoc(self, ctx: commands.Context) -> None: """Respond with an explanation of all things Advent of Code.""" await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST) + @whitelist_override(channels=AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year @@ -178,7 +178,7 @@ class AdventOfCode(commands.Cog): aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) - @override_in_channel(AOC_WHITELIST_RESTRICTED) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) async def aoc_leaderboard(self, ctx: commands.Context) -> None: """Get the current top scorers of the Python Discord Leaderboard.""" async with ctx.typing(): @@ -203,7 +203,7 @@ class AdventOfCode(commands.Cog): aliases=("globalboard", "gb"), brief="Get a link to the global leaderboard", ) - @override_in_channel(AOC_WHITELIST_RESTRICTED) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: """Get a link to the global Advent of Code leaderboard.""" url = self.global_leaderboard_url @@ -219,7 +219,7 @@ class AdventOfCode(commands.Cog): aliases=("dailystats", "ds"), brief="Get daily statistics for the Python Discord leaderboard" ) - @override_in_channel(AOC_WHITELIST_RESTRICTED) + @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """Send an embed with daily completion statistics for the Python Discord leaderboard.""" try: diff --git a/bot/exts/evergreen/cheatsheet.py b/bot/exts/evergreen/cheatsheet.py new file mode 100644 index 00000000..3fe709d5 --- /dev/null +++ b/bot/exts/evergreen/cheatsheet.py @@ -0,0 +1,107 @@ +import random +import re +import typing as t +from urllib.parse import quote_plus + +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Context + +from bot import constants +from bot.constants import Categories, Channels, Colours, ERROR_REPLIES +from bot.utils.decorators import whitelist_override + +ERROR_MESSAGE = f""" +Unknown cheat sheet. Please try to reformulate your query. + +**Examples**: +```md +{constants.Client.prefix}cht read json +{constants.Client.prefix}cht hello world +{constants.Client.prefix}cht lambda +``` +If the problem persists send a message in <#{Channels.dev_contrib}> +""" + +URL = 'https://cheat.sh/python/{search}' +ESCAPE_TT = str.maketrans({"`": "\\`"}) +ANSI_RE = re.compile(r"\x1b\[.*?m") +# We need to pass headers as curl otherwise it would default to aiohttp which would return raw html. +HEADERS = {'User-Agent': 'curl/7.68.0'} + + +class CheatSheet(commands.Cog): + """Commands that sends a result of a cht.sh search in code blocks.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def fmt_error_embed() -> Embed: + """ + Format the Error Embed. + + If the cht.sh search returned 404, overwrite it to send a custom error embed. + link -> https://github.com/chubin/cheat.sh/issues/198 + """ + embed = Embed( + title=random.choice(ERROR_REPLIES), + description=ERROR_MESSAGE, + colour=Colours.soft_red + ) + return embed + + def result_fmt(self, url: str, body_text: str) -> t.Tuple[bool, t.Union[str, Embed]]: + """Format Result.""" + if body_text.startswith("# 404 NOT FOUND"): + embed = self.fmt_error_embed() + return True, embed + + body_space = min(1986 - len(url), 1000) + + if len(body_text) > body_space: + description = (f"**Result Of cht.sh**\n" + f"```python\n{body_text[:body_space]}\n" + f"... (truncated - too many lines)```\n" + f"Full results: {url} ") + else: + description = (f"**Result Of cht.sh**\n" + f"```python\n{body_text}```\n" + f"{url}") + return False, description + + @commands.command( + name="cheat", + aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"), + ) + @commands.cooldown(1, 10, BucketType.user) + @whitelist_override(categories=[Categories.help_in_use]) + async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None: + """ + Search cheat.sh. + + Gets a post from https://cheat.sh/python/ by default. + Usage: + --> .cht read json + """ + async with ctx.typing(): + search_string = quote_plus(" ".join(search_terms)) + + async with self.bot.http_session.get( + URL.format(search=search_string), headers=HEADERS + ) as response: + result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT) + + is_embed, description = self.result_fmt( + URL.format(search=search_string), + result + ) + if is_embed: + await ctx.send(embed=description) + else: + await ctx.send(content=description) + + +def setup(bot: commands.Bot) -> None: + """Load the CheatSheet cog.""" + bot.add_cog(CheatSheet(bot)) diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py new file mode 100644 index 00000000..7e3ec42b --- /dev/null +++ b/bot/exts/evergreen/connect_four.py @@ -0,0 +1,450 @@ +import asyncio +import random +import typing +from functools import partial + +import discord +import emojis +from discord.ext import commands +from discord.ext.commands import guild_only + +from bot.constants import Emojis + +NUMBERS = list(Emojis.number_emojis.values()) +CROSS_EMOJI = Emojis.incident_unactioned + +Coordinate = typing.Optional[typing.Tuple[int, int]] +EMOJI_CHECK = typing.Union[discord.Emoji, str] + + +class Game: + """A Connect 4 Game.""" + + def __init__( + self, + bot: commands.Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: typing.Optional[discord.Member], + tokens: typing.List[str], + size: int = 7 + ) -> None: + + self.bot = bot + self.channel = channel + self.player1 = player1 + self.player2 = player2 or AI(self.bot, game=self) + self.tokens = tokens + + self.grid = self.generate_board(size) + self.grid_size = size + + self.unicode_numbers = NUMBERS[:self.grid_size] + + self.message = None + + self.player_active = None + self.player_inactive = None + + @staticmethod + def generate_board(size: int) -> typing.List[typing.List[int]]: + """Generate the connect 4 board.""" + return [[0 for _ in range(size)] for _ in range(size)] + + async def print_grid(self) -> None: + """Formats and outputs the Connect Four grid to the channel.""" + title = ( + f'Connect 4: {self.player1.display_name}' + f' VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}' + ) + + rows = [" ".join(self.tokens[s] for s in row) for row in self.grid] + first_row = " ".join(x for x in NUMBERS[:self.grid_size]) + formatted_grid = "\n".join([first_row] + rows) + embed = discord.Embed(title=title, description=formatted_grid) + + if self.message: + await self.message.edit(embed=embed) + else: + self.message = await self.channel.send(content='Loading...') + for emoji in self.unicode_numbers: + await self.message.add_reaction(emoji) + await self.message.add_reaction(CROSS_EMOJI) + await self.message.edit(content=None, embed=embed) + + async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None: + """Announces to public chat.""" + if action == "win": + await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") + elif action == "draw": + await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") + elif action == "quit": + await self.channel.send(f"{self.player1.mention} surrendered. Game over!") + await self.print_grid() + + async def start_game(self) -> None: + """Begins the game.""" + self.player_active, self.player_inactive = self.player1, self.player2 + + while True: + await self.print_grid() + + if isinstance(self.player_active, AI): + coords = self.player_active.play() + if not coords: + await self.game_over( + "draw", + self.bot.user if isinstance(self.player_active, AI) else self.player_active, + self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, + ) + else: + coords = await self.player_turn() + + if not coords: + return + + if self.check_win(coords, 1 if self.player_active == self.player1 else 2): + await self.game_over( + "win", + self.bot.user if isinstance(self.player_active, AI) else self.player_active, + self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, + ) + return + + self.player_active, self.player_inactive = self.player_inactive, self.player_active + + def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool: + """The predicate to check for the player's reaction.""" + return ( + reaction.message.id == self.message.id + and user.id == self.player_active.id + and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI) + ) + + async def player_turn(self) -> Coordinate: + """Initiate the player's turn.""" + message = await self.channel.send( + f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in." + ) + player_num = 1 if self.player_active == self.player1 else 2 + while True: + try: + reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) + except asyncio.TimeoutError: + await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") + return + else: + await message.delete() + if str(reaction.emoji) == CROSS_EMOJI: + await self.game_over("quit", self.player_active, self.player_inactive) + return + + await self.message.remove_reaction(reaction, user) + + column_num = self.unicode_numbers.index(str(reaction.emoji)) + column = [row[column_num] for row in self.grid] + + for row_num, square in reversed(list(enumerate(column))): + if not square: + self.grid[row_num][column_num] = player_num + return row_num, column_num + message = await self.channel.send(f"Column {column_num + 1} is full. Try again") + + def check_win(self, coords: Coordinate, player_num: int) -> bool: + """Check that placing a counter here would cause the player to win.""" + vertical = [(-1, 0), (1, 0)] + horizontal = [(0, 1), (0, -1)] + forward_diag = [(-1, 1), (1, -1)] + backward_diag = [(-1, -1), (1, 1)] + axes = [vertical, horizontal, forward_diag, backward_diag] + + for axis in axes: + counters_in_a_row = 1 # The initial counter that is compared to + for (row_incr, column_incr) in axis: + row, column = coords + row += row_incr + column += column_incr + + while 0 <= row < self.grid_size and 0 <= column < self.grid_size: + if self.grid[row][column] == player_num: + counters_in_a_row += 1 + row += row_incr + column += column_incr + else: + break + if counters_in_a_row >= 4: + return True + return False + + +class AI: + """The Computer Player for Single-Player games.""" + + def __init__(self, bot: commands.Bot, game: Game) -> None: + self.game = game + self.mention = bot.user.mention + + def get_possible_places(self) -> typing.List[Coordinate]: + """Gets all the coordinates where the AI could possibly place a counter.""" + possible_coords = [] + for column_num in range(self.game.grid_size): + column = [row[column_num] for row in self.game.grid] + for row_num, square in reversed(list(enumerate(column))): + if not square: + possible_coords.append((row_num, column_num)) + break + return possible_coords + + def check_ai_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]: + """ + Check AI win. + + Check if placing a counter in any possible coordinate would cause the AI to win + with 10% chance of not winning and returning None + """ + if random.randint(1, 10) == 1: + return + for coords in coord_list: + if self.game.check_win(coords, 2): + return coords + + def check_player_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]: + """ + Check Player win. + + Check if placing a counter in possible coordinates would stop the player + from winning with 25% of not blocking them and returning None. + """ + if random.randint(1, 4) == 1: + return + for coords in coord_list: + if self.game.check_win(coords, 1): + return coords + + @staticmethod + def random_coords(coord_list: typing.List[Coordinate]) -> Coordinate: + """Picks a random coordinate from the possible ones.""" + return random.choice(coord_list) + + def play(self) -> typing.Union[Coordinate, bool]: + """ + Plays for the AI. + + Gets all possible coords, and determins the move: + 1. coords where it can win. + 2. coords where the player can win. + 3. Random coord + The first possible value is choosen. + """ + possible_coords = self.get_possible_places() + + if not possible_coords: + return False + + coords = ( + self.check_ai_win(possible_coords) + or self.check_player_win(possible_coords) + or self.random_coords(possible_coords) + ) + + row, column = coords + self.game.grid[row][column] = 2 + return coords + + +class ConnectFour(commands.Cog): + """Connect Four. The Classic Vertical Four-in-a-row Game!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.games: typing.List[Game] = [] + self.waiting: typing.List[discord.Member] = [] + + self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"] + + self.max_board_size = 9 + self.min_board_size = 5 + + async def check_author(self, ctx: commands.Context, board_size: int) -> bool: + """Check if the requester is free and the board size is correct.""" + if self.already_playing(ctx.author): + await ctx.send("You're already playing a game!") + return False + + if ctx.author in self.waiting: + await ctx.send("You've already sent out a request for a player 2") + return False + + if not self.min_board_size <= board_size <= self.max_board_size: + await ctx.send(f"{board_size} is not a valid board size. A valid board size is " + f"between `{self.min_board_size}` and `{self.max_board_size}`.") + return False + + return True + + def get_player( + self, + ctx: commands.Context, + announcement: discord.Message, + reaction: discord.Reaction, + user: discord.Member + ) -> bool: + """Predicate checking the criteria for the announcement message.""" + if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 + return True # Is dealt with later on + + if ( + user.id not in (ctx.me.id, ctx.author.id) + and str(reaction.emoji) == Emojis.hand_raised + and reaction.message.id == announcement.id + ): + if self.already_playing(user): + self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + if user in self.waiting: + self.bot.loop.create_task(ctx.send( + f"{user.mention} Please cancel your game first before joining another one." + )) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + return True + + if ( + user.id == ctx.author.id + and str(reaction.emoji) == CROSS_EMOJI + and reaction.message.id == announcement.id + ): + return True + return False + + def already_playing(self, player: discord.Member) -> bool: + """Check if someone is already in a game.""" + return any(player in (game.player1, game.player2) for game in self.games) + + @staticmethod + def check_emojis( + e1: EMOJI_CHECK, e2: EMOJI_CHECK + ) -> typing.Tuple[bool, typing.Optional[str]]: + """Validate the emojis, the user put.""" + if isinstance(e1, str) and emojis.count(e1) != 1: + return False, e1 + if isinstance(e2, str) and emojis.count(e2) != 1: + return False, e2 + return True, None + + async def _play_game( + self, + ctx: commands.Context, + user: typing.Optional[discord.Member], + board_size: int, + emoji1: str, + emoji2: str + ) -> None: + """Helper for playing a game of connect four.""" + self.tokens = [":white_circle:", str(emoji1), str(emoji2)] + game = None # if game fails to intialize in try...except + + try: + game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size) + self.games.append(game) + await game.start_game() + self.games.remove(game) + except Exception: + # End the game in the event of an unforeseen error so the players aren't stuck in a game + await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed") + if game in self.games: + self.games.remove(game) + raise + + @guild_only() + @commands.group( + invoke_without_command=True, + aliases=["4inarow", "connect4", "connectfour", "c4"], + case_insensitive=True + ) + async def connect_four( + self, + ctx: commands.Context, + board_size: int = 7, + emoji1: EMOJI_CHECK = "\U0001f535", + emoji2: EMOJI_CHECK = "\U0001f534" + ) -> None: + """ + Play the classic game of Connect Four with someone! + + Sets up a message waiting for someone else to react and play along. + The game will start once someone has reacted. + All inputs will be through reactions. + """ + check, emoji = self.check_emojis(emoji1, emoji2) + if not check: + raise commands.EmojiNotFound(emoji) + + check_author_result = await self.check_author(ctx, board_size) + if not check_author_result: + return + + announcement = await ctx.send( + "**Connect Four**: A new game is about to start!\n" + f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n" + f"(Cancel the game with {CROSS_EMOJI}.)" + ) + self.waiting.append(ctx.author) + await announcement.add_reaction(Emojis.hand_raised) + await announcement.add_reaction(CROSS_EMOJI) + + try: + reaction, user = await self.bot.wait_for( + "reaction_add", + check=partial(self.get_player, ctx, announcement), + timeout=60.0 + ) + except asyncio.TimeoutError: + self.waiting.remove(ctx.author) + await announcement.delete() + await ctx.send( + f"{ctx.author.mention} Seems like there's no one here to play. " + f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer." + ) + return + + if str(reaction.emoji) == CROSS_EMOJI: + self.waiting.remove(ctx.author) + await announcement.delete() + await ctx.send(f"{ctx.author.mention} Game cancelled.") + return + + await announcement.delete() + self.waiting.remove(ctx.author) + if self.already_playing(ctx.author): + return + + await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2)) + + @guild_only() + @connect_four.command(aliases=["bot", "computer", "cpu"]) + async def ai( + self, + ctx: commands.Context, + board_size: int = 7, + emoji1: EMOJI_CHECK = "\U0001f535", + emoji2: EMOJI_CHECK = "\U0001f534" + ) -> None: + """Play Connect Four against a computer player.""" + check, emoji = self.check_emojis(emoji1, emoji2) + if not check: + raise commands.EmojiNotFound(emoji) + + check_author_result = await self.check_author(ctx, board_size) + if not check_author_result: + return + + await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2)) + + +def setup(bot: commands.Bot) -> None: + """Load ConnectFour Cog.""" + bot.add_cog(ConnectFour(bot)) diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py index 576b8d76..e7058961 100644 --- a/bot/exts/evergreen/conversationstarters.py +++ b/bot/exts/evergreen/conversationstarters.py @@ -5,7 +5,7 @@ from discord import Color, Embed from discord.ext import commands from bot.constants import WHITELISTED_CHANNELS -from bot.utils.decorators import override_in_channel +from bot.utils.decorators import whitelist_override from bot.utils.randomization import RandomCycle SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9' @@ -38,7 +38,7 @@ class ConvoStarters(commands.Cog): self.bot = bot @commands.command() - @override_in_channel(ALL_ALLOWED_CHANNELS) + @whitelist_override(channels=ALL_ALLOWED_CHANNELS) async def topic(self, ctx: commands.Context) -> None: """ Responds with a random topic to start a conversation. diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index 99af1519..28902503 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -7,7 +7,7 @@ from discord import Embed, Message from discord.ext import commands from sentry_sdk import push_scope -from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES +from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure from bot.utils.exceptions import UserNotPlayingError @@ -83,7 +83,12 @@ class CommandErrorHandler(commands.Cog): return if isinstance(error, commands.NoPrivateMessage): - await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES)) + await ctx.send( + embed=self.error_embed( + f"This command can only be used in the server. Go to <#{Channels.community_bot_commands}> instead!", + NEGATIVE_REPLIES + ) + ) return if isinstance(error, commands.BadArgument): diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py index e419a6f5..bbcbf611 100644 --- a/bot/exts/evergreen/issues.py +++ b/bot/exts/evergreen/issues.py @@ -1,11 +1,13 @@ import logging import random +import re +import typing as t +from enum import Enum import discord -from discord.ext import commands +from discord.ext import commands, tasks -from bot.constants import Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS -from bot.utils.decorators import override_in_channel +from bot.constants import Categories, Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS log = logging.getLogger(__name__) @@ -15,55 +17,88 @@ BAD_RESPONSE = { } MAX_REQUESTS = 10 - REQUEST_HEADERS = dict() + +REPOS_API = "https://api.github.com/orgs/{org}/repos" if GITHUB_TOKEN := Tokens.github: REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" +WHITELISTED_CATEGORIES = ( + Categories.development, Categories.devprojects, Categories.media, Categories.staff +) +WHITELISTED_CHANNELS_ON_MESSAGE = ( + Channels.organisation, Channels.mod_meta, Channels.mod_tools, Channels.staff_voice +) + +CODE_BLOCK_RE = re.compile( + r"^`([^`\n]+)`" # Inline codeblock + r"|```(.+?)```", # Multiline codeblock + re.DOTALL | re.MULTILINE +) + + +class FetchIssueErrors(Enum): + """Errors returned in fetch issues.""" + + value_error = "Numbers not found." + max_requests = "Max requests hit." + class Issues(commands.Cog): """Cog that allows users to retrieve issues from GitHub.""" def __init__(self, bot: commands.Bot): self.bot = bot - - @commands.command(aliases=("pr",)) - @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib, Channels.dev_branding)) - async def issue( - self, - ctx: commands.Context, - numbers: commands.Greedy[int], - repository: str = "sir-lancebot", - user: str = "python-discord" - ) -> None: - """Command to retrieve issue(s) from a GitHub repository.""" + self.repos = [] + self.get_pydis_repos.start() + + @tasks.loop(minutes=30) + async def get_pydis_repos(self) -> None: + """Get all python-discord repositories on github.""" + async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp: + if resp.status == 200: + data = await resp.json() + for repo in data: + self.repos.append(repo["full_name"].split("/")[1]) + self.repo_regex = "|".join(self.repos) + else: + log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}") + + @staticmethod + def check_in_block(message: discord.Message, repo_issue: str) -> bool: + """Check whether the <repo>#<issue> is in codeblocks.""" + block = re.findall(CODE_BLOCK_RE, message.content) + + if not block: + return False + elif "#".join(repo_issue.split(" ")) in "".join([*block[0]]): + return True + return False + + async def fetch_issues( + self, + numbers: set, + repository: str, + user: str + ) -> t.Union[FetchIssueErrors, str, list]: + """Retrieve issue(s) from a GitHub repository.""" links = [] - numbers = set(numbers) # Convert from list to set to remove duplicates, if any - if not numbers: - await ctx.invoke(self.bot.get_command('help'), 'issue') - return + return FetchIssueErrors.value_error if len(numbers) > MAX_REQUESTS: - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - color=Colours.soft_red, - description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})" - ) - await ctx.send(embed=embed) - return + return FetchIssueErrors.max_requests for number in numbers: url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" - log.trace(f"Querying GH issues API: {url}") async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r: json_data = await r.json() if r.status in BAD_RESPONSE: log.warning(f"Received response {r.status} from: {url}") - return await ctx.send(f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}") + return f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}" # The initial API request is made to the issues API endpoint, which will return information # if the issue or PR is present. However, the scope of information returned for PRs differs @@ -92,15 +127,80 @@ class Issues(commands.Cog): issue_url = json_data.get("html_url") links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url]) - # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link. - description_list = ["{0} [{1}]({2})".format(*link) for link in links] + return links + + @staticmethod + def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed: + """Get Response Embed.""" + description_list = ["{0} [{1}]({2})".format(*link) for link in result] resp = discord.Embed( colour=Colours.bright_green, description='\n'.join(description_list) ) resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}") - await ctx.send(embed=resp) + return resp + + @commands.command(aliases=("pr",)) + async def issue( + self, + ctx: commands.Context, + numbers: commands.Greedy[int], + repository: str = "sir-lancebot", + user: str = "python-discord" + ) -> None: + """Command to retrieve issue(s) from a GitHub repository.""" + if not( + ctx.channel.category.id in WHITELISTED_CATEGORIES + or ctx.channel.id in WHITELISTED_CHANNELS + ): + return + + result = await self.fetch_issues(set(numbers), repository, user) + + if result == FetchIssueErrors.value_error: + await ctx.invoke(self.bot.get_command('help'), 'issue') + + elif result == FetchIssueErrors.max_requests: + embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + color=Colours.soft_red, + description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})" + ) + await ctx.send(embed=embed) + + elif isinstance(result, list): + # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link. + resp = self.get_embed(result, user, repository) + await ctx.send(embed=resp) + + elif isinstance(result, str): + await ctx.send(result) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>.""" + if not( + message.channel.category.id in WHITELISTED_CATEGORIES + or message.channel.id in WHITELISTED_CHANNELS_ON_MESSAGE + ): + return + + message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content) + links = [] + + if message_repo_issue_map: + for repo_issue in message_repo_issue_map: + if not self.check_in_block(message, " ".join(repo_issue)): + result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord") + if isinstance(result, list): + links.extend(result) + + if not links: + return + + resp = self.get_embed(links, "python-discord") + await message.channel.send(embed=resp) def setup(bot: commands.Bot) -> None: diff --git a/bot/exts/evergreen/status_cats.py b/bot/exts/evergreen/status_cats.py deleted file mode 100644 index 586b8378..00000000 --- a/bot/exts/evergreen/status_cats.py +++ /dev/null @@ -1,33 +0,0 @@ -from http import HTTPStatus - -import discord -from discord.ext import commands - - -class StatusCats(commands.Cog): - """Commands that give HTTP statuses described and visualized by cats.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=['statuscat']) - async def http_cat(self, ctx: commands.Context, code: int) -> None: - """Sends an embed with an image of a cat, potraying the status code.""" - embed = discord.Embed(title=f'**Status: {code}**') - - try: - HTTPStatus(code) - - except ValueError: - embed.set_footer(text='Inputted status code does not exist.') - - else: - embed.set_image(url=f'https://http.cat/{code}.jpg') - - finally: - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Load the StatusCats cog.""" - bot.add_cog(StatusCats(bot)) diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py new file mode 100644 index 00000000..874c87eb --- /dev/null +++ b/bot/exts/evergreen/status_codes.py @@ -0,0 +1,71 @@ +from http import HTTPStatus + +import discord +from discord.ext import commands + +HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" +HTTP_CAT_URL = "https://http.cat/{code}.jpg" + + +class HTTPStatusCodes(commands.Cog): + """Commands that give HTTP statuses described and visualized by cats and dogs.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.group(name="http_status", aliases=("status", "httpstatus")) + async def http_status_group(self, ctx: commands.Context) -> None: + """Group containing dog and cat http status code commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @http_status_group.command(name='cat') + async def http_cat(self, ctx: commands.Context, code: int) -> None: + """Sends an embed with an image of a cat, portraying the status code.""" + embed = discord.Embed(title=f'**Status: {code}**') + url = HTTP_CAT_URL.format(code=code) + + try: + HTTPStatus(code) + async with self.bot.http_session.get(url, allow_redirects=False) as response: + if response.status != 404: + embed.set_image(url=url) + else: + raise NotImplementedError + + except ValueError: + embed.set_footer(text='Inputted status code does not exist.') + + except NotImplementedError: + embed.set_footer(text='Inputted status code is not implemented by http.cat yet.') + + finally: + await ctx.send(embed=embed) + + @http_status_group.command(name='dog') + async def http_dog(self, ctx: commands.Context, code: int) -> None: + """Sends an embed with an image of a dog, portraying the status code.""" + embed = discord.Embed(title=f'**Status: {code}**') + url = HTTP_DOG_URL.format(code=code) + + try: + HTTPStatus(code) + async with self.bot.http_session.get(url, allow_redirects=False) as response: + if response.status != 302: + embed.set_image(url=url) + else: + raise NotImplementedError + + except ValueError: + embed.set_footer(text='Inputted status code does not exist.') + + except NotImplementedError: + embed.set_footer(text='Inputted status code is not implemented by httpstatusdogs.com yet.') + + finally: + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Load the HTTPStatusCodes cog.""" + bot.add_cog(HTTPStatusCodes(bot)) diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py new file mode 100644 index 00000000..e1190502 --- /dev/null +++ b/bot/exts/evergreen/tic_tac_toe.py @@ -0,0 +1,323 @@ +import asyncio +import random +import typing as t + +import discord +from discord.ext.commands import Cog, Context, check, group, guild_only + +from bot.bot import Bot +from bot.constants import Emojis +from bot.utils.pagination import LinePaginator + +CONFIRMATION_MESSAGE = ( + "{opponent}, {requester} wants to play Tic-Tac-Toe against you. React to this message with " + f"{Emojis.confirmation} to accept or with {Emojis.decline} to decline." +) + + +def check_win(board: t.Dict[int, str]) -> bool: + """Check from board, is any player won game.""" + return any( + ( + # Horizontal + board[1] == board[2] == board[3], + board[4] == board[5] == board[6], + board[7] == board[8] == board[9], + # Vertical + board[1] == board[4] == board[7], + board[2] == board[5] == board[8], + board[3] == board[6] == board[9], + # Diagonal + board[1] == board[5] == board[9], + board[3] == board[5] == board[7], + ) + ) + + +class Player: + """Class that contains information about player and functions that interact with player.""" + + def __init__(self, user: discord.User, ctx: Context, symbol: str): + self.user = user + self.ctx = ctx + self.symbol = symbol + + async def get_move(self, board: t.Dict[int, str], msg: discord.Message) -> t.Tuple[bool, t.Optional[int]]: + """ + Get move from user. + + Return is timeout reached and position of field what user will fill when timeout don't reach. + """ + def check_for_move(r: discord.Reaction, u: discord.User) -> bool: + """Check does user who reacted is user who we want, message is board and emoji is in board values.""" + return ( + u.id == self.user.id + and msg.id == r.message.id + and r.emoji in board.values() + and r.emoji in Emojis.number_emojis.values() + ) + + try: + react, _ = await self.ctx.bot.wait_for('reaction_add', timeout=30.0, check=check_for_move) + except asyncio.TimeoutError: + return True, None + else: + return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)] + + def __str__(self) -> str: + """Return mention of user.""" + return self.user.mention + + +class AI: + """Tic Tac Toe AI class for against computer gaming.""" + + def __init__(self, symbol: str): + self.symbol = symbol + + async def get_move(self, board: t.Dict[int, str], _: discord.Message) -> t.Tuple[bool, int]: + """Get move from AI. AI use Minimax strategy.""" + possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] + + for symbol in (Emojis.o, Emojis.x): + for move in possible_moves: + board_copy = board.copy() + board_copy[move] = symbol + if check_win(board_copy): + return False, move + + open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)] + if len(open_corners) > 0: + return False, random.choice(open_corners) + + if 5 in possible_moves: + return False, 5 + + open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)] + return False, random.choice(open_edges) + + def __str__(self) -> str: + """Return `AI` as user name.""" + return "AI" + + +class Game: + """Class that contains information and functions about Tic Tac Toe game.""" + + def __init__(self, players: t.List[t.Union[Player, AI]], ctx: Context): + self.players = players + self.ctx = ctx + self.board = { + 1: Emojis.number_emojis[1], + 2: Emojis.number_emojis[2], + 3: Emojis.number_emojis[3], + 4: Emojis.number_emojis[4], + 5: Emojis.number_emojis[5], + 6: Emojis.number_emojis[6], + 7: Emojis.number_emojis[7], + 8: Emojis.number_emojis[8], + 9: Emojis.number_emojis[9] + } + + self.current = self.players[0] + self.next = self.players[1] + + self.winner: t.Optional[t.Union[Player, AI]] = None + self.loser: t.Optional[t.Union[Player, AI]] = None + self.over = False + self.canceled = False + self.draw = False + + async def get_confirmation(self) -> t.Tuple[bool, t.Optional[str]]: + """ + Ask does user want to play TicTacToe against requester. First player is always requester. + + This return tuple that have: + - first element boolean (is game accepted?) + - (optional, only when first element is False, otherwise None) reason for declining. + """ + confirm_message = await self.ctx.send( + CONFIRMATION_MESSAGE.format( + opponent=self.players[1].user.mention, + requester=self.players[0].user.mention + ) + ) + await confirm_message.add_reaction(Emojis.confirmation) + await confirm_message.add_reaction(Emojis.decline) + + def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool: + """Check is user who reacted from who this was requested, message is confirmation and emoji is valid.""" + return ( + reaction.emoji in (Emojis.confirmation, Emojis.decline) + and reaction.message.id == confirm_message.id + and user == self.players[1].user + ) + + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=60.0, + check=confirm_check + ) + except asyncio.TimeoutError: + self.over = True + self.canceled = True + await confirm_message.delete() + return False, "Running out of time... Cancelled game." + + await confirm_message.delete() + if reaction.emoji == Emojis.confirmation: + return True, None + else: + self.over = True + self.canceled = True + return False, "User declined" + + async def add_reactions(self, msg: discord.Message) -> None: + """Add number emojis to message.""" + for nr in Emojis.number_emojis.values(): + await msg.add_reaction(nr) + + def format_board(self) -> str: + """Get formatted tic-tac-toe board for message.""" + board = list(self.board.values()) + return "\n".join( + (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) + ) + + async def play(self) -> None: + """Start and handle game.""" + await self.ctx.send("It's time for the game! Let's begin.") + board = await self.ctx.send( + embed=discord.Embed(description=self.format_board()) + ) + await self.add_reactions(board) + + for _ in range(9): + if isinstance(self.current, Player): + announce = await self.ctx.send( + f"{self.current.user.mention}, it's your turn! " + "React with an emoji to take your go." + ) + timeout, pos = await self.current.get_move(self.board, board) + if isinstance(self.current, Player): + await announce.delete() + if timeout: + await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.") + self.over = True + self.canceled = True + return + self.board[pos] = self.current.symbol + await board.edit( + embed=discord.Embed(description=self.format_board()) + ) + await board.clear_reaction(Emojis.number_emojis[pos]) + if check_win(self.board): + self.winner = self.current + self.loser = self.next + await self.ctx.send( + f":tada: {self.current} won this game! :tada:" + ) + await board.clear_reactions() + break + self.current, self.next = self.next, self.current + if not self.winner: + self.draw = True + await self.ctx.send("It's a DRAW!") + self.over = True + + +def is_channel_free() -> t.Callable: + """Check is channel where command will be invoked free.""" + async def predicate(ctx: Context) -> bool: + return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over) + return check(predicate) + + +def is_requester_free() -> t.Callable: + """Check is requester not already in any game.""" + async def predicate(ctx: Context) -> bool: + return all( + ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over + ) + return check(predicate) + + +class TicTacToe(Cog): + """TicTacToe cog contains tic-tac-toe game commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.games: t.List[Game] = [] + + @guild_only() + @is_channel_free() + @is_requester_free() + @group(name="tictactoe", aliases=("ttt",), invoke_without_command=True) + async def tic_tac_toe(self, ctx: Context, opponent: t.Optional[discord.User]) -> None: + """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" + if opponent == ctx.author: + await ctx.send("You can't play against yourself.") + return + if opponent is not None and not all( + opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over + ): + await ctx.send("Opponent is already in game.") + return + if opponent is None: + game = Game( + [Player(ctx.author, ctx, Emojis.x), AI(Emojis.o)], + ctx + ) + else: + game = Game( + [Player(ctx.author, ctx, Emojis.x), Player(opponent, ctx, Emojis.o)], + ctx + ) + self.games.append(game) + if opponent is not None: + confirmed, msg = await game.get_confirmation() + + if not confirmed: + if msg: + await ctx.send(msg) + return + await game.play() + + @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True) + async def tic_tac_toe_logs(self, ctx: Context) -> None: + """Show most recent tic-tac-toe games.""" + if len(self.games) < 1: + await ctx.send("No recent games.") + return + log_games = [] + for i, game in enumerate(self.games): + if game.over and not game.canceled: + if game.draw: + log_games.append( + f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)" + ) + else: + log_games.append( + f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}" + ) + await LinePaginator.paginate( + log_games, + ctx, + discord.Embed(title="Most recent Tic Tac Toe games") + ) + + @tic_tac_toe_logs.command(name="show", aliases=("s",)) + async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None: + """View game board by ID (ID is possible to get by `.tictactoe history`).""" + if len(self.games) < game_id: + await ctx.send("Game don't exist.") + return + game = self.games[game_id - 1] + await ctx.send(f"{game.winner} :trophy: vs {game.loser}") + await ctx.send(game.format_board()) + + +def setup(bot: Bot) -> None: + """Load TicTacToe Cog.""" + bot.add_cog(TicTacToe(bot)) diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py index be36e2c4..068c4f43 100644 --- a/bot/exts/evergreen/wikipedia.py +++ b/bot/exts/evergreen/wikipedia.py @@ -1,114 +1,94 @@ -import asyncio -import datetime import logging +import re +from datetime import datetime +from html import unescape from typing import List, Optional -from aiohttp import client_exceptions -from discord import Color, Embed, Message +from discord import Color, Embed, TextChannel from discord.ext import commands -from bot.constants import Wikipedia +from bot.bot import Bot +from bot.utils import LinePaginator log = logging.getLogger(__name__) -SEARCH_API = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={search_term}&format=json" -WIKIPEDIA_URL = "https://en.wikipedia.org/wiki/{title}" +SEARCH_API = ( + "https://en.wikipedia.org/w/api.php?action=query&list=search&prop=info&inprop=url&utf8=&" + "format=json&origin=*&srlimit={number_of_results}&srsearch={string}" +) +WIKI_THUMBNAIL = ( + "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg" + "/330px-Wikipedia-logo-v2.svg.png" +) +WIKI_SNIPPET_REGEX = r'(<!--.*?-->|<[^>]*>)' +WIKI_SEARCH_RESULT = ( + "**[{name}]({url})**\n" + "{description}\n" +) class WikipediaSearch(commands.Cog): """Get info from wikipedia.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot - self.http_session = bot.http_session - @staticmethod - def formatted_wiki_url(index: int, title: str) -> str: - """Formating wikipedia link with index and title.""" - return f'`{index}` [{title}]({WIKIPEDIA_URL.format(title=title.replace(" ", "_"))})' + async def wiki_request(self, channel: TextChannel, search: str) -> Optional[List[str]]: + """Search wikipedia search string and return formatted first 10 pages found.""" + url = SEARCH_API.format(number_of_results=10, string=search) + async with self.bot.http_session.get(url=url) as resp: + if resp.status == 200: + raw_data = await resp.json() + number_of_results = raw_data['query']['searchinfo']['totalhits'] + + if number_of_results: + results = raw_data['query']['search'] + lines = [] + + for article in results: + line = WIKI_SEARCH_RESULT.format( + name=article['title'], + description=unescape( + re.sub( + WIKI_SNIPPET_REGEX, '', article['snippet'] + ) + ), + url=f"https://en.wikipedia.org/?curid={article['pageid']}" + ) + lines.append(line) + + return lines - async def search_wikipedia(self, search_term: str) -> Optional[List[str]]: - """Search wikipedia and return the first 10 pages found.""" - pages = [] - async with self.http_session.get(SEARCH_API.format(search_term=search_term)) as response: - try: - data = await response.json() - - search_results = data["query"]["search"] - - # Ignore pages with "may refer to" - for search_result in search_results: - log.info("trying to append titles") - if "may refer to" not in search_result["snippet"]: - pages.append(search_result["title"]) - except client_exceptions.ContentTypeError: - pages = None - - log.info("Finished appending titles") - return pages + else: + await channel.send( + "Sorry, we could not find a wikipedia article using that search term." + ) + return + else: + log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") + await channel.send( + "Whoops, the Wikipedia API is having some issues right now. Try again later." + ) + return @commands.cooldown(1, 10, commands.BucketType.user) @commands.command(name="wikipedia", aliases=["wiki"]) async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: - """Return list of results containing your search query from wikipedia.""" - titles = await self.search_wikipedia(search) - - def check(message: Message) -> bool: - return message.author.id == ctx.author.id and message.channel == ctx.channel - - if not titles: - await ctx.send("Sorry, we could not find a wikipedia article using that search term") - return - - async with ctx.typing(): - log.info("Finished appending titles to titles_no_underscore list") - - s_desc = "\n".join(self.formatted_wiki_url(index, title) for index, title in enumerate(titles, start=1)) - embed = Embed(colour=Color.blue(), title=f"Wikipedia results for `{search}`", description=s_desc) - embed.timestamp = datetime.datetime.utcnow() - await ctx.send(embed=embed) - embed = Embed(colour=Color.green(), description="Enter number to choose") - msg = await ctx.send(embed=embed) - titles_len = len(titles) # getting length of list - - for retry_count in range(1, Wikipedia.total_chance + 1): - retries_left = Wikipedia.total_chance - retry_count - if retry_count < Wikipedia.total_chance: - error_msg = f"You have `{retries_left}/{Wikipedia.total_chance}` chances left" - else: - error_msg = 'Please try again by using `.wiki` command' - try: - message = await ctx.bot.wait_for('message', timeout=60.0, check=check) - response_from_user = await self.bot.get_context(message) - - if response_from_user.command: - return - - response = int(message.content) - if response < 0: - await ctx.send(f"Sorry, but you can't give negative index, {error_msg}") - elif response == 0: - await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}") - else: - await ctx.send(WIKIPEDIA_URL.format(title=titles[response - 1].replace(" ", "_"))) - break - - except asyncio.TimeoutError: - embed = Embed(colour=Color.red(), description=f"Time's up {ctx.author.mention}") - await msg.edit(embed=embed) - break - - except ValueError: - await ctx.send(f"Sorry, but you cannot do that, I will only accept an positive integer, {error_msg}") - - except IndexError: - await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}") - - except Exception as e: - log.info(f"Caught exception {e}, breaking out of retry loop") - break - - -def setup(bot: commands.Bot) -> None: + """Sends paginated top 10 results of Wikipedia search..""" + contents = await self.wiki_request(ctx.channel, search) + + if contents: + embed = Embed( + title="Wikipedia Search Results", + colour=Color.blurple() + ) + embed.set_thumbnail(url=WIKI_THUMBNAIL) + embed.timestamp = datetime.utcnow() + await LinePaginator.paginate( + contents, ctx, embed + ) + + +def setup(bot: Bot) -> None: """Wikipedia Cog load.""" bot.add_cog(WikipediaSearch(bot)) diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py index 898e8d2a..437d9e1a 100644 --- a/bot/exts/evergreen/wolfram.py +++ b/bot/exts/evergreen/wolfram.py @@ -108,7 +108,10 @@ async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional "input": query, "appid": APPID, "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" + "format": "image,plaintext", + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" }) request_url = QUERY.format(request="query", data=url_str) @@ -168,6 +171,9 @@ class Wolfram(Cog): url_str = parse.urlencode({ "i": query, "appid": APPID, + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" }) query = QUERY.format(request="simple", data=url_str) @@ -248,6 +254,9 @@ class Wolfram(Cog): url_str = parse.urlencode({ "i": query, "appid": APPID, + "location": "the moon", + "latlong": "0.0,0.0", + "ip": "1.1.1.1" }) query = QUERY.format(request="result", data=url_str) diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py index d3224bfe..1ff98ca2 100644 --- a/bot/exts/evergreen/xkcd.py +++ b/bot/exts/evergreen/xkcd.py @@ -69,6 +69,8 @@ class XKCD(Cog): return embed.title = f"XKCD comic #{info['num']}" + embed.description = info['alt'] + embed.url = f"{BASE_URL}/{info['num']}" if info["img"][-3:] in ("jpg", "png", "gif"): embed.set_image(url=info["img"]) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index a1c55922..d9fc0e8a 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -11,7 +11,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS -from bot.utils.decorators import in_month, override_in_channel +from bot.utils.decorators import in_month, whitelist_override log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ class HacktoberStats(commands.Cog): @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) - @override_in_channel(HACKTOBER_WHITELIST) + @whitelist_override(channels=HACKTOBER_WHITELIST) async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None: """ Display an embed for a user's Hacktoberfest contributions. @@ -72,7 +72,7 @@ class HacktoberStats(commands.Cog): @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) @hacktoberstats_group.command(name="link") - @override_in_channel(HACKTOBER_WHITELIST) + @whitelist_override(channels=HACKTOBER_WHITELIST) async def link_user(self, ctx: commands.Context, github_username: str = None) -> None: """ Link the invoking user's Github github_username to their Discord ID. @@ -96,7 +96,7 @@ class HacktoberStats(commands.Cog): @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) @hacktoberstats_group.command(name="unlink") - @override_in_channel(HACKTOBER_WHITELIST) + @whitelist_override(channels=HACKTOBER_WHITELIST) async def unlink_user(self, ctx: commands.Context) -> None: """Remove the invoking user's account link from the log.""" author_id, author_mention = self._author_mention_from_context(ctx) diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py index 4db4d191..f3392bcb 100644 --- a/bot/exts/valentines/be_my_valentine.py +++ b/bot/exts/valentines/be_my_valentine.py @@ -2,13 +2,13 @@ import logging import random from json import load from pathlib import Path -from typing import Optional, Tuple +from typing import Tuple import discord from discord.ext import commands from discord.ext.commands.cooldowns import BucketType -from bot.constants import Channels, Client, Colours, Lovefest, Month +from bot.constants import Channels, Colours, Lovefest, Month from bot.utils.decorators import in_month log = logging.getLogger(__name__) @@ -70,44 +70,35 @@ class BeMyValentine(commands.Cog): @commands.cooldown(1, 1800, BucketType.user) @commands.group(name='bemyvalentine', invoke_without_command=True) async def send_valentine( - self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None ) -> None: """ - Send a valentine to user, if specified, or to a random user with the lovefest role. + Send a valentine to a specified user with the lovefest role. - syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message] (optional) - example: .bemyvalentine (sends valentine as a poem or a compliment to a random user) example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. """ if ctx.guild is None: # This command should only be used in the server - msg = "You are supposed to use this command in the server." - return await ctx.send(msg) + raise commands.UserInputError("You are supposed to use this command in the server.") - if user: - if Lovefest.role_id not in [role.id for role in user.roles]: - message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" - return await ctx.send(message) + if Lovefest.role_id not in [role.id for role in user.roles]: + raise commands.UserInputError( + f"You cannot send a valentine to {user} as they do not have the lovefest role!" + ) if user == ctx.author: # Well a user can't valentine himself/herself. - return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:") + raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") emoji_1, emoji_2 = self.random_emoji() - lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) channel = self.bot.get_channel(Channels.community_bot_commands) valentine, title = self.valentine_check(valentine_type) - if user is None: - author = ctx.author - user = self.random_user(author, lovefest_role.members) - if user is None: - return await ctx.send("There are no users avilable to whome your valentine can be sent.") - embed = discord.Embed( title=f'{emoji_1} {title} {user.display_name} {emoji_2}', description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**', @@ -118,56 +109,41 @@ class BeMyValentine(commands.Cog): @commands.cooldown(1, 1800, BucketType.user) @send_valentine.command(name='secret') async def anonymous( - self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None ) -> None: """ - Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. - - **This command should be DMed to the bot.** + Send an anonymous Valentine via DM to to a specified user with the lovefest role. - syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message] (optional) - example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you - anonymous) example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous) example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to Iceman in DM making you anonymous) """ - if ctx.guild is not None: - # This command is only DM specific - msg = "You are not supposed to use this command in the server, DM the command to the bot." - return await ctx.send(msg) - - if user: - if Lovefest.role_id not in [role.id for role in user.roles]: - message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" - return await ctx.send(message) + if Lovefest.role_id not in [role.id for role in user.roles]: + await ctx.message.delete() + raise commands.UserInputError( + f"You cannot send a valentine to {user} as they do not have the lovefest role!" + ) if user == ctx.author: # Well a user cant valentine himself/herself. - return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:') + raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:") - guild = self.bot.get_guild(id=Client.guild) emoji_1, emoji_2 = self.random_emoji() - lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id) valentine, title = self.valentine_check(valentine_type) - if user is None: - author = ctx.author - user = self.random_user(author, lovefest_role.members) - if user is None: - return await ctx.send("There are no users avilable to whome your valentine can be sent.") - embed = discord.Embed( title=f'{emoji_1}{title} {user.display_name}{emoji_2}', description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**', color=Colours.pink ) + await ctx.message.delete() try: await user.send(embed=embed) except discord.Forbidden: - await ctx.author.send(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") + raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") else: await ctx.author.send(f"Your message has been sent to {user}") @@ -191,18 +167,6 @@ class BeMyValentine(commands.Cog): return valentine, title @staticmethod - def random_user(author: discord.Member, members: discord.Member) -> None: - """ - Picks a random member from the list provided in `members`. - - The invoking author is ignored. - """ - if author in members: - members.remove(author) - - return random.choice(members) if members else None - - @staticmethod def random_emoji() -> Tuple[str, str]: """Return two random emoji from the module-defined constants.""" emoji_1 = random.choice(HEART_EMOJIS) diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py index c75ea6cf..966acc82 100644 --- a/bot/exts/valentines/lovecalculator.py +++ b/bot/exts/valentines/lovecalculator.py @@ -4,15 +4,13 @@ import json import logging import random from pathlib import Path -from typing import Union +from typing import Coroutine, Union import discord from discord import Member from discord.ext import commands from discord.ext.commands import BadArgument, Cog, clean_content -from bot.constants import Roles - log = logging.getLogger(__name__) with Path("bot/resources/valentines/love_matches.json").open(encoding="utf8") as file: @@ -46,14 +44,11 @@ class LoveCalculator(Cog): If you want to use multiple words for one argument, you must include quotes. .love "Zes Vappa" "morning coffee" - - If only one argument is provided, the subject will become one of the helpers at random. """ if whom is None: - staff = ctx.guild.get_role(Roles.helpers).members - whom = random.choice(staff) + whom = ctx.author - def normalize(arg: Union[Member, str]) -> str: + def normalize(arg: Union[Member, str]) -> Coroutine: if isinstance(arg, Member): # If we are given a member, return name#discrim without any extra changes arg = str(arg) diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml index 1e53429a..f3b2eaa3 100644 --- a/bot/resources/evergreen/py_topics.yaml +++ b/bot/resources/evergreen/py_topics.yaml @@ -3,8 +3,6 @@ # python-general 267624335836053506: - What's your favorite PEP? - - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python? - - What functionality is your text editor/IDE missing for programming Python? - What parts of your life has Python automated, if any? - Which Python project are you the most proud of making? - What made you want to learn Python? @@ -16,23 +14,34 @@ - What feature do you think should be added to Python? - Has Python helped you in school? If so, how? - What was the first thing you created with Python? + - What is your favorite Python package? + - What standard library module is really underrated? + - Have you published any packages on PyPi? If so, what are they? + - What are you currently working on in Python? + - What's your favorite script and how has it helped you in day to day activities? + - When you were first learning, what is something that stumped you? + - When you were first learning, what is a resource you wish you had? + - What is something you know now, that you wish you knew when starting out? + - What is something simple that you still error on today? + +# algos-and-data-structs +650401909852864553: + - # async 630504881542791169: - Are there any frameworks you wish were async? - How have coroutines changed the way you write Python? + - What is your favorite async library? # c-extensions 728390945384431688: - -# computer-science -650401909852864553: - - - # databases 342318764227821568: - Where do you get your best data? + - What is your preferred database and for what use? # data-science 366673247892275221: @@ -45,11 +54,18 @@ - What feature would you be the most interested in making? - What feature would you like to see added to the library? what feature in the library do you think is redundant? - Do you think there's a way in which Discord could handle bots better? + - What's one feature you wish more developers had in their bots? + +# editors-ides +813178633006350366: + - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python? + - What functionality is your text editor/IDE missing for programming Python? # esoteric-python 470884583684964352: - What's a common part of programming we can make harder? - What are the pros and cons of messing with __magic__()? + - What's your favorite Python hack? # game-development 660625198390837248: @@ -57,7 +73,7 @@ # microcontrollers 545603026732318730: - - + - What is your favorite version of the Raspberry Pi? # networking 716325106619777044: @@ -67,23 +83,40 @@ 366674035876167691: - If you could wish for a library involving net-sec, what would it be? -# software-testing -463035728335732738: +# software-design +782713858615017503: - # tools-and-devops 463035462760792066: - What editor would you recommend to a beginner? Why? - What editor would you recommend to be the most efficient? Why? + - How often do you use GitHub Actions and workflows to automate your repositories? + - What's your favorite app on GitHub? + +# unit-testing +463035728335732738: + - # unix 491523972836360192: - - + - What's your favorite Bash command? + - What's your most used Bash command? + - How often do you update your Unix machine? + - How often do you upgrade on production? # user-interfaces 338993628049571840: - What's the most impressive Desktop Application you've made with Python so far? + - Have you ever made your own GUI? If so, how? + - Do you perfer Command Line Interfaces (CLI) or Graphic User Interfaces (GUI)? + - What's your favorite CLI (Command Line Interface) or TUI (Terminal Line Interface)? + - What's your best GUI project? # web-development 366673702533988363: - How has Python helped you in web development? + - What tools do you use for web development? + - What is your favorite API library? + - What do you use for your frontend? + - What does your stack look like? diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml index 53c89364..949220f9 100644 --- a/bot/resources/evergreen/starter.yaml +++ b/bot/resources/evergreen/starter.yaml @@ -20,3 +20,14 @@ - If you had $100 bill in your Easter Basket, what would you do with it? - What would you do if you know you could succeed at anything you chose to do? - If you could take only three things from your house, what would they be? +- What's the best pastry? +- What's your favourite kind of soup? +- What is the most useless talent that you have? +- Would you rather fight 100 duck sized horses or one horse sized duck? +- What is your favourite color? +- What's your favourite type of weather? +- Tea or coffee? What about milk? +- Do you speak a language other than English? +- What is your favorite TV show? +- What is your favorite media genre? +- How many years have you spent coding? diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py index 9cdaad3f..c12a15ff 100644 --- a/bot/utils/decorators.py +++ b/bot/utils/decorators.py @@ -13,6 +13,7 @@ from discord.ext.commands import CheckFailure, Command, Context from bot.constants import ERROR_REPLIES, Month from bot.utils import human_months, resolve_current_month +from bot.utils.checks import in_whitelist_check ONE_DAY = 24 * 60 * 60 @@ -186,82 +187,104 @@ def without_role(*role_ids: int) -> t.Callable: return commands.check(predicate) -def in_channel_check(*channels: int, bypass_roles: t.Container[int] = None) -> t.Callable[[Context], bool]: +def whitelist_check(**default_kwargs: t.Container[int]) -> t.Callable[[Context], bool]: """ - Checks that the message is in a whitelisted channel or optionally has a bypass role. + Checks if a message is sent in a whitelisted context. - If `in_channel_override` is present, check if it contains channels - and use them in place of the global whitelist. + All arguments from `in_whitelist_check` are supported, with the exception of "fail_silently". + If `whitelist_override` is present, it is added to the global whitelist. """ def predicate(ctx: Context) -> bool: + # Skip DM invocations if not ctx.guild: log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.") return True - if ctx.channel.id in channels: - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command " - f"and the command was used in a whitelisted channel." - ) - return True - if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles): - log.debug( - f"{ctx.author} called the '{ctx.command.name}' command and " - f"had a role to bypass the in_channel check." - ) - return True + kwargs = default_kwargs.copy() - if hasattr(ctx.command.callback, "in_channel_override"): - override = ctx.command.callback.in_channel_override - if override is None: + # Update kwargs based on override + if hasattr(ctx.command.callback, "override"): + # Remove default kwargs if reset is True + if ctx.command.callback.override_reset: + kwargs = {} log.debug( - f"{ctx.author} called the '{ctx.command.name}' command " - f"and the command was whitelisted to bypass the in_channel check." + f"{ctx.author} called the '{ctx.command.name}' command and " + f"overrode default checks." ) - return True - else: - if ctx.channel.id in override: - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command " - f"and the command was used in an overridden whitelisted channel." - ) - return True - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The overridden in_channel check failed." - ) - channels_str = ', '.join(f"<#{c_id}>" for c_id in override) - raise InChannelCheckFailure( - f"Sorry, but you may only use this command within {channels_str}." - ) + # Merge overwrites and defaults + for arg in ctx.command.callback.override: + default_value = kwargs.get(arg) + new_value = ctx.command.callback.override[arg] + + # Skip values that don't need merging, or can't be merged + if default_value is None or isinstance(arg, int): + kwargs[arg] = new_value + + # Merge containers + elif isinstance(default_value, t.Container): + if isinstance(new_value, t.Container): + kwargs[arg] = (*default_value, *new_value) + else: + kwargs[arg] = new_value + + log.debug( + f"Updated default check arguments for '{ctx.command.name}' " + f"invoked by {ctx.author}." + ) + + log.trace(f"Calling whitelist check for {ctx.author} for command {ctx.command.name}.") + result = in_whitelist_check(ctx, fail_silently=True, **kwargs) + + # Return if check passed + if result: + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command " + f"and the command was used in an overridden context." + ) + return result log.debug( f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The in_channel check failed." + f"The whitelist check failed." ) - channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) - raise InChannelCheckFailure( - f"Sorry, but you may only use this command within {channels_str}." - ) + # Raise error if the check did not pass + channels = set(kwargs.get("channels") or {}) + categories = kwargs.get("categories") - return predicate + # Add all whitelisted category channels + if categories: + for category_id in categories: + category = ctx.guild.get_channel(category_id) + if category is None: + continue + [channels.add(channel.id) for channel in category.text_channels] -in_channel = commands.check(in_channel_check) + if channels: + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + message = f"Sorry, but you may only use this command within {channels_str}." + else: + message = "Sorry, but you may not use this command." + + raise InChannelCheckFailure(message) + + return predicate -def override_in_channel(channels: t.Tuple[int] = None) -> t.Callable: +def whitelist_override(bypass_defaults: bool = False, **kwargs: t.Container[int]) -> t.Callable: """ - Set command callback attribute for detection in `in_channel_check`. + Override global whitelist context, with the kwargs specified. - Override global whitelist if channels are specified. + All arguments from `in_whitelist_check` are supported, with the exception of `fail_silently`. + Set `bypass_defaults` to True if you want to completely bypass global checks. This decorator has to go before (below) below the `command` decorator. """ def inner(func: t.Callable) -> t.Callable: - func.in_channel_override = channels + func.override = kwargs + func.override_reset = bypass_defaults return func return inner |