diff options
author | 2019-08-14 15:24:01 -0400 | |
---|---|---|
committer | 2019-08-14 15:24:01 -0400 | |
commit | 119d542483f59d3f581b168854af9012208abf7d (patch) | |
tree | 7706226d2e01cfb128a71d1ca52404a1246407e3 | |
parent | moved data grab to module level, changed var to constant (diff) | |
parent | Merge pull request #257 from vivax3794/minesweeper-lenght-fix (diff) |
Merge pull request #1 from python-discord/master
update
-rw-r--r-- | bot/__main__.py | 5 | ||||
-rw-r--r-- | bot/bot.py | 9 | ||||
-rw-r--r-- | bot/constants.py | 62 | ||||
-rw-r--r-- | bot/decorators.py | 93 | ||||
-rw-r--r-- | bot/seasons/christmas/adventofcode.py | 2 | ||||
-rw-r--r-- | bot/seasons/easter/egg_hunt/cog.py | 3 | ||||
-rw-r--r-- | bot/seasons/easter/egg_hunt/constants.py | 3 | ||||
-rw-r--r-- | bot/seasons/evergreen/error_handler.py | 15 | ||||
-rw-r--r-- | bot/seasons/evergreen/issues.py | 2 | ||||
-rw-r--r-- | bot/seasons/evergreen/minesweeper.py | 266 | ||||
-rw-r--r-- | bot/seasons/season.py | 3 |
11 files changed, 432 insertions, 31 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index a3b68ec1..9dc0b173 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,8 +1,11 @@ import logging -from bot.constants import Client, bot +from bot.bot import bot +from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS +from bot.decorators import in_channel_check log = logging.getLogger(__name__) +bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES)) bot.load_extension("bot.seasons") bot.run(Client.token) @@ -7,11 +7,11 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Embed from discord.ext import commands -from bot import constants +from bot.constants import Channels, Client log = logging.getLogger(__name__) -__all__ = ('SeasonalBot',) +__all__ = ('SeasonalBot', 'bot') class SeasonalBot(commands.Bot): @@ -42,7 +42,7 @@ class SeasonalBot(commands.Bot): async def send_log(self, title: str, details: str = None, *, icon: str = None): """Send an embed message to the devlog channel.""" - devlog = self.get_channel(constants.Channels.devlog) + devlog = self.get_channel(Channels.devlog) if not devlog: log.warning("Log failed to send. Devlog channel not found.") @@ -62,3 +62,6 @@ class SeasonalBot(commands.Bot): context.command.reset_cooldown(context) else: await super().on_command_error(context, exception) + + +bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/constants.py b/bot/constants.py index 8902d918..dbf35754 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -2,11 +2,10 @@ import logging from os import environ from typing import NamedTuple -from bot.bot import SeasonalBot - __all__ = ( - "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", - "Tokens", "ERROR_REPLIES", "bot" + "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens", + "WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES", + "POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES", ) log = logging.getLogger(__name__) @@ -118,6 +117,58 @@ class Tokens(NamedTuple): youtube = environ.get("YOUTUBE_API_KEY") +# Default role combinations +MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner +STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner + +# Whitelisted channels +WHITELISTED_CHANNELS = ( + Channels.bot, Channels.seasonalbot_commands, + Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2, + Channels.devtest, +) + +# Bot replies +NEGATIVE_REPLIES = [ + "Noooooo!!", + "Nope.", + "I'm sorry Dave, I'm afraid I can't do that.", + "I don't think so.", + "Not gonna happen.", + "Out of the question.", + "Huh? No.", + "Nah.", + "Naw.", + "Not likely.", + "No way, José.", + "Not in a million years.", + "Fat chance.", + "Certainly not.", + "NEGATORY.", + "Nuh-uh.", + "Not in my house!", +] + +POSITIVE_REPLIES = [ + "Yep.", + "Absolutely!", + "Can do!", + "Affirmative!", + "Yeah okay.", + "Sure.", + "Sure thing!", + "You're the boss!", + "Okay.", + "No problem.", + "I got you.", + "Alright.", + "You got it!", + "ROGER THAT", + "Of course!", + "Aye aye, cap'n!", + "I'll allow it.", +] + ERROR_REPLIES = [ "Please don't do that.", "You have to stop.", @@ -130,6 +181,3 @@ ERROR_REPLIES = [ "Noooooo!!", "I can't believe you've done this", ] - - -bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/decorators.py b/bot/decorators.py index dfe80e5c..02cf4b8a 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,24 +1,33 @@ import logging import random +import typing from asyncio import Lock from functools import wraps from weakref import WeakValueDictionary from discord import Colour, Embed from discord.ext import commands -from discord.ext.commands import Context +from discord.ext.commands import CheckFailure, Context from bot.constants import ERROR_REPLIES log = logging.getLogger(__name__) +class InChannelCheckFailure(CheckFailure): + """Check failure when the user runs a command in a non-whitelisted channel.""" + + pass + + def with_role(*role_ids: int): """Check to see whether the invoking user has any of the roles specified in role_ids.""" async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request.") + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request." + ) return False for role in ctx.author.roles: @@ -26,8 +35,10 @@ def with_role(*role_ids: int): log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") return True - log.debug(f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected.") + log.debug( + f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected." + ) return False return commands.check(predicate) @@ -36,26 +47,74 @@ def without_role(*role_ids: int): """Check whether the invoking user does not have all of the roles specified in role_ids.""" async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request.") + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request." + ) return False author_roles = [role.id for role in ctx.author.roles] check = all(role not in author_roles for role in role_ids) - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}.") + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}." + ) return check return commands.check(predicate) -def in_channel(channel_id): - """Check that the command invocation is in the channel specified by channel_id.""" - async def predicate(ctx: Context): - check = ctx.channel.id == channel_id - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the in_channel check was {check}.") - return check - return commands.check(predicate) +def in_channel_check(*channels: int, bypass_roles: typing.Container[int] = None) -> typing.Callable[[Context], bool]: + """Checks that the message is in a whitelisted channel or optionally has a bypass role.""" + def predicate(ctx: Context) -> bool: + 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 hasattr(ctx.command.callback, "in_channel_override"): + log.debug( + f"{ctx.author} called the '{ctx.command.name}' command " + f"and the command was whitelisted to bypass the in_channel check." + ) + 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 + + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The in_channel 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}." + ) + + return predicate + + +in_channel = commands.check(in_channel_check) + + +def override_in_channel(func: typing.Callable) -> typing.Callable: + """ + Set command callback attribute for detection in `in_channel_check`. + + This decorator has to go before (below) below the `command` decorator. + """ + func.in_channel_override = True + return func def locked(): diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 08b07e83..a9e72805 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -14,6 +14,7 @@ from discord.ext import commands from pytz import timezone from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens +from bot.decorators import override_in_channel log = logging.getLogger(__name__) @@ -125,6 +126,7 @@ class AdventOfCode(commands.Cog): self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro)) @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) + @override_in_channel async def adventofcode_group(self, ctx: commands.Context): """All of the Advent of Code commands.""" await ctx.send_help(ctx.command) diff --git a/bot/seasons/easter/egg_hunt/cog.py b/bot/seasons/easter/egg_hunt/cog.py index 30fd3284..a4ad27df 100644 --- a/bot/seasons/easter/egg_hunt/cog.py +++ b/bot/seasons/easter/egg_hunt/cog.py @@ -9,7 +9,8 @@ from pathlib import Path import discord from discord.ext import commands -from bot.constants import Channels, Client, Roles as MainRoles, bot +from bot.bot import bot +from bot.constants import Channels, Client, Roles as MainRoles from bot.decorators import with_role from .constants import Colours, EggHuntSettings, Emoji, Roles diff --git a/bot/seasons/easter/egg_hunt/constants.py b/bot/seasons/easter/egg_hunt/constants.py index c7d9818b..02f6e9f2 100644 --- a/bot/seasons/easter/egg_hunt/constants.py +++ b/bot/seasons/easter/egg_hunt/constants.py @@ -2,7 +2,8 @@ import os from discord import Colour -from bot.constants import Channels, Client, bot +from bot.bot import bot +from bot.constants import Channels, Client GUILD = bot.get_guild(Client.guild) diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index f4457f8f..6690cf89 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -1,10 +1,15 @@ import logging
import math
+import random
import sys
import traceback
+from discord import Colour, Embed
from discord.ext import commands
+from bot.constants import NEGATIVE_REPLIES
+from bot.decorators import InChannelCheckFailure
+
log = logging.getLogger(__name__)
@@ -34,6 +39,16 @@ class CommandErrorHandler(commands.Cog): error = getattr(error, 'original', error)
+ if isinstance(error, InChannelCheckFailure):
+ logging.debug(
+ f"{ctx.author} the command '{ctx.command}', but they did not have "
+ f"permissions to run commands in the channel {ctx.channel}!"
+ )
+ embed = Embed(colour=Colour.red())
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = str(error)
+ return await ctx.send(embed=embed)
+
if isinstance(error, commands.CommandNotFound):
return logging.debug(
f"{ctx.author} called '{ctx.message.content}' but no command was found."
diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py index 2a31a2e1..f19a1129 100644 --- a/bot/seasons/evergreen/issues.py +++ b/bot/seasons/evergreen/issues.py @@ -4,6 +4,7 @@ import discord from discord.ext import commands from bot.constants import Colours +from bot.decorators import override_in_channel log = logging.getLogger(__name__) @@ -15,6 +16,7 @@ class Issues(commands.Cog): self.bot = bot @commands.command(aliases=("issues",)) + @override_in_channel async def issue(self, ctx, number: int, repository: str = "seasonalbot", user: str = "python-discord"): """Command to retrieve issues from a GitHub repository.""" api_url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py new file mode 100644 index 00000000..9f6aff95 --- /dev/null +++ b/bot/seasons/evergreen/minesweeper.py @@ -0,0 +1,266 @@ +import logging +import typing +from dataclasses import dataclass +from random import random + +import discord +from discord.ext import commands + +from bot.constants import Client + +MESSAGE_MAPPING = { + 0: ":stop_button:", + 1: ":one:", + 2: ":two:", + 3: ":three:", + 4: ":four:", + 5: ":five:", + 6: ":six:", + 7: ":seven:", + 8: ":eight:", + 9: ":nine:", + 10: ":keycap_ten:", + "bomb": ":bomb:", + "hidden": ":grey_question:", + "flag": ":pyflag:" +} + +log = logging.getLogger(__name__) + + +class CoordinateConverter(commands.Converter): + """Converter for Coordinates.""" + + async def convert(self, ctx, coordinate: str) -> typing.Tuple[int, int]: + """Take in a coordinate string and turn it into x, y""" + if not 2 <= len(coordinate) <= 3: + raise commands.BadArgument('Invalid co-ordinate provided') + + digit, letter = sorted(coordinate.lower()) + + if not digit.isdigit(): + raise commands.BadArgument + + x = ord(letter) - ord('a') + y = int(digit) - 1 + + if (not 0 <= x <= 9) or (not 0 <= y <= 9): + raise commands.BadArgument + return x, y + + +GameBoard = typing.List[typing.List[typing.Union[str, int]]] + + +@dataclass +class Game: + """The data for a game.""" + + board: GameBoard + revealed: GameBoard + dm_msg: discord.Message + chat_msg: discord.Message + activated_on_server: bool + + +GamesDict = typing.Dict[int, Game] + + +class Minesweeper(commands.Cog): + """Play a game of Minesweeper.""" + + def __init__(self, bot: commands.Bot) -> None: + self.games: GamesDict = {} # Store the currently running games + + @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True) + async def minesweeper_group(self, ctx: commands.Context): + """Commands for Playing Minesweeper""" + await ctx.send_help(ctx.command) + + @staticmethod + def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]: + """Get all the neighbouring x and y including it self.""" + for x_ in [x - 1, x, x + 1]: + for y_ in [y - 1, y, y + 1]: + if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: + yield x_, y_ + + def generate_board(self, bomb_chance: float) -> GameBoard: + """Generate a 2d array for the board.""" + board: GameBoard = [ + [ + "bomb" if random() <= bomb_chance else "number" + for _ in range(10) + ] for _ in range(10) + ] + for y, row in enumerate(board): + for x, cell in enumerate(row): + if cell == "number": + # calculate bombs near it + bombs = 0 + for x_, y_ in self.get_neighbours(x, y): + if board[y_][x_] == "bomb": + bombs += 1 + board[y][x] = bombs + return board + + @staticmethod + def format_for_discord(board: GameBoard) -> str: + """Format the board as a string for Discord.""" + discord_msg = ( + ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:" + ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" + ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n" + ) + rows = [] + for row_number, row in enumerate(board): + new_row = f"{MESSAGE_MAPPING[row_number + 1]} " + new_row += "".join(MESSAGE_MAPPING[cell] for cell in row) + rows.append(new_row) + + discord_msg += "\n".join(rows) + return discord_msg + + @minesweeper_group.command(name="start") + async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: + """Start a game of Minesweeper.""" + if ctx.author.id in self.games: # Player is already playing + await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2) + await ctx.message.delete(delay=2) + return + + # Add game to list + board: GameBoard = self.generate_board(bomb_chance) + revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] + + if ctx.guild: + await ctx.send(f"{ctx.author.mention} is playing Minesweeper") + chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}") + else: + chat_msg = None + + await ctx.author.send( + f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" + f"Close the game with `{Client.prefix}ms end`\n" + ) + dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") + + self.games[ctx.author.id] = Game( + board=board, + revealed=revealed_board, + dm_msg=dm_msg, + chat_msg=chat_msg, + activated_on_server=ctx.guild is not None + ) + + async def update_boards(self, ctx: commands.Context) -> None: + """Update both playing boards.""" + game = self.games[ctx.author.id] + await game.dm_msg.delete() + game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") + if game.activated_on_server: + await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}") + + @commands.dm_only() + @minesweeper_group.command(name="flag") + async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Place multiple flags on the board""" + board: GameBoard = self.games[ctx.author.id].revealed + for x, y in coordinates: + if board[y][x] == "hidden": + board[y][x] = "flag" + + await self.update_boards(ctx) + + async def lost(self, ctx: commands.Context) -> None: + """The player lost the game""" + game = self.games[ctx.author.id] + game.revealed = game.board + await ctx.author.send(":fire: You lost! :fire:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:") + + async def won(self, ctx: commands.Context) -> None: + """The player won the game""" + game = self.games[ctx.author.id] + game.revealed = game.board + await ctx.author.send(":tada: You won! :tada:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") + + def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: + """Recursively reveal adjacent cells when a 0 cell is encountered.""" + for x_, y_ in self.get_neighbours(x, y): + if revealed[y_][x_] != "hidden": + continue + revealed[y_][x_] = board[y_][x_] + if board[y_][x_] == 0: + self.reveal_zeros(revealed, board, x_, y_) + + async def check_if_won(self, ctx, revealed: GameBoard, board: GameBoard) -> bool: + """Checks if a player has won""" + if any( + revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb" + for x in range(10) + for y in range(10) + ): + return False + else: + await self.won(ctx) + return True + + async def reveal_one( + self, + ctx: commands.Context, + revealed: GameBoard, + board: GameBoard, + x: int, + y: int + ) -> bool: + """ + Reveal one square. + + return is True if the game ended, breaking the loop in `reveal_command` and deleting the game + """ + revealed[y][x] = board[y][x] + if board[y][x] == "bomb": + await self.lost(ctx) + return True + elif board[y][x] == 0: + self.reveal_zeros(revealed, board, x, y) + return await self.check_if_won(ctx, revealed, board) + + @commands.dm_only() + @minesweeper_group.command(name="reveal") + async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Reveal multiple cells""" + game = self.games[ctx.author.id] + revealed: GameBoard = game.revealed + board: GameBoard = game.board + + for x, y in coordinates: + # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game + if await self.reveal_one(ctx, revealed, board, x, y): + await self.update_boards(ctx) + del self.games[ctx.author.id] + break + else: + await self.update_boards(ctx) + + @minesweeper_group.command(name="end") + async def end_command(self, ctx: commands.Context): + """End your current game""" + game = self.games[ctx.author.id] + game.revealed = game.board + await self.update_boards(ctx) + new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}" + await game.dm_msg.edit(content=new_msg) + if game.activated_on_server: + await game.chat_msg.edit(content=new_msg) + del self.games[ctx.author.id] + + +def setup(bot: commands.Bot) -> None: + """Load the Minesweeper cog.""" + bot.add_cog(Minesweeper(bot)) + log.info("Minesweeper cog loaded") diff --git a/bot/seasons/season.py b/bot/seasons/season.py index 3b623040..c88ef2a7 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -12,7 +12,8 @@ import async_timeout import discord from discord.ext import commands -from bot.constants import Channels, Client, Roles, bot +from bot.bot import bot +from bot.constants import Channels, Client, Roles from bot.decorators import with_role log = logging.getLogger(__name__) |