diff options
| -rw-r--r-- | bot/__init__.py | 1 | ||||
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | bot/resources/easter/april_fools_vids.json | 125 | ||||
| -rw-r--r-- | bot/resources/persist/egg_hunt.sqlite | bin | 0 -> 16384 bytes | |||
| -rw-r--r-- | bot/seasons/easter/april_fools_vids.py | 38 | ||||
| -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 | 
8 files changed, 858 insertions, 2 deletions
diff --git a/bot/__init__.py b/bot/__init__.py index 21ff8c97..7c564178 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -54,6 +54,7 @@ if root.handlers:  # Silence irrelevant loggers  logging.getLogger("discord").setLevel(logging.ERROR)  logging.getLogger("websockets").setLevel(logging.ERROR) +logging.getLogger("PIL").setLevel(logging.ERROR)  # Setup new logging configuration  logging.basicConfig( diff --git a/bot/constants.py b/bot/constants.py index b19d494b..d362c90e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -29,7 +29,7 @@ class Channels(NamedTuple):      bot = 267659945086812160      checkpoint_test = 422077681434099723      devalerts = 460181980097675264 -    devlog = int(environ.get('CHANNEL_DEVLOG', 409308876241108992)) +    devlog = int(environ.get('CHANNEL_DEVLOG', 548438471685963776))      devtest = 414574275865870337      help_0 = 303906576991780866      help_1 = 303906556754395136 @@ -46,19 +46,22 @@ class Channels(NamedTuple):      off_topic_2 = 463035268514185226      python = 267624335836053506      reddit = 458224812528238616 +    seasonalbot_chat = int(environ.get('CHANNEL_SEASONALBOT_CHAT', 542272993192050698))      staff_lounge = 464905259261755392      verification = 352442727016693763 +    python_discussion = 267624335836053506  class Client(NamedTuple):      guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506)) -    prefix = "." +    prefix = environ.get("PREFIX", ".")      token = environ.get('SEASONALBOT_TOKEN')      debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true'      season_override = environ.get('SEASON_OVERRIDE')  class Colours: +    yellow = 0xf9f586      soft_red = 0xcd6d6d      soft_green = 0x68c290      bright_green = 0x01d277 diff --git a/bot/resources/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json new file mode 100644 index 00000000..dfc01b7b --- /dev/null +++ b/bot/resources/easter/april_fools_vids.json @@ -0,0 +1,125 @@ +{ +  "google": [ +    { +      "title": "Introducing Bad Joke Detector", +      "link": "https://youtu.be/OYcv406J_J4" +    }, +    { +      "title": "Introducing Google Cloud Hummus API - Find your Hummus!", +      "link": "https://youtu.be/0_5X6N6DHyk" +    }, +    { +      "title": "Introducing Google Play for Pets", +      "link": "https://youtu.be/UmJ2NBHXTqo" +    }, +    { +      "title": "Haptic Helpers: bringing you to your senses", +      "link": "https://youtu.be/3MA6_21nka8" +    }, +    { +      "title": "Introducing Google Gnome", +      "link": "https://youtu.be/vNOllWX-2aE" +    }, +    { +      "title": "Introducing Google Wind", +      "link": "https://youtu.be/QAwL0O5nXe0" +    }, +    { +      "title": "Experience YouTube in #SnoopaVision", +      "link": "https://youtu.be/DPEJB-FCItk" +    }, +    { +      "title": "Introducing the self-driving bicycle in the Netherlands", +      "link": "https://youtu.be/LSZPNwZex9s" +    }, +    { +      "title": "Android Developer Story: The Guardian goes galactic with Android and Google Play", +      "link": "https://youtu.be/dFrgNiweQDk" +    }, +    { +      "title": "Introducing new delivery technology from Google Express", +      "link": "https://youtu.be/F0F6SnbqUcE" +    }, +    { +      "title": "Google Cardboard Plastic", +      "link": "https://youtu.be/VkOuShXpoKc" +    }, +    { +      "title": "Google Photos: Search your photos by emoji", +      "link": "https://youtu.be/HQtGFBbwKEk" +    }, +    { +      "title": "Introducing Google Actual Cloud Platform", +      "link": "https://youtu.be/Cp10_PygJ4o" +    }, +    { +      "title": "Introducing Dial-Up mode", +      "link": "https://youtu.be/XTTtkisylQw" +    }, +    { +      "title": "Smartbox by Inbox: the mailbox of tomorrow, today", +      "link": "https://youtu.be/hydLZJXG3Tk" +    }, +    { +      "title": "Introducing Coffee to the Home", +      "link": "https://youtu.be/U2JBFlW--UU" +    }, +    { +      "title": "Chrome for Android and iOS: Emojify the Web", +      "link": "https://youtu.be/G3NXNnoGr3Y" +    }, +    { +      "title": "Google Maps: Pokémon Challenge", +      "link": "https://youtu.be/4YMD6xELI_k" +    }, +    { +      "title": "Introducing Google Fiber to the Pole", +      "link": "https://youtu.be/qcgWRpQP6ds" +    }, +    { +      "title": "Introducing Gmail Blue", +      "link": "https://youtu.be/Zr4JwPb99qU" +    }, +    { +      "title": "Introducing Google Nose", +      "link": "https://youtu.be/VFbYadm_mrw" +    }, +    { +      "title": "Explore Treasure Mode with Google Maps", +      "link": "https://youtu.be/_qFFHC0eIUc" +    }, +    { +      "title": "YouTube's ready to select a winner", +      "link": "https://youtu.be/H542nLTTbu0" +    }, +    { +      "title": "A word about Gmail Tap", +      "link": "https://youtu.be/Je7Xq9tdCJc" +    }, +    { +      "title": "Introducing the Google Fiber Bar", +      "link": "https://youtu.be/re0VRK6ouwI" +    }, +    { +      "title": "Introducing Gmail Tap", +      "link": "https://youtu.be/1KhZKNZO8mQ" +    }, +    { +      "title": "Chrome Multitask Mode", +      "link": "https://youtu.be/UiLSiqyDf4Y" +    }, +    { +      "title": "Google Maps 8-bit for NES", +      "link": "https://youtu.be/rznYifPHxDg" +    }, +    { +      "title": "Being a Google Autocompleter", +      "link": "https://youtu.be/blB_X38YSxQ" +    }, +    { +      "title": "Introducing Gmail Motion", +      "link": "https://youtu.be/Bu927_ul_X0" +    } +  ] + +}
\ No newline at end of file diff --git a/bot/resources/persist/egg_hunt.sqlite b/bot/resources/persist/egg_hunt.sqlite Binary files differnew file mode 100644 index 00000000..6a7ae32d --- /dev/null +++ b/bot/resources/persist/egg_hunt.sqlite 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/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)  |