diff options
Diffstat (limited to 'bot/seasons')
| -rw-r--r-- | bot/seasons/christmas/adventofcode.py | 6 | ||||
| -rw-r--r-- | bot/seasons/christmas/hanukkah_embed.py | 112 | ||||
| -rw-r--r-- | bot/seasons/easter/april_fools_vids.py | 38 | ||||
| -rw-r--r-- | bot/seasons/easter/avatar_easterifier.py | 132 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_decorating.py | 1 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_hunt/__init__.py | 12 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_hunt/cog.py | 638 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_hunt/constants.py | 39 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/snakes_cog.py | 9 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/utils.py | 11 | ||||
| -rw-r--r-- | bot/seasons/halloween/candy_collection.py | 10 | ||||
| -rw-r--r-- | bot/seasons/halloween/halloween_facts.py | 4 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookyavatar.py | 5 | ||||
| -rw-r--r-- | bot/seasons/season.py | 2 | ||||
| -rw-r--r-- | bot/seasons/valentines/be_my_valentine.py | 6 | 
15 files changed, 994 insertions, 31 deletions
| diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 5d05dce6..440484b4 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -13,7 +13,7 @@ from bs4 import BeautifulSoup  from discord.ext import commands  from pytz import timezone -from bot.constants import AdventOfCode as AocConfig, Colours, Emojis, Tokens +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens  log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ async def day_countdown(bot: commands.Bot):          await asyncio.sleep(time_left.seconds) -        channel = bot.get_channel(AocConfig.channel_id) +        channel = bot.get_channel(Channels.seasonalbot_chat)          if not channel:              log.error("Could not find the AoC channel to send notification in") @@ -132,7 +132,7 @@ class AdventOfCode(commands.Cog):      async def adventofcode_group(self, ctx: commands.Context):          """All of the Advent of Code commands.""" -        await ctx.invoke(self.bot.get_command("help"), "adventofcode") +        await ctx.send_help(ctx.command)      @adventofcode_group.command(          name="subscribe", diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py new file mode 100644 index 00000000..652a1f35 --- /dev/null +++ b/bot/seasons/christmas/hanukkah_embed.py @@ -0,0 +1,112 @@ +import datetime +import logging + +from discord import Embed +from discord.ext import commands + +from bot.constants import Colours + + +log = logging.getLogger(__name__) + + +class HanukkahEmbed(commands.Cog): +    """A cog that returns information about Hanukkah festival.""" + +    def __init__(self, bot): +        self.bot = bot +        self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" +                    "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on") +        self.hanukkah_days = [] +        self.hanukkah_months = [] +        self.hanukkah_years = [] + +    async def get_hanukkah_dates(self): +        """Gets the dates for hanukkah festival.""" +        hanukkah_dates = [] +        async with self.bot.http_session.get(self.url) as response: +            json_data = await response.json() +        festivals = json_data['items'] +        for festival in festivals: +            if festival['title'].startswith('Chanukah'): +                date = festival['date'] +                hanukkah_dates.append(date) +        return hanukkah_dates + +    @commands.command(name='hanukkah', aliases=['chanukah']) +    async def hanukkah_festival(self, ctx): +        """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" +        hanukkah_dates = await self.get_hanukkah_dates() +        self.hanukkah_dates_split(hanukkah_dates) +        hanukkah_start_day = int(self.hanukkah_days[0]) +        hanukkah_start_month = int(self.hanukkah_months[0]) +        hanukkah_start_year = int(self.hanukkah_years[0]) +        hanukkah_end_day = int(self.hanukkah_days[8]) +        hanukkah_end_month = int(self.hanukkah_months[8]) +        hanukkah_end_year = int(self.hanukkah_years[8]) + +        hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) +        hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) +        today = datetime.date.today() +        # today = datetime.date(2019, 12, 24) (for testing) +        day = str(today.day) +        month = str(today.month) +        year = str(today.year) +        embed = Embed() +        embed.title = 'Hanukkah' +        embed.colour = Colours.blue +        if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: +            if int(day) == hanukkah_start_day: +                now = datetime.datetime.utcnow() +                now = str(now) +                hours = int(now[11:13]) + 4  # using only hours +                hanukkah_start_hour = 18 +                if hours < hanukkah_start_hour: +                    embed.description = (f"Hanukkah hasnt started yet, " +                                         f"it will start in about {hanukkah_start_hour-hours} hour/s.") +                    return await ctx.send(embed=embed) +                elif hours > hanukkah_start_hour: +                    embed.description = (f'It is the starting day of Hanukkah ! ' +                                         f'Its been {hours-hanukkah_start_hour} hours hanukkah started !') +                    return await ctx.send(embed=embed) +            festival_day = self.hanukkah_days.index(day) +            number_suffixes = ['st', 'nd', 'rd', 'th'] +            suffix = '' +            if int(festival_day) == 1: +                suffix = number_suffixes[0] +            if int(festival_day) == 2: +                suffix = number_suffixes[1] +            if int(festival_day) == 3: +                suffix = number_suffixes[2] +            if int(festival_day) > 3: +                suffix = number_suffixes[3] +            message = '' +            for _ in range(1, festival_day + 1): +                message += ':menorah:' +            embed.description = f'It is the {festival_day}{suffix} day of Hanukkah ! \n {message}' +            await ctx.send(embed=embed) +        else: +            if today < hanukkah_start: +                festival_starting_month = hanukkah_start.strftime('%B') +                embed.description = (f"Hanukkah has not started yet. " +                                     f"Hanukkah will start at sundown on {hanukkah_start_day}th " +                                     f"of {festival_starting_month}.") +            else: +                festival_end_month = hanukkah_end.strftime('%B') +                embed.description = (f"Looks like you missed Hanukkah !" +                                     f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}.") + +            await ctx.send(embed=embed) + +    def hanukkah_dates_split(self, hanukkah_dates): +        """We are splitting the dates for hanukkah into days, months and years.""" +        for date in hanukkah_dates: +            self.hanukkah_days.append(date[8:10]) +            self.hanukkah_months.append(date[5:7]) +            self.hanukkah_years.append(date[0:4]) + + +def setup(bot): +    """Cog load.""" +    bot.add_cog(HanukkahEmbed(bot)) +    log.info("Hanukkah embed cog loaded") diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py new file mode 100644 index 00000000..5dae8485 --- /dev/null +++ b/bot/seasons/easter/april_fools_vids.py @@ -0,0 +1,38 @@ +import logging +import random +from json import load +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class AprilFoolVideos(commands.Cog): +    """A cog for april fools that gets a random april fools video from youtube.""" +    def __init__(self, bot): +        self.bot = bot +        self.yt_vids = self.load_json() +        self.youtubers = ['google']  # will add more in future + +    @staticmethod +    def load_json(): +        """A function to load json data.""" +        p = Path('bot', 'resources', 'easter', 'april_fools_vids.json') +        with p.open() as json_file: +            all_vids = load(json_file) +        return all_vids + +    @commands.command(name='fool') +    async def aprial_fools(self, ctx): +        """Gets a random april fools video from youtube.""" +        random_youtuber = random.choice(self.youtubers) +        category = self.yt_vids[random_youtuber] +        random_vid = random.choice(category) +        await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}") + + +def setup(bot): +    """A function to add the cog.""" +    bot.add_cog(AprilFoolVideos(bot)) +    log.info('April Fools videos cog loaded!') diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py new file mode 100644 index 00000000..a84e5eb4 --- /dev/null +++ b/bot/seasons/easter/avatar_easterifier.py @@ -0,0 +1,132 @@ +import asyncio +import logging +from io import BytesIO +from pathlib import Path +from typing import Union + +import discord +from PIL import Image +from PIL.ImageOps import posterize +from discord.ext import commands + +log = logging.getLogger(__name__) + +COLOURS = [ +    (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), +    (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), +    (135, 206, 235), (0, 204, 204), (64, 224, 208) +]  # Pastel colours - Easter-like + + +class AvatarEasterifier(commands.Cog): +    """Put an Easter spin on your avatar or image!""" + +    def __init__(self, bot): +        self.bot = bot + +    @staticmethod +    def closest(x): +        """ +        Finds the closest easter colour to a given pixel. + +        Returns a merge between the original colour and the closest colour +        """ + +        r1, g1, b1 = x + +        def distance(point): +            """Finds the difference between a pastel colour and the original pixel colour""" + +            r2, g2, b2 = point +            return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) + +        closest_colours = sorted(COLOURS, key=lambda point: distance(point)) +        r2, g2, b2 = closest_colours[0] +        r = (r1 + r2) // 2 +        g = (g1 + g2) // 2 +        b = (b1 + b2) // 2 +        return (r, g, b) + +    @commands.command(pass_context=True, aliases=["easterify"]) +    async def avatareasterify(self, ctx, *colours: Union[discord.Colour, str]): +        """ +        This "Easterifies" the user's avatar. + +        Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. +        If colours are not given, a nice little chocolate bunny will sit in the corner. +        Colours are split by spaces, unless you wrap the colour name in double quotes. +        Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. +        """ + +        async def send(*args, **kwargs): +            """ +            This replaces the original ctx.send. + +            When invoking the egg decorating command, the egg itself doesn't print to to the channel. +            Returns the message content so that if any errors occur, the error message can be output. +            """ +            if args: +                return args[0] + +        async with ctx.typing(): + +            # Grabs image of avatar +            image_bytes = await ctx.author.avatar_url_as(size=256).read() + +            old = Image.open(BytesIO(image_bytes)) +            old = old.convert("RGBA") + +            # Grabs alpha channel since posterize can't be used with an RGBA image. +            alpha = old.getchannel("A").getdata() +            old = old.convert("RGB") +            old = posterize(old, 6) + +            data = old.getdata() +            setted_data = set(data) +            new_d = {} + +            for x in setted_data: +                new_d[x] = self.closest(x) +                await asyncio.sleep(0)  # Ensures discord doesn't break in the background. +            new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + +            im = Image.new("RGBA", old.size) +            im.putdata(new_data) + +            if colours: +                send_message = ctx.send +                ctx.send = send  # Assigns ctx.send to a fake send +                egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) +                if isinstance(egg, str):  # When an error message occurs in eggdecorate. +                    return await send_message(egg) + +                ratio = 64 / egg.height +                egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) +                egg = egg.convert("RGBA") +                im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2))  # Right centre. +                ctx.send = send_message  # Reassigns ctx.send +            else: +                bunny = Image.open(Path("bot", "resources", "easter", "chocolate_bunny.png")) +                im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2))  # Right centre. + +            bufferedio = BytesIO() +            im.save(bufferedio, format="PNG") + +            bufferedio.seek(0) + +            file = discord.File(bufferedio, filename="easterified_avatar.png")  # Creates file to be used in embed +            embed = discord.Embed( +                name="Your Lovely Easterified Avatar", +                description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" +            ) +            embed.set_image(url="attachment://easterified_avatar.png") +            embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + +        await ctx.send(file=file, embed=embed) + + +def setup(bot): +    """Cog load.""" + +    bot.add_cog(AvatarEasterifier(bot)) +    log.info("AvatarEasterifier cog loaded") diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py index b5f3e428..d283e42a 100644 --- a/bot/seasons/easter/egg_decorating.py +++ b/bot/seasons/easter/egg_decorating.py @@ -109,6 +109,7 @@ class EggDecorating(commands.Cog):              embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)          await ctx.send(file=file, embed=embed) +        return new_im  def setup(bot): diff --git a/bot/seasons/easter/egg_hunt/__init__.py b/bot/seasons/easter/egg_hunt/__init__.py new file mode 100644 index 00000000..43bda223 --- /dev/null +++ b/bot/seasons/easter/egg_hunt/__init__.py @@ -0,0 +1,12 @@ +import logging + +from .cog import EggHunt + +log = logging.getLogger(__name__) + + +def setup(bot): +    """Easter Egg Hunt Cog load.""" + +    bot.add_cog(EggHunt()) +    log.info("EggHunt cog loaded") diff --git a/bot/seasons/easter/egg_hunt/cog.py b/bot/seasons/easter/egg_hunt/cog.py new file mode 100644 index 00000000..c9e2dc18 --- /dev/null +++ b/bot/seasons/easter/egg_hunt/cog.py @@ -0,0 +1,638 @@ +import asyncio +import contextlib +import logging +import random +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Channels, Client, Roles as MainRoles, bot +from bot.decorators import with_role +from .constants import Colours, EggHuntSettings, Emoji, Roles + +log = logging.getLogger(__name__) + +DB_PATH = Path("bot", "resources", "persist", "egg_hunt.sqlite") + +TEAM_MAP = { +    Roles.white: Emoji.egg_white, +    Roles.blurple: Emoji.egg_blurple, +    Emoji.egg_white: Roles.white, +    Emoji.egg_blurple: Roles.blurple +} + +GUILD = bot.get_guild(Client.guild) + +MUTED = GUILD.get_role(MainRoles.muted) + + +def get_team_role(user: discord.Member) -> discord.Role: +    """Helper function to get the team role for a member.""" + +    if Roles.white in user.roles: +        return Roles.white +    if Roles.blurple in user.roles: +        return Roles.blurple + + +async def assign_team(user: discord.Member) -> discord.Member: +    """Helper function to assign a new team role for a member.""" + +    db = sqlite3.connect(DB_PATH) +    c = db.cursor() +    c.execute(f"SELECT team FROM user_scores WHERE user_id = {user.id}") +    result = c.fetchone() +    if not result: +        c.execute( +            "SELECT team, COUNT(*) AS count FROM user_scores " +            "GROUP BY team ORDER BY count ASC LIMIT 1;" +        ) +        result = c.fetchone() +        result = result[0] if result else "WHITE" + +    if result[0] == "WHITE": +        new_team = Roles.white +    else: +        new_team = Roles.blurple + +    db.close() + +    log.debug(f"Assigned role {new_team} to {user}.") + +    await user.add_roles(new_team) +    return GUILD.get_member(user.id) + + +class EggMessage: +    """Handles a single egg reaction drop session.""" + +    def __init__(self, message: discord.Message, egg: discord.Emoji): +        self.message = message +        self.egg = egg +        self.first = None +        self.users = set() +        self.teams = {Roles.white: "WHITE", Roles.blurple: "BLURPLE"} +        self.new_team_assignments = {} +        self.timeout_task = None + +    @staticmethod +    def add_user_score_sql(user_id: int, team: str, score: int) -> str: +        """Builds the SQL for adding a score to a user in the database.""" + +        return ( +            "INSERT INTO user_scores(user_id, team, score)" +            f"VALUES({user_id}, '{team}', {score})" +            f"ON CONFLICT (user_id) DO UPDATE SET score=score+{score}" +        ) + +    @staticmethod +    def add_team_score_sql(team_name: str, score: int) -> str: +        """Builds the SQL for adding a score to a team in the database.""" + +        return f"UPDATE team_scores SET team_score=team_score+{score} WHERE team_id='{team_name}'" + +    def finalise_score(self): +        """Sums and actions scoring for this egg drop session.""" + +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() + +        team_scores = {"WHITE": 0, "BLURPLE": 0} + +        first_team = get_team_role(self.first) +        if not first_team: +            log.debug("User without team role!") +            db.close() +            return + +        score = 3 if first_team == TEAM_MAP[first_team] else 2 + +        c.execute(self.add_user_score_sql(self.first.id, self.teams[first_team], score)) +        team_scores[self.teams[first_team]] += score + +        for user in self.users: +            team = get_team_role(user) +            if not team: +                log.debug("User without team role!") +                continue + +            team_name = self.teams[team] +            team_scores[team_name] += 1 +            score = 2 if team == first_team else 1 +            c.execute(self.add_user_score_sql(user.id, team_name, score)) + +        for team_name, score in team_scores.items(): +            if not score: +                continue +            c.execute(self.add_team_score_sql(team_name, score)) + +        db.commit() +        db.close() + +        log.debug( +            f"EggHunt session finalising: ID({self.message.id}) " +            f"FIRST({self.first}) REST({self.users})." +        ) + +    async def start_timeout(self, seconds: int = 5): +        """Begins a task that will sleep until the given seconds before finalizing the session.""" + +        if self.timeout_task: +            self.timeout_task.cancel() +            self.timeout_task = None + +        await asyncio.sleep(seconds) + +        bot.remove_listener(self.collect_reacts, name="on_reaction_add") + +        with contextlib.suppress(discord.Forbidden): +            await self.message.clear_reactions() + +        if self.first: +            self.finalise_score() + +    def is_valid_react(self, reaction: discord.Reaction, user: discord.Member) -> bool: +        """Validates a reaction event was meant for this session.""" + +        if user.bot: +            return False +        if reaction.message.id != self.message.id: +            return False +        if reaction.emoji != self.egg: +            return False + +        # ignore the pushished +        if MUTED in user.roles: +            return False + +        return True + +    async def collect_reacts(self, reaction: discord.Reaction, user: discord.Member): +        """Handles emitted reaction_add events via listener.""" + +        if not self.is_valid_react(reaction, user): +            return + +        team = get_team_role(user) +        if not team: +            log.debug(f"Assigning a team for {user}.") +            user = await assign_team(user) + +        if not self.first: +            log.debug(f"{user} was first to react to egg on {self.message.id}.") +            self.first = user +            await self.start_timeout() +        else: +            if user != self.first: +                self.users.add(user) + +    async def start(self): +        """Starts the egg drop session.""" + +        log.debug(f"EggHunt session started for message {self.message.id}.") +        bot.add_listener(self.collect_reacts, name="on_reaction_add") +        with contextlib.suppress(discord.Forbidden): +            await self.message.add_reaction(self.egg) +        self.timeout_task = asyncio.create_task(self.start_timeout(300)) +        while True: +            if not self.timeout_task: +                break +            if not self.timeout_task.done(): +                await self.timeout_task +            else: +                # make sure any exceptions raise if necessary +                self.timeout_task.result() +                break + + +class SuperEggMessage(EggMessage): +    """Handles a super egg session.""" + +    def __init__(self, message: discord.Message, egg: discord.Emoji, window: int): +        super().__init__(message, egg) +        self.window = window + +    async def finalise_score(self): +        """Sums and actions scoring for this super egg session.""" +        try: +            message = await self.message.channel.fetch_message(self.message.id) +        except discord.NotFound: +            return + +        count = 0 +        white = 0 +        blurple = 0 +        react_users = [] +        for reaction in message.reactions: +            if reaction.emoji == self.egg: +                react_users = await reaction.users().flatten() +                for user in react_users: +                    team = get_team_role(user) +                    if team == Roles.white: +                        white += 1 +                    elif team == Roles.blurple: +                        blurple += 1 +                count = reaction.count - 1 +                break + +        score = 50 if self.egg == Emoji.egg_gold else 100 +        if white == blurple: +            log.debug("Tied SuperEgg Result.") +            team = None +            score /= 2 +        elif white > blurple: +            team = Roles.white +        else: +            team = Roles.blurple + +        embed = self.message.embeds[0] + +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() + +        user_bonus = 5 if self.egg == Emoji.egg_gold else 10 +        for user in react_users: +            if user.bot: +                continue +            role = get_team_role(user) +            if not role: +                print("issue") +            user_score = 1 if user != self.first else user_bonus +            c.execute(self.add_user_score_sql(user.id, self.teams[role], user_score)) + +        if not team: +            embed.description = f"{embed.description}\n\nA Tie!\nBoth got {score} points!" +            c.execute(self.add_team_score_sql(self.teams[Roles.white], score)) +            c.execute(self.add_team_score_sql(self.teams[Roles.blurple], score)) +            team_name = "TIE" +        else: +            team_name = self.teams[team] +            embed.description = ( +                f"{embed.description}\n\nTeam {team_name.capitalize()} won the points!" +            ) +            c.execute(self.add_team_score_sql(team_name, score)) + +        c.execute( +            "INSERT INTO super_eggs (message_id, egg_type, team, window) " +            f"VALUES ({self.message.id}, '{self.egg.name}', '{team_name}', {self.window});" +        ) + +        log.debug("Committing Super Egg scores.") +        db.commit() +        db.close() + +        embed.set_footer(text=f"Finished with {count} total reacts.") +        with contextlib.suppress(discord.HTTPException): +            await self.message.edit(embed=embed) + +    async def start_timeout(self, seconds=None): +        """Starts the super egg session.""" + +        if not seconds: +            return +        count = 4 +        for _ in range(count): +            await asyncio.sleep(60) +            embed = self.message.embeds[0] +            embed.set_footer(text=f"Finishing in {count} minutes.") +            try: +                await self.message.edit(embed=embed) +            except discord.HTTPException: +                break +            count -= 1 +        bot.remove_listener(self.collect_reacts, name="on_reaction_add") +        await self.finalise_score() + + +class EggHunt(commands.Cog): +    """Easter Egg Hunt Event.""" + +    def __init__(self): +        self.event_channel = GUILD.get_channel(Channels.seasonalbot_chat) +        self.super_egg_buffer = 60*60 +        self.tables = { +            "super_eggs": ( +                "CREATE TABLE super_eggs (" +                "message_id INTEGER NOT NULL " +                "  CONSTRAINT super_eggs_pk PRIMARY KEY, " +                "egg_type   TEXT    NOT NULL, " +                "team       TEXT    NOT NULL, " +                "window     INTEGER);" +            ), +            "team_scores": ( +                "CREATE TABLE team_scores (" +                "team_id TEXT, " +                "team_score INTEGER DEFAULT 0);" +            ), +            "user_scores": ( +                "CREATE TABLE user_scores(" +                "user_id INTEGER NOT NULL " +                "  CONSTRAINT user_scores_pk PRIMARY KEY, " +                "team TEXT NOT NULL, " +                "score INTEGER DEFAULT 0 NOT NULL);" +            ), +            "react_logs": ( +                "CREATE TABLE react_logs(" +                "member_id INTEGER NOT NULL, " +                "message_id INTEGER NOT NULL, " +                "reaction_id TEXT NOT NULL, " +                "react_timestamp REAL NOT NULL);" +            ) +        } +        self.prepare_db() +        self.task = asyncio.create_task(self.super_egg()) +        self.task.add_done_callback(self.task_cleanup) + +    def prepare_db(self): +        """Ensures database tables all exist and if not, creates them.""" + +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() + +        exists_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';" + +        missing_tables = [] +        for table in self.tables: +            c.execute(exists_sql.format(table_name=table)) +            result = c.fetchone() +            if not result: +                missing_tables.append(table) + +        for table in missing_tables: +            log.info(f"Table {table} is missing, building new one.") +            c.execute(self.tables[table]) + +        db.commit() +        db.close() + +    def task_cleanup(self, task): +        """Returns task result and restarts. Used as a done callback to show raised exceptions.""" + +        task.result() +        self.task = asyncio.create_task(self.super_egg()) + +    @staticmethod +    def current_timestamp() -> float: +        """Returns a timestamp of the current UTC time.""" + +        return datetime.utcnow().replace(tzinfo=timezone.utc).timestamp() + +    async def super_egg(self): +        """Manages the timing of super egg drops.""" + +        while True: +            now = int(self.current_timestamp()) + +            if now > EggHuntSettings.end_time: +                log.debug("Hunt ended. Ending task.") +                break + +            if now < EggHuntSettings.start_time: +                remaining = EggHuntSettings.start_time - now +                log.debug(f"Hunt not started yet. Sleeping for {remaining}.") +                await asyncio.sleep(remaining) + +            log.debug(f"Hunt started.") + +            db = sqlite3.connect(DB_PATH) +            c = db.cursor() + +            current_window = None +            next_window = None +            windows = EggHuntSettings.windows.copy() +            windows.insert(0, EggHuntSettings.start_time) +            for i, window in enumerate(windows): +                c.execute(f"SELECT COUNT(*) FROM super_eggs WHERE window={window}") +                already_dropped = c.fetchone()[0] + +                if already_dropped: +                    log.debug(f"Window {window} already dropped, checking next one.") +                    continue + +                if now < window: +                    log.debug("Drop windows up to date, sleeping until next one.") +                    await asyncio.sleep(window-now) +                    now = int(self.current_timestamp()) + +                current_window = window +                next_window = windows[i+1] +                break + +            count = c.fetchone() +            db.close() + +            if not current_window: +                log.debug("No drop windows left, ending task.") +                break + +            log.debug(f"Current Window: {current_window}. Next Window {next_window}") + +            if not count: +                if next_window < now: +                    log.debug("An Egg Drop Window was missed, dropping one now.") +                    next_drop = 0 +                else: +                    next_drop = random.randrange(now, next_window) + +                if next_drop: +                    log.debug(f"Sleeping until next super egg drop: {next_drop}.") +                    await asyncio.sleep(next_drop) + +                if random.randrange(10) <= 2: +                    egg = Emoji.egg_diamond +                    egg_type = "Diamond" +                    score = "100" +                    colour = Colours.diamond +                else: +                    egg = Emoji.egg_gold +                    egg_type = "Gold" +                    score = "50" +                    colour = Colours.gold + +                embed = discord.Embed( +                    title=f"A {egg_type} Egg Has Appeared!", +                    description=f"**Worth {score} team points!**\n\n" +                                "The team with the most reactions after 5 minutes wins!", +                    colour=colour +                ) +                embed.set_thumbnail(url=egg.url) +                embed.set_footer(text="Finishing in 5 minutes.") +                msg = await self.event_channel.send(embed=embed) +                await SuperEggMessage(msg, egg, current_window).start() + +            log.debug("Sleeping until next window.") +            next_loop = max(next_window - int(self.current_timestamp()), self.super_egg_buffer) +            await asyncio.sleep(next_loop) + +    @commands.Cog.listener() +    async def on_raw_reaction_add(self, payload): +        """Reaction event listener for reaction logging for later anti-cheat analysis.""" + +        if payload.channel_id not in EggHuntSettings.allowed_channels: +            return + +        now = self.current_timestamp() +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() +        c.execute( +            "INSERT INTO react_logs(member_id, message_id, reaction_id, react_timestamp) " +            f"VALUES({payload.user_id}, {payload.message_id}, '{payload.emoji}', {now})" +        ) +        db.commit() +        db.close() + +    @commands.Cog.listener() +    async def on_message(self, message): +        """Message event listener for random egg drops.""" + +        if self.current_timestamp() < EggHuntSettings.start_time: +            return + +        if message.channel.id not in EggHuntSettings.allowed_channels: +            log.debug("Message not in Egg Hunt channel; ignored.") +            return + +        if message.author.bot: +            return + +        if random.randrange(100) <= 5: +            await EggMessage(message, random.choice([Emoji.egg_white, Emoji.egg_blurple])).start() + +    @commands.group(invoke_without_command=True) +    async def hunt(self, ctx): +        """ +        For 48 hours, hunt down as many eggs randomly appearing as possible. + +        Standard Eggs +        -------------- +        Egg React: +1pt +        Team Bonus for Claimed Egg: +1pt +        First React on Other Team Egg: +1pt +        First React on Your Team Egg: +2pt + +        If you get first react, you will claim that egg for your team, allowing +        your team to get the Team Bonus point, but be quick, as the egg will +        disappear after 5 seconds of the first react. + +        Super Eggs +        ----------- +        Gold Egg: 50 team pts, 5pts to first react +        Diamond Egg: 100 team pts, 10pts to first react + +        Super Eggs only appear in #seasonalbot-chat so be sure to keep an eye +        out. They stay around for 5 minutes and the team with the most reacts +        wins the points. +        """ + +        await ctx.invoke(bot.get_command("help"), command="hunt") + +    @hunt.command() +    async def countdown(self, ctx): +        """Show the time status of the Egg Hunt event.""" + +        now = self.current_timestamp() +        if now > EggHuntSettings.end_time: +            return await ctx.send("The Hunt has ended.") + +        difference = EggHuntSettings.start_time - now +        if difference < 0: +            difference = EggHuntSettings.end_time - now +            msg = "The Egg Hunt will end in" +        else: +            msg = "The Egg Hunt will start in" + +        hours, r = divmod(difference, 3600) +        minutes, r = divmod(r, 60) +        await ctx.send(f"{msg} {hours:.0f}hrs, {minutes:.0f}mins & {r:.0f}secs") + +    @hunt.command() +    async def leaderboard(self, ctx): +        """Show the Egg Hunt Leaderboards.""" + +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() +        c.execute(f"SELECT *, RANK() OVER(ORDER BY score DESC) AS rank FROM user_scores LIMIT 10") +        user_result = c.fetchall() +        c.execute(f"SELECT * FROM team_scores ORDER BY team_score DESC") +        team_result = c.fetchall() +        db.close() +        output = [] +        if user_result: +            # Get the alignment needed for the score +            score_lengths = [] +            for result in user_result: +                length = len(str(result[2])) +                score_lengths.append(length) + +            score_length = max(score_lengths) +            for user_id, team, score, rank in user_result: +                user = GUILD.get_member(user_id) or user_id +                team = team.capitalize() +                score = f"{score}pts" +                output.append(f"{rank:>2}. {score:>{score_length+3}} - {user} ({team})") +            user_board = "\n".join(output) +        else: +            user_board = "No entries." +        if team_result: +            output = [] +            for team, score in team_result: +                output.append(f"{team:<7}: {score}") +            team_board = "\n".join(output) +        else: +            team_board = "No entries." +        embed = discord.Embed( +            title="Egg Hunt Leaderboards", +            description=f"**Team Scores**\n```\n{team_board}\n```\n" +                        f"**Top 10 Members**\n```\n{user_board}\n```" +        ) +        await ctx.send(embed=embed) + +    @hunt.command() +    async def rank(self, ctx, *, member: discord.Member = None): +        """Get your ranking in the Egg Hunt Leaderboard.""" + +        member = member or ctx.author +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() +        c.execute( +            "SELECT rank FROM " +            "(SELECT RANK() OVER(ORDER BY score DESC) AS rank, user_id FROM user_scores)" +            f"WHERE user_id = {member.id};" +        ) +        result = c.fetchone() +        db.close() +        if not result: +            embed = discord.Embed().set_author(name=f"Egg Hunt - No Ranking") +        else: +            embed = discord.Embed().set_author(name=f"Egg Hunt - Rank #{result[0]}") +        await ctx.send(embed=embed) + +    @with_role(MainRoles.admin) +    @hunt.command() +    async def clear_db(self, ctx): +        """Resets the database to it's initial state.""" + +        def check(msg): +            if msg.author != ctx.author: +                return False +            if msg.channel != ctx.channel: +                return False +            return True +        await ctx.send( +            "WARNING: This will delete all current event data.\n" +            "Please verify this action by replying with 'Yes, I want to delete all data.'" +        ) +        reply_msg = await bot.wait_for('message', check=check) +        if reply_msg.content != "Yes, I want to delete all data.": +            return await ctx.send("Reply did not match. Aborting database deletion.") +        db = sqlite3.connect(DB_PATH) +        c = db.cursor() +        c.execute("DELETE FROM super_eggs;") +        c.execute("DELETE FROM user_scores;") +        c.execute("UPDATE team_scores SET team_score=0") +        db.commit() +        db.close() +        await ctx.send("Database successfully cleared.") diff --git a/bot/seasons/easter/egg_hunt/constants.py b/bot/seasons/easter/egg_hunt/constants.py new file mode 100644 index 00000000..c7d9818b --- /dev/null +++ b/bot/seasons/easter/egg_hunt/constants.py @@ -0,0 +1,39 @@ +import os + +from discord import Colour + +from bot.constants import Channels, Client, bot + + +GUILD = bot.get_guild(Client.guild) + + +class EggHuntSettings: +    start_time = int(os.environ["HUNT_START"]) +    end_time = start_time + 172800  # 48 hrs later +    windows = [int(w) for w in os.environ.get("HUNT_WINDOWS").split(',')] or [] +    allowed_channels = [ +        Channels.seasonalbot_chat, +        Channels.off_topic_0, +        Channels.off_topic_1, +        Channels.off_topic_2, +    ] + + +class Roles: +    white = GUILD.get_role(569304397054607363) +    blurple = GUILD.get_role(569304472820514816) + + +class Emoji: +    egg_white = bot.get_emoji(569266762428841989) +    egg_blurple = bot.get_emoji(569266666094067819) +    egg_gold = bot.get_emoji(569266900106739712) +    egg_diamond = bot.get_emoji(569266839738384384) + + +class Colours: +    white = Colour(0xFFFFFF) +    blurple = Colour(0x7289DA) +    gold = Colour(0xE4E415) +    diamond = Colour(0xECF5FF) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index 3ffdf1bf..b5fb2881 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -458,7 +458,7 @@ class Snakes(Cog):      async def snakes_group(self, ctx: Context):          """Commands from our first code jam.""" -        await ctx.invoke(self.bot.get_command("help"), "snake") +        await ctx.send_help(ctx.command)      @bot_has_permissions(manage_messages=True)      @snakes_group.command(name='antidote') @@ -1055,13 +1055,6 @@ class Snakes(Cog):          )          await ctx.channel.send(embed=embed) -    @snakes_group.command(name='help') -    async def help_command(self, ctx: Context): -        """Invokes the help command for the Snakes Cog.""" - -        log.debug(f"{ctx.author} requested info about the snakes cog") -        return await ctx.invoke(self.bot.get_command("help"), "Snakes") -      @snakes_group.command(name='snakify')      async def snakify_command(self, ctx: Context, *, message: str = None):          """ diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py index e2ed60bd..a7cb70a7 100644 --- a/bot/seasons/evergreen/snakes/utils.py +++ b/bot/seasons/evergreen/snakes/utils.py @@ -8,7 +8,6 @@ from itertools import product  from pathlib import Path  from typing import List, Tuple -import aiohttp  from PIL import Image  from PIL.ImageDraw import ImageDraw  from discord import File, Member, Reaction @@ -480,12 +479,10 @@ class SnakeAndLaddersGame:      async def _add_player(self, user: Member):          self.players.append(user)          self.player_tiles[user.id] = 1 -        avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE) -        async with aiohttp.ClientSession() as session: -            async with session.get(avatar_url) as res: -                avatar_bytes = await res.read() -                im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) -                self.avatar_images[user.id] = im + +        avatar_bytes = await user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE).read() +        im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) +        self.avatar_images[user.id] = im      async def player_join(self, user: Member):          """ diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 70648e64..f8ab4c60 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -7,7 +7,7 @@ import random  import discord  from discord.ext import commands -from bot.constants import Hacktoberfest +from bot.constants import Channels  log = logging.getLogger(__name__) @@ -41,7 +41,7 @@ class CandyCollection(commands.Cog):          if message.author.bot:              return          # ensure it's hacktober channel -        if message.channel.id != Hacktoberfest.channel_id: +        if message.channel.id != Channels.seasonalbot_chat:              return          # do random check for skull first as it has the lower chance @@ -65,7 +65,7 @@ class CandyCollection(commands.Cog):              return          # check to ensure it is in correct channel -        if message.channel.id != Hacktoberfest.channel_id: +        if message.channel.id != Channels.seasonalbot_chat:              return          # if its not a candy or skull, and it is one of 10 most recent messages, @@ -127,7 +127,7 @@ class CandyCollection(commands.Cog):          ten_recent = []          recent_msg = max(message.id for message                           in self.bot._connection._messages -                         if message.channel.id == Hacktoberfest.channel_id) +                         if message.channel.id == Channels.seasonalbot_chat)          channel = await self.hacktober_channel()          ten_recent.append(recent_msg.id) @@ -159,7 +159,7 @@ class CandyCollection(commands.Cog):      async def hacktober_channel(self):          """Get #hacktoberbot channel from its ID.""" -        return self.bot.get_channel(id=Hacktoberfest.channel_id) +        return self.bot.get_channel(id=Channels.seasonalbot_chat)      async def remove_reactions(self, reaction):          """Remove all candy/skull reactions.""" diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index ee90dbd3..ad9aa716 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -7,7 +7,7 @@ from pathlib import Path  import discord  from discord.ext import commands -from bot.constants import Hacktoberfest +from bot.constants import Channels  log = logging.getLogger(__name__) @@ -40,7 +40,7 @@ class HalloweenFacts(commands.Cog):      async def on_ready(self):          """Get event Channel object and initialize fact task loop.""" -        self.channel = self.bot.get_channel(Hacktoberfest.channel_id) +        self.channel = self.bot.get_channel(Channels.seasonalbot_chat)          self.bot.loop.create_task(self._fact_publisher_task())      def random_fact(self): diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index 15c7c431..2cc81da8 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -37,8 +37,9 @@ class SpookyAvatar(commands.Cog):              embed = discord.Embed(colour=0xFF0000)              embed.title = "Is this you or am I just really paranoid?"              embed.set_author(name=str(user.name), icon_url=user.avatar_url) -            resp = await self.get(user.avatar_url) -            im = Image.open(BytesIO(resp)) + +            image_bytes = await ctx.author.avatar_url.read() +            im = Image.open(BytesIO(image_bytes))              modified_im = spookifications.get_random_effect(im)              modified_im.save(str(ctx.message.id)+'.png')              f = discord.File(str(ctx.message.id)+'.png') diff --git a/bot/seasons/season.py b/bot/seasons/season.py index 6d992276..6d99b77f 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -442,7 +442,7 @@ class SeasonManager(commands.Cog):      async def refresh(self, ctx):          """Refreshes certain seasonal elements without reloading seasons."""          if not ctx.invoked_subcommand: -            await ctx.invoke(bot.get_command("help"), "refresh") +            await ctx.send_help(ctx.command)      @refresh.command(name="avatar")      async def refresh_avatar(self, ctx): diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index 55c4adb1..8340d7fa 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -8,7 +8,7 @@ import discord  from discord.ext import commands  from discord.ext.commands.cooldowns import BucketType -from bot.constants import Client, Colours, Lovefest +from bot.constants import Channels, Client, Colours, Lovefest  log = logging.getLogger(__name__) @@ -42,7 +42,7 @@ class BeMyValentine(commands.Cog):          2) use the command \".lovefest unsub\" to get rid of the lovefest role.          """ -        await ctx.invoke(self.bot.get_command("help"), "lovefest") +        await ctx.send_help(ctx.command)      @lovefest_role.command(name="sub")      async def add_role(self, ctx): @@ -99,7 +99,7 @@ class BeMyValentine(commands.Cog):          emoji_1, emoji_2 = self.random_emoji()          lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) -        channel = self.bot.get_channel(Lovefest.channel_id) +        channel = self.bot.get_channel(Channels.seasonalbot_chat)          valentine, title = self.valentine_check(valentine_type)          if user is None: | 
