aboutsummaryrefslogtreecommitdiffstats
path: root/bot/seasons/easter
diff options
context:
space:
mode:
Diffstat (limited to 'bot/seasons/easter')
-rw-r--r--bot/seasons/easter/__init__.py4
-rw-r--r--bot/seasons/easter/april_fools_vids.py39
-rw-r--r--bot/seasons/easter/avatar_easterifier.py129
-rw-r--r--bot/seasons/easter/conversationstarters.py4
-rw-r--r--bot/seasons/easter/egg_decorating.py13
-rw-r--r--bot/seasons/easter/egg_hunt/__init__.py11
-rw-r--r--bot/seasons/easter/egg_hunt/cog.py617
-rw-r--r--bot/seasons/easter/egg_hunt/constants.py39
-rw-r--r--bot/seasons/easter/egghead_quiz.py8
-rw-r--r--bot/seasons/easter/traditions.py4
10 files changed, 849 insertions, 19 deletions
diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py
index 83d12ead..1d77b6a6 100644
--- a/bot/seasons/easter/__init__.py
+++ b/bot/seasons/easter/__init__.py
@@ -30,4 +30,6 @@ class Easter(SeasonBase):
end_date = "30/04"
colour = Colours.pink
- icon = "/logos/logo_seasonal/easter/easter.png"
+ icon = (
+ "/logos/logo_seasonal/easter/easter.png",
+ )
diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py
new file mode 100644
index 00000000..9fbe87a0
--- /dev/null
+++ b/bot/seasons/easter/april_fools_vids.py
@@ -0,0 +1,39 @@
+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/easterapril_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):
+ """Get 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):
+ """April Fools' Cog load."""
+ 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..ad8b5473
--- /dev/null
+++ b/bot/seasons/easter/avatar_easterifier.py
@@ -0,0 +1,129 @@
+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):
+ """Avatar Easterifier Cog load."""
+ bot.add_cog(AvatarEasterifier(bot))
+ log.info("AvatarEasterifier cog loaded")
diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py
index b479406b..c2cdf26c 100644
--- a/bot/seasons/easter/conversationstarters.py
+++ b/bot/seasons/easter/conversationstarters.py
@@ -7,7 +7,7 @@ from discord.ext import commands
log = logging.getLogger(__name__)
-with open(Path('bot', 'resources', 'easter', 'starter.json'), 'r', encoding="utf8") as f:
+with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f:
starters = json.load(f)
@@ -20,12 +20,10 @@ class ConvoStarters(commands.Cog):
@commands.command()
async def topic(self, ctx):
"""Responds with a random topic to start a conversation."""
-
await ctx.send(random.choice(starters['starters']))
def setup(bot):
"""Conversation starters Cog load."""
-
bot.add_cog(ConvoStarters(bot))
log.info("ConvoStarters cog loaded")
diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py
index b5f3e428..ee8a80e5 100644
--- a/bot/seasons/easter/egg_decorating.py
+++ b/bot/seasons/easter/egg_decorating.py
@@ -12,10 +12,10 @@ from discord.ext import commands
log = logging.getLogger(__name__)
-with open(Path("bot", "resources", "evergreen", "html_colours.json")) as f:
+with open(Path("bot/resources/evergreen/html_colours.json")) as f:
HTML_COLOURS = json.load(f)
-with open(Path("bot", "resources", "evergreen", "xkcd_colours.json")) as f:
+with open(Path("bot/resources/evergreen/xkcd_colours.json")) as f:
XKCD_COLOURS = json.load(f)
COLOURS = [
@@ -51,7 +51,6 @@ class EggDecorating(commands.Cog):
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.
"""
-
if len(colours) < 2:
return await ctx.send("You must include at least 2 colours!")
@@ -72,13 +71,13 @@ class EggDecorating(commands.Cog):
return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!")
async with ctx.typing():
- # expand list to 8 colours
+ # Expand list to 8 colours
colours_n = len(colours)
if colours_n < 8:
q, r = divmod(8, colours_n)
colours = colours * q + colours[:r]
num = random.randint(1, 6)
- im = Image.open(Path("bot", "resources", "easter", "easter_eggs", f"design{num}.png"))
+ im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png"))
data = list(im.getdata())
replaceable = {x for x in data if x not in IRREPLACEABLE}
@@ -109,10 +108,10 @@ 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):
- """Cog load."""
-
+ """Egg decorating Cog load."""
bot.add_cog(EggDecorating(bot))
log.info("EggDecorating 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..0e4b9e16
--- /dev/null
+++ b/bot/seasons/easter/egg_hunt/__init__.py
@@ -0,0 +1,11 @@
+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..30fd3284
--- /dev/null
+++ b/bot/seasons/easter/egg_hunt/cog.py
@@ -0,0 +1,617 @@
+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 punished
+ 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/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py
index 8dd2c21d..3e0cc598 100644
--- a/bot/seasons/easter/egghead_quiz.py
+++ b/bot/seasons/easter/egghead_quiz.py
@@ -11,7 +11,7 @@ from bot.constants import Colours
log = logging.getLogger(__name__)
-with open(Path('bot', 'resources', 'easter', 'egghead_questions.json'), 'r', encoding="utf8") as f:
+with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f:
EGGHEAD_QUESTIONS = load(f)
@@ -41,7 +41,6 @@ class EggheadQuiz(commands.Cog):
Also informs of the percentages and votes of each option
"""
-
random_question = random.choice(EGGHEAD_QUESTIONS)
question, answers = random_question["question"], random_question["answers"]
answers = [(EMOJIS[i], a) for i, a in enumerate(answers)]
@@ -69,7 +68,7 @@ class EggheadQuiz(commands.Cog):
total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions
if total_no == 0:
- return await msg.delete() # to avoid ZeroDivisionError if nobody reacts
+ return await msg.delete() # To avoid ZeroDivisionError if nobody reacts
results = ["**VOTES:**"]
for emoji, _ in answers:
@@ -115,7 +114,6 @@ class EggheadQuiz(commands.Cog):
def setup(bot):
- """Cog load."""
-
+ """Egghead Quiz Cog load."""
bot.add_cog(EggheadQuiz(bot))
log.info("EggheadQuiz bot loaded")
diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py
index 05cd79f3..f04b8828 100644
--- a/bot/seasons/easter/traditions.py
+++ b/bot/seasons/easter/traditions.py
@@ -7,7 +7,7 @@ from discord.ext import commands
log = logging.getLogger(__name__)
-with open(Path('bot', 'resources', 'easter', 'traditions.json'), 'r', encoding="utf8") as f:
+with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f:
traditions = json.load(f)
@@ -20,7 +20,6 @@ class Traditions(commands.Cog):
@commands.command(aliases=('eastercustoms',))
async def easter_tradition(self, ctx):
"""Responds with a random tradition or custom"""
-
random_country = random.choice(list(traditions))
await ctx.send(f"{random_country}:\n{traditions[random_country]}")
@@ -28,6 +27,5 @@ class Traditions(commands.Cog):
def setup(bot):
"""Traditions Cog load."""
-
bot.add_cog(Traditions(bot))
log.info("Traditions cog loaded")