diff options
Diffstat (limited to 'bot')
-rw-r--r-- | bot/__init__.py | 1 | ||||
-rw-r--r-- | bot/constants.py | 15 | ||||
-rw-r--r-- | bot/resources/easter/april_fools_vids.json | 125 | ||||
-rw-r--r-- | bot/resources/easter/chocolate_bunny.png | bin | 0 -> 7789 bytes | |||
-rw-r--r-- | bot/resources/persist/egg_hunt.sqlite | bin | 0 -> 16384 bytes | |||
-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 |
20 files changed, 1128 insertions, 38 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..bf542daf 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -18,7 +18,6 @@ class AdventOfCode: leaderboard_join_code = str(environ.get("AOC_JOIN_CODE", None)) leaderboard_max_displayed_members = 10 year = 2018 - channel_id = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) @@ -29,7 +28,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,25 +45,29 @@ 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: - soft_red = 0xcd6d6d - soft_green = 0x68c290 + blue = 0x0279fd bright_green = 0x01d277 dark_green = 0x1f8b4c orange = 0xe67e22 pink = 0xcf84e0 + soft_green = 0x68c290 + soft_red = 0xcd6d6d + yellow = 0xf9f586 class Emojis: @@ -81,12 +84,10 @@ class Emojis: class Lovefest: - channel_id = int(environ.get("LOVEFEST_CHANNEL_ID", 542272993192050698)) role_id = int(environ.get("LOVEFEST_ROLE_ID", 542431903886606399)) class Hacktoberfest(NamedTuple): - channel_id = 498804484324196362 voice_id = 514420006474219521 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/easter/chocolate_bunny.png b/bot/resources/easter/chocolate_bunny.png Binary files differnew file mode 100644 index 00000000..6b25aa5a --- /dev/null +++ b/bot/resources/easter/chocolate_bunny.png 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/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: |