diff options
| author | 2021-03-08 20:54:24 +1000 | |
|---|---|---|
| committer | 2021-03-08 20:54:24 +1000 | |
| commit | d21f4870506c97c3120b18cda6a50610022bc134 (patch) | |
| tree | 661c2a11d2b4d0e262f2b084e659f2d8521cbea5 /bot/exts | |
| parent | Improve punctuation and wording of .emoji count. (diff) | |
| parent | Merge pull request #598 from Arez1337/trivia (diff) | |
Merge branch 'master' into master
Diffstat (limited to 'bot/exts')
| -rw-r--r-- | bot/exts/christmas/advent_of_code/_cog.py | 20 | ||||
| -rw-r--r-- | bot/exts/easter/earth_photos.py | 61 | ||||
| -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/pythonfacts.py | 33 | ||||
| -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 | 91 | ||||
| -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 | 
17 files changed, 1397 insertions, 241 deletions
| 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/easter/earth_photos.py b/bot/exts/easter/earth_photos.py new file mode 100644 index 00000000..60e34b15 --- /dev/null +++ b/bot/exts/easter/earth_photos.py @@ -0,0 +1,61 @@ +import logging + +import discord +from discord.ext import commands + +from bot.constants import Colours +from bot.constants import Tokens + +log = logging.getLogger(__name__) + + +class EarthPhotos(commands.Cog): +    """This cog contains the command for earth photos.""" + +    def __init__(self, bot: commands.Bot): +        self.bot = bot + +    @commands.command(aliases=["earth"]) +    async def earth_photos(self, ctx: commands.Context) -> None: +        """Returns a random photo of earth, sourced from Unsplash.""" +        async with ctx.typing(): +            async with self.bot.http_session.get( +                    'https://api.unsplash.com/photos/random', +                    params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key} +            ) as r: +                jsondata = await r.json() +                linksdata = jsondata.get("urls") +                embedlink = linksdata.get("regular") +                downloadlinksdata = jsondata.get("links") +                userdata = jsondata.get("user") +                username = userdata.get("name") +                userlinks = userdata.get("links") +                profile = userlinks.get("html") +                # Referral flags +                rf = "?utm_source=Sir%20Lancebot&utm_medium=referral" +            async with self.bot.http_session.get( +                downloadlinksdata.get("download_location"), +                    params={"client_id": Tokens.unsplash_access_key} +            ) as _: +                pass + +            embed = discord.Embed( +                title="Earth Photo", +                description="A photo of Earth 🌎 from Unsplash.", +                color=Colours.grass_green +            ) +            embed.set_image(url=embedlink) +            embed.add_field( +                name="Author", +                value=f"Photo by [{username}]({profile}{rf}) \ +                on [Unsplash](https://unsplash.com{rf})." +            ) +            await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: +    """Load the Earth Photos cog.""" +    if not Tokens.unsplash_access_key: +        log.warning("No Unsplash access key found. Cog not loading.") +        return +    bot.add_cog(EarthPhotos(bot)) 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/pythonfacts.py b/bot/exts/evergreen/pythonfacts.py new file mode 100644 index 00000000..457c2fd3 --- /dev/null +++ b/bot/exts/evergreen/pythonfacts.py @@ -0,0 +1,33 @@ +import itertools + +import discord +from discord.ext import commands + +from bot.constants import Colours + +with open('bot/resources/evergreen/python_facts.txt') as file: +    FACTS = itertools.cycle(list(file)) + +COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow]) + + +class PythonFacts(commands.Cog): +    """Sends a random fun fact about Python.""" + +    def __init__(self, bot: commands.Bot) -> None: +        self.bot = bot + +    @commands.command(name='pythonfact', aliases=['pyfact']) +    async def get_python_fact(self, ctx: commands.Context) -> None: +        """Sends a Random fun fact about Python.""" +        embed = discord.Embed(title='Python Facts', +                                    description=next(FACTS), +                                    colour=next(COLORS)) +        embed.add_field(name='Suggestions', +                        value="Suggest more facts [here!](https://github.com/python-discord/meta/discussions/93)") +        await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: +    """Load PythonFacts Cog.""" +    bot.add_cog(PythonFacts(bot)) 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 new file mode 100644 index 00000000..1ff98ca2 --- /dev/null +++ b/bot/exts/evergreen/xkcd.py @@ -0,0 +1,91 @@ +import logging +import re +from random import randint +from typing import Dict, Optional, Union + +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +COMIC_FORMAT = re.compile(r"latest|[0-9]+") +BASE_URL = "https://xkcd.com" + + +class XKCD(Cog): +    """Retrieving XKCD comics.""" + +    def __init__(self, bot: Bot) -> None: +        self.bot = bot +        self.latest_comic_info: Dict[str, Union[str, int]] = {} +        self.get_latest_comic_info.start() + +    def cog_unload(self) -> None: +        """Cancels refreshing of the task for refreshing the most recent comic info.""" +        self.get_latest_comic_info.cancel() + +    @tasks.loop(minutes=30) +    async def get_latest_comic_info(self) -> None: +        """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" +        async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: +            if resp.status == 200: +                self.latest_comic_info = await resp.json() +            else: +                log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") + +    @command(name="xkcd") +    async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: +        """ +        Getting an xkcd comic's information along with the image. + +        To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. +        """ +        embed = Embed(title=f"XKCD comic '{comic}'") + +        embed.colour = Colours.soft_red + +        if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: +            embed.description = "Comic parameter should either be an integer or 'latest'." +            await ctx.send(embed=embed) +            return + +        comic = randint(1, self.latest_comic_info['num']) if comic is None else comic.group(0) + +        if comic == "latest": +            info = self.latest_comic_info +        else: +            async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: +                if resp.status == 200: +                    info = await resp.json() +                else: +                    embed.title = f"XKCD comic #{comic}" +                    embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." +                    log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.") +                    await ctx.send(embed=embed) +                    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"]) +            date = f"{info['year']}/{info['month']}/{info['day']}" +            embed.set_footer(text=f"{date} - #{info['num']}, \'{info['safe_title']}\'") +            embed.colour = Colours.soft_green +        else: +            embed.description = ( +                "The selected comic is interactive, and cannot be displayed within an embed.\n" +                f"Comic can be viewed [here](https://xkcd.com/{info['num']})." +            ) + +        await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: +    """Loading the XKCD cog.""" +    bot.add_cog(XKCD(bot)) 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) | 
