aboutsummaryrefslogtreecommitdiffstats
path: root/bot/seasons
diff options
context:
space:
mode:
Diffstat (limited to 'bot/seasons')
-rw-r--r--bot/seasons/__init__.py12
-rw-r--r--bot/seasons/christmas/__init__.py16
-rw-r--r--bot/seasons/evergreen/__init__.py13
-rw-r--r--bot/seasons/evergreen/error_handler.py109
-rw-r--r--bot/seasons/evergreen/uptime.py38
-rw-r--r--bot/seasons/halloween/__init__.py16
-rw-r--r--bot/seasons/halloween/candy_collection.py234
-rw-r--r--bot/seasons/halloween/hacktoberstats.py335
-rw-r--r--bot/seasons/halloween/halloween_facts.py66
-rw-r--r--bot/seasons/halloween/halloweenify.py55
-rw-r--r--bot/seasons/halloween/monstersurvey.py218
-rw-r--r--bot/seasons/halloween/scarymovie.py141
-rw-r--r--bot/seasons/halloween/spookyavatar.py58
-rw-r--r--bot/seasons/halloween/spookygif.py43
-rw-r--r--bot/seasons/halloween/spookyreact.py72
-rw-r--r--bot/seasons/halloween/spookysound.py50
-rw-r--r--bot/seasons/season.py180
17 files changed, 1656 insertions, 0 deletions
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py
new file mode 100644
index 00000000..c43334a4
--- /dev/null
+++ b/bot/seasons/__init__.py
@@ -0,0 +1,12 @@
+import logging
+
+from bot.seasons.season import SeasonBase, SeasonManager, get_season
+
+__all__ = ("SeasonBase", "get_season")
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot):
+ bot.add_cog(SeasonManager(bot))
+ log.debug("SeasonManager cog loaded")
diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py
new file mode 100644
index 00000000..cd5ce307
--- /dev/null
+++ b/bot/seasons/christmas/__init__.py
@@ -0,0 +1,16 @@
+from bot.seasons import SeasonBase
+
+
+class Christmas(SeasonBase):
+ name = "christmas"
+ start_date = "01/12"
+ end_date = "31/12"
+ bot_name = "Santabot"
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @property
+ def bot_avatar(self):
+ with open(self.avatar_path("christmas.png"), "rb") as avatar:
+ return bytearray(avatar.read())
diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py
new file mode 100644
index 00000000..e4367aaa
--- /dev/null
+++ b/bot/seasons/evergreen/__init__.py
@@ -0,0 +1,13 @@
+from bot.seasons import SeasonBase
+
+
+class Evergreen(SeasonBase):
+ bot_name = "SeasonalBot"
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @property
+ def bot_avatar(self):
+ with open(self.avatar_path("standard.png"), "rb") as avatar:
+ return bytearray(avatar.read())
diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py
new file mode 100644
index 00000000..6de35e60
--- /dev/null
+++ b/bot/seasons/evergreen/error_handler.py
@@ -0,0 +1,109 @@
+import logging
+import math
+import sys
+import traceback
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class CommandErrorHandler:
+ """A error handler for the PythonDiscord server!"""
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def on_command_error(self, ctx, error):
+ """Activates when a command opens an error"""
+
+ if hasattr(ctx.command, 'on_error'):
+ return logging.debug(
+ "A command error occured but "
+ "the command had it's own error handler"
+ )
+ error = getattr(error, 'original', error)
+ if isinstance(error, commands.CommandNotFound):
+ return logging.debug(
+ f"{ctx.author} called '{ctx.message.content}' "
+ "but no command was found"
+ )
+ if isinstance(error, commands.UserInputError):
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but entered invalid input!"
+ )
+ return await ctx.send(
+ ":no_entry: The command you specified failed to run."
+ "This is because the arguments you provided were invalid."
+ )
+ if isinstance(error, commands.CommandOnCooldown):
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but they were on cooldown!"
+ )
+ return await ctx.send(
+ "This command is on cooldown,"
+ f" please retry in {math.ceil(error.retry_after)}s."
+ )
+ if isinstance(error, commands.DisabledCommand):
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but the command was disabled!"
+ )
+ return await ctx.send(
+ ":no_entry: This command has been disabled."
+ )
+ if isinstance(error, commands.NoPrivateMessage):
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "in a private message however the command was guild only!"
+ )
+ return await ctx.author.send(
+ ":no_entry: This command can only be used inside a server."
+ )
+ if isinstance(error, commands.BadArgument):
+ if ctx.command.qualified_name == 'tag list':
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but entered an invalid user!"
+ )
+ return await ctx.send(
+ "I could not find that member. Please try again."
+ )
+ else:
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but entered a bad argument!"
+ )
+ return await ctx.send(
+ "The argument you provided was invalid."
+ )
+ if isinstance(error, commands.CheckFailure):
+ logging.debug(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "but the checks failed!"
+ )
+ return await ctx.send(
+ ":no_entry: You are not authorized to use this command."
+ )
+ print(
+ f"Ignoring exception in command {ctx.command}:",
+ file=sys.stderr
+ )
+ logging.warning(
+ f"{ctx.author} called the command '{ctx.command}' "
+ "however the command failed to run with the error:"
+ f"-------------\n{error}"
+ )
+ traceback.print_exception(
+ type(error),
+ error,
+ error.__traceback__,
+ file=sys.stderr
+ )
+
+
+def setup(bot):
+ bot.add_cog(CommandErrorHandler(bot))
+ log.debug("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py
new file mode 100644
index 00000000..1321da19
--- /dev/null
+++ b/bot/seasons/evergreen/uptime.py
@@ -0,0 +1,38 @@
+import logging
+
+import arrow
+from dateutil.relativedelta import relativedelta
+from discord.ext import commands
+
+from bot import start_time
+
+log = logging.getLogger(__name__)
+
+
+class Uptime:
+ """
+ A cog for posting the bots uptime.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command(name="uptime")
+ async def uptime(self, ctx):
+ """
+ Returns the uptime of the bot.
+ """
+ difference = relativedelta(start_time - arrow.utcnow())
+ uptime_string = start_time.shift(
+ seconds=-difference.seconds,
+ minutes=-difference.minutes,
+ hours=-difference.hours,
+ days=-difference.days
+ ).humanize()
+ await ctx.send(f"I started up {uptime_string}.")
+
+
+# Required in order to load the cog, use the class name in the add_cog function.
+def setup(bot):
+ bot.add_cog(Uptime(bot))
+ log.debug("Uptime cog loaded")
diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py
new file mode 100644
index 00000000..40b9ce90
--- /dev/null
+++ b/bot/seasons/halloween/__init__.py
@@ -0,0 +1,16 @@
+from bot.seasons import SeasonBase
+
+
+class Halloween(SeasonBase):
+ name = "halloween"
+ start_date = "01/10"
+ end_date = "31/10"
+ bot_name = "Spookybot"
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @property
+ def bot_avatar(self):
+ with open(self.avatar_path("spooky.png"), "rb") as avatar:
+ return bytearray(avatar.read())
diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py
new file mode 100644
index 00000000..80f30a1b
--- /dev/null
+++ b/bot/seasons/halloween/candy_collection.py
@@ -0,0 +1,234 @@
+import functools
+import json
+import logging
+import os
+import random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Hacktoberfest
+
+log = logging.getLogger(__name__)
+
+json_location = os.path.join("bot", "resources", "halloween", "candy_collection.json")
+
+# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy)
+ADD_CANDY_REACTION_CHANCE = 20 # 5%
+ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10%
+ADD_SKULL_REACTION_CHANCE = 50 # 2%
+ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5%
+
+
+class CandyCollection:
+ def __init__(self, bot):
+ self.bot = bot
+ with open(json_location) as candy:
+ self.candy_json = json.load(candy)
+ self.msg_reacted = self.candy_json['msg_reacted']
+ self.get_candyinfo = dict()
+ for userinfo in self.candy_json['records']:
+ userid = userinfo['userid']
+ self.get_candyinfo[userid] = userinfo
+
+ async def on_message(self, message):
+ """
+ Randomly adds candy or skull to certain messages
+ """
+
+ # make sure its a human message
+ if message.author.bot:
+ return
+ # ensure it's hacktober channel
+ if message.channel.id != Hacktoberfest.channel_id:
+ return
+
+ # do random check for skull first as it has the lower chance
+ if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{SKULL}')
+ # check for the candy chance next
+ if random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{CANDY}')
+
+ async def on_reaction_add(self, reaction, user):
+ """
+ Add/remove candies from a person if the reaction satisfies criteria
+ """
+
+ message = reaction.message
+ # check to ensure the reactor is human
+ if user.bot:
+ return
+
+ # check to ensure it is in correct channel
+ if message.channel.id != Hacktoberfest.channel_id:
+ return
+
+ # if its not a candy or skull, and it is one of 10 most recent messages,
+ # proceed to add a skull/candy with higher chance
+ if str(reaction.emoji) not in ('\N{SKULL}', '\N{CANDY}'):
+ if message.id in await self.ten_recent_msg():
+ await self.reacted_msg_chance(message)
+ return
+
+ for react in self.msg_reacted:
+ # check to see if the message id of a message we added a
+ # reaction to is in json file, and if nobody has won/claimed it yet
+ if react['msg_id'] == message.id and react['won'] is False:
+ react['user_reacted'] = user.id
+ react['won'] = True
+ try:
+ # if they have record/candies in json already it will do this
+ user_records = self.get_candyinfo[user.id]
+ if str(reaction.emoji) == '\N{CANDY}':
+ user_records['record'] += 1
+ if str(reaction.emoji) == '\N{SKULL}':
+ if user_records['record'] <= 3:
+ user_records['record'] = 0
+ lost = 'all of your'
+ else:
+ lost = random.randint(1, 3)
+ user_records['record'] -= lost
+ await self.send_spook_msg(message.author, message.channel, lost)
+
+ except KeyError:
+ # otherwise it will raise KeyError so we need to add them to file
+ if str(reaction.emoji) == '\N{CANDY}':
+ print('ok')
+ d = {"userid": user.id, "record": 1}
+ self.candy_json['records'].append(d)
+ await self.remove_reactions(reaction)
+
+ async def reacted_msg_chance(self, message):
+ """
+ Randomly add a skull or candy to a message if there is a reaction there already
+ (higher probability)
+ """
+
+ if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{SKULL}')
+
+ if random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1:
+ d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False}
+ self.msg_reacted.append(d)
+ return await message.add_reaction('\N{CANDY}')
+
+ async def ten_recent_msg(self):
+ """Get the last 10 messages sent in the channel"""
+ ten_recent = []
+ recent_msg = max(message.id for message
+ in self.bot._connection._messages
+ if message.channel.id == Hacktoberfest.channel_id)
+
+ channel = await self.hacktober_channel()
+ ten_recent.append(recent_msg.id)
+
+ for i in range(9):
+ o = discord.Object(id=recent_msg.id + i)
+ msg = await next(channel.history(limit=1, before=o))
+ ten_recent.append(msg.id)
+
+ return ten_recent
+
+ async def get_message(self, msg_id):
+ """
+ Get the message from it's ID.
+ """
+
+ try:
+ o = discord.Object(id=msg_id + 1)
+ # Use history rather than get_message due to
+ # poor ratelimit (50/1s vs 1/1s)
+ msg = await next(self.hacktober_channel.history(limit=1, before=o))
+
+ if msg.id != msg_id:
+ return None
+
+ return msg
+
+ except Exception:
+ return None
+
+ async def hacktober_channel(self):
+ """
+ Get #hacktoberbot channel from it's id
+ """
+ return self.bot.get_channel(id=Hacktoberfest.channel_id)
+
+ async def remove_reactions(self, reaction):
+ """
+ Remove all candy/skull reactions
+ """
+
+ try:
+ async for user in reaction.users():
+ await reaction.message.remove_reaction(reaction.emoji, user)
+
+ except discord.HTTPException:
+ pass
+
+ async def send_spook_msg(self, author, channel, candies):
+ """
+ Send a spooky message
+ """
+ e = discord.Embed(colour=author.colour)
+ e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; "
+ f"I took {candies} candies and quickly took flight.")
+ await channel.send(embed=e)
+
+ def save_to_json(self):
+ """
+ Save json to the file.
+ """
+ with open(json_location, 'w') as outfile:
+ json.dump(self.candy_json, outfile)
+
+ @commands.command()
+ async def candy(self, ctx):
+ """
+ Get the candy leaderboard and save to json when this is called
+ """
+
+ # use run_in_executor to prevent blocking
+ thing = functools.partial(self.save_to_json)
+ await self.bot.loop.run_in_executor(None, thing)
+
+ emoji = (
+ '\N{FIRST PLACE MEDAL}',
+ '\N{SECOND PLACE MEDAL}',
+ '\N{THIRD PLACE MEDAL}',
+ '\N{SPORTS MEDAL}',
+ '\N{SPORTS MEDAL}'
+ )
+
+ top_sorted = sorted(self.candy_json['records'], key=lambda k: k.get('record', 0), reverse=True)
+ top_five = top_sorted[:5]
+
+ usersid = []
+ records = []
+ for record in top_five:
+ usersid.append(record['userid'])
+ records.append(record['record'])
+
+ value = '\n'.join(f'{emoji[index]} <@{usersid[index]}>: {records[index]}'
+ for index in range(0, len(usersid))) or 'No Candies'
+
+ e = discord.Embed(colour=discord.Colour.blurple())
+ e.add_field(name="Top Candy Records", value=value, inline=False)
+ e.add_field(name='\u200b',
+ value=f"Candies will randomly appear on messages sent. "
+ f"\nHit the candy when it appears as fast as possible to get the candy! "
+ f"\nBut beware the ghosts...",
+ inline=False)
+ await ctx.send(embed=e)
+
+
+def setup(bot):
+ bot.add_cog(CandyCollection(bot))
+ log.debug("CandyCollection cog loaded")
diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py
new file mode 100644
index 00000000..41cf10ee
--- /dev/null
+++ b/bot/seasons/halloween/hacktoberstats.py
@@ -0,0 +1,335 @@
+import json
+import logging
+import re
+import typing
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class HacktoberStats:
+ def __init__(self, bot):
+ self.bot = bot
+ self.link_json = Path("bot", "resources", "github_links.json")
+ self.linked_accounts = self.load_linked_users()
+
+ @commands.group(
+ name='hacktoberstats',
+ aliases=('hackstats',),
+ invoke_without_command=True
+ )
+ async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None):
+ """
+ If invoked without a subcommand or github_username, get the invoking user's stats if
+ they've linked their Discord name to GitHub using .stats link
+
+ If invoked with a github_username, get that user's contributions
+ """
+ if not github_username:
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+
+ if str(author_id) in self.linked_accounts.keys():
+ github_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"Getting stats for {author_id} linked GitHub account '{github_username}'")
+ else:
+ msg = (
+ f"{author_mention}, you have not linked a GitHub account\n\n"
+ f"You can link your GitHub account using:\n```{ctx.prefix}stats link github_username```\n"
+ f"Or query GitHub stats directly using:\n```{ctx.prefix}stats github_username```"
+ )
+ await ctx.send(msg)
+ return
+
+ await self.get_stats(ctx, github_username)
+
+ @hacktoberstats_group.command(name="link")
+ async def link_user(self, ctx: commands.Context, github_username: str = None):
+ """
+ Link the invoking user's Github github_username to their Discord ID
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+ if github_username:
+ if str(author_id) in self.linked_accounts.keys():
+ old_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'")
+ else:
+ logging.info(f"{author_id} has added a github link to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been added")
+
+ self.linked_accounts[author_id] = {
+ "github_username": github_username,
+ "date_added": datetime.now()
+ }
+
+ self.save_linked_users()
+ else:
+ logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username")
+ await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
+
+ @hacktoberstats_group.command(name="unlink")
+ async def unlink_user(self, ctx: commands.Context):
+ """
+ Remove the invoking user's account link from the log
+ """
+ author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
+
+ stored_user = self.linked_accounts.pop(author_id, None)
+ if stored_user:
+ await ctx.send(f"{author_mention}, your GitHub profile has been unlinked")
+ logging.info(f"{author_id} has unlinked their GitHub account")
+ else:
+ await ctx.send(f"{author_mention}, you do not currently have a linked GitHub account")
+ logging.info(f"{author_id} tried to unlink their GitHub account but no account was linked")
+
+ self.save_linked_users()
+
+ def load_linked_users(self) -> typing.Dict:
+ """
+ Load list of linked users from local JSON file
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ if self.link_json.exists():
+ logging.info(f"Loading linked GitHub accounts from '{self.link_json}'")
+ with open(self.link_json, 'r') as fID:
+ linked_accounts = json.load(fID)
+
+ logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'")
+ return linked_accounts
+ else:
+ logging.info(f"Linked account log: '{self.link_json}' does not exist")
+ return {}
+
+ def save_linked_users(self):
+ """
+ Save list of linked users to local JSON file
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ logging.info(f"Saving linked_accounts to '{self.link_json}'")
+ with open(self.link_json, 'w') as fID:
+ json.dump(self.linked_accounts, fID, default=str)
+ logging.info(f"linked_accounts saved to '{self.link_json}'")
+
+ async def get_stats(self, ctx: commands.Context, github_username: str):
+ """
+ Query GitHub's API for PRs created by a GitHub user during the month of October that
+ do not have an 'invalid' tag
+
+ For example:
+ !getstats heavysaturn
+
+ If a valid github_username is provided, an embed is generated and posted to the channel
+
+ Otherwise, post a helpful error message
+ """
+ async with ctx.typing():
+ prs = await self.get_october_prs(github_username)
+
+ if prs:
+ stats_embed = self.build_embed(github_username, prs)
+ await ctx.send('Here are some stats!', embed=stats_embed)
+ else:
+ await ctx.send(f"No October GitHub contributions found for '{github_username}'")
+
+ def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed:
+ """
+ Return a stats embed built from github_username's PRs
+ """
+ logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'")
+ pr_stats = self._summarize_prs(prs)
+
+ n = pr_stats['n_prs']
+ if n >= 5:
+ shirtstr = f"**{github_username} has earned a tshirt!**"
+ elif n == 4:
+ shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
+ else:
+ shirtstr = f"**{github_username} is {5 - n} PRs away from a tshirt!**"
+
+ stats_embed = discord.Embed(
+ title=f"{github_username}'s Hacktoberfest",
+ color=discord.Color(0x9c4af7),
+ description=(
+ f"{github_username} has made {n} "
+ f"{HacktoberStats._contributionator(n)} in "
+ f"October\n\n"
+ f"{shirtstr}\n\n"
+ )
+ )
+
+ stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png")
+ stats_embed.set_author(
+ name="Hacktoberfest",
+ url="https://hacktoberfest.digitalocean.com",
+ icon_url="https://hacktoberfest.digitalocean.com/assets/logo-hacktoberfest.png"
+ )
+ stats_embed.add_field(
+ name="Top 5 Repositories:",
+ value=self._build_top5str(pr_stats)
+ )
+
+ logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'")
+ return stats_embed
+
+ @staticmethod
+ async def get_october_prs(github_username: str) -> typing.List[dict]:
+ """
+ Query GitHub's API for PRs created during the month of October by github_username
+ that do not have an 'invalid' tag
+
+ If PRs are found, return a list of dicts with basic PR information
+
+ For each PR:
+ {
+ "repo_url": str
+ "repo_shortname": str (e.g. "python-discord/seasonalbot")
+ "created_at": datetime.datetime
+ }
+
+ Otherwise, return None
+ """
+ logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'")
+ base_url = "https://api.github.com/search/issues?q="
+ not_label = "invalid"
+ action_type = "pr"
+ is_query = f"public+author:{github_username}"
+ date_range = "2018-10-01..2018-10-31"
+ per_page = "300"
+ query_url = (
+ f"{base_url}"
+ f"-label:{not_label}"
+ f"+type:{action_type}"
+ f"+is:{is_query}"
+ f"+created:{date_range}"
+ f"&per_page={per_page}"
+ )
+
+ headers = {"user-agent": "Discord Python Hactoberbot"}
+ async with aiohttp.ClientSession() as session:
+ async with session.get(query_url, headers=headers) as resp:
+ jsonresp = await resp.json()
+
+ if "message" in jsonresp.keys():
+ # One of the parameters is invalid, short circuit for now
+ api_message = jsonresp["errors"][0]["message"]
+ logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+ return
+ else:
+ if jsonresp["total_count"] == 0:
+ # Short circuit if there aren't any PRs
+ logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'")
+ return
+ else:
+ logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
+ outlist = []
+ for item in jsonresp["items"]:
+ shortname = HacktoberStats._get_shortname(item["repository_url"])
+ itemdict = {
+ "repo_url": f"https://www.github.com/{shortname}",
+ "repo_shortname": shortname,
+ "created_at": datetime.strptime(
+ item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
+ ),
+ }
+ outlist.append(itemdict)
+ return outlist
+
+ @staticmethod
+ def _get_shortname(in_url: str) -> str:
+ """
+ Extract shortname from https://api.github.com/repos/* URL
+
+ e.g. "https://api.github.com/repos/python-discord/seasonalbot"
+ |
+ V
+ "python-discord/seasonalbot"
+ """
+ exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)"
+ return re.findall(exp, in_url)[0]
+
+ @staticmethod
+ def _summarize_prs(prs: typing.List[dict]) -> typing.Dict:
+ """
+ Generate statistics from an input list of PR dictionaries, as output by get_october_prs
+
+ Return a dictionary containing:
+ {
+ "n_prs": int
+ "top5": [(repo_shortname, ncontributions), ...]
+ }
+ """
+ contributed_repos = [pr["repo_shortname"] for pr in prs]
+ return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
+
+ @staticmethod
+ def _build_top5str(stats: typing.List[tuple]) -> str:
+ """
+ Build a string from the Top 5 contributions that is compatible with a discord.Embed field
+
+ Top 5 contributions should be a list of tuples, as output in the stats dictionary by
+ _summarize_prs
+
+ String is of the form:
+ n contribution(s) to [shortname](url)
+ ...
+ """
+ baseURL = "https://www.github.com/"
+ contributionstrs = []
+ for repo in stats['top5']:
+ n = repo[1]
+ contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})")
+
+ return "\n".join(contributionstrs)
+
+ @staticmethod
+ def _contributionator(n: int) -> str:
+ """
+ Return "contribution" or "contributions" based on the value of n
+ """
+ if n == 1:
+ return "contribution"
+ else:
+ return "contributions"
+
+ @staticmethod
+ def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple:
+ """
+ Return stringified Message author ID and mentionable string from commands.Context
+ """
+ author_id = str(ctx.message.author.id)
+ author_mention = ctx.message.author.mention
+
+ return author_id, author_mention
+
+
+def setup(bot):
+ bot.add_cog(HacktoberStats(bot))
+ log.debug("HacktoberStats cog loaded")
diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py
new file mode 100644
index 00000000..098ee432
--- /dev/null
+++ b/bot/seasons/halloween/halloween_facts.py
@@ -0,0 +1,66 @@
+import json
+import logging
+import random
+from datetime import timedelta
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Hacktoberfest
+
+log = logging.getLogger(__name__)
+
+SPOOKY_EMOJIS = [
+ "\N{BAT}",
+ "\N{DERELICT HOUSE BUILDING}",
+ "\N{EXTRATERRESTRIAL ALIEN}",
+ "\N{GHOST}",
+ "\N{JACK-O-LANTERN}",
+ "\N{SKULL}",
+ "\N{SKULL AND CROSSBONES}",
+ "\N{SPIDER WEB}",
+]
+PUMPKIN_ORANGE = discord.Color(0xFF7518)
+INTERVAL = timedelta(hours=6).total_seconds()
+
+
+class HalloweenFacts:
+
+ def __init__(self, bot):
+ self.bot = bot
+ with open(Path("bot", "resources", "halloween", "halloween_facts.json"), "r") as file:
+ self.halloween_facts = json.load(file)
+ self.channel = None
+ self.facts = list(enumerate(self.halloween_facts))
+ random.shuffle(self.facts)
+
+ async def on_ready(self):
+ self.channel = self.bot.get_channel(Hacktoberfest.channel_id)
+ self.bot.loop.create_task(self._fact_publisher_task())
+
+ def random_fact(self):
+ return random.choice(self.facts)
+
+ @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact")
+ async def get_random_fact(self, ctx):
+ """
+ Reply with the most recent Halloween fact.
+ """
+ index, fact = self.random_fact()
+ embed = self._build_embed(index, fact)
+ await ctx.send(embed=embed)
+
+ @staticmethod
+ def _build_embed(index, fact):
+ """
+ Builds a Discord embed from the given fact and its index.
+ """
+ emoji = random.choice(SPOOKY_EMOJIS)
+ title = f"{emoji} Halloween Fact #{index + 1}"
+ return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE)
+
+
+def setup(bot):
+ bot.add_cog(HalloweenFacts(bot))
+ log.debug("HalloweenFacts cog loaded")
diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py
new file mode 100644
index 00000000..cda07472
--- /dev/null
+++ b/bot/seasons/halloween/halloweenify.py
@@ -0,0 +1,55 @@
+import logging
+from json import load
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+log = logging.getLogger(__name__)
+
+
+class Halloweenify:
+ """
+ A cog to change a invokers nickname to a spooky one!
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.cooldown(1, 300, BucketType.user)
+ @commands.command()
+ async def halloweenify(self, ctx):
+ """
+ Change your nickname into a much spookier one!
+ """
+ async with ctx.typing():
+ with open(Path('bot', 'resources', 'halloween', 'halloweenify.json'), 'r') as f:
+ data = load(f)
+
+ # Choose a random character from our list we loaded above and set apart the nickname and image url.
+ character = choice(data['characters'])
+ nickname = ''.join([nickname for nickname in character])
+ image = ''.join([character[nickname] for nickname in character])
+
+ # Build up a Embed
+ embed = discord.Embed()
+ embed.colour = discord.Colour.dark_orange()
+ embed.title = 'Not spooky enough?'
+ embed.description = (
+ f'**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, '
+ f'{ctx.author.display_name} isn\'t scary at all! '
+ 'Let me think of something better. Hmm... I got it!\n\n '
+ f'Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:'
+ )
+ embed.set_image(url=image)
+
+ await ctx.author.edit(nick=nickname)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(Halloweenify(bot))
+ log.debug("Halloweenify cog loaded")
diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py
new file mode 100644
index 00000000..08873f24
--- /dev/null
+++ b/bot/seasons/halloween/monstersurvey.py
@@ -0,0 +1,218 @@
+import json
+import logging
+import os
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import Bot, Context
+
+log = logging.getLogger(__name__)
+
+EMOJIS = {
+ 'SUCCESS': u'\u2705',
+ 'ERROR': u'\u274C'
+}
+
+
+class MonsterSurvey:
+ """
+ Vote for your favorite monster!
+ This command allows users to vote for their favorite listed monster.
+ Users may change their vote, but only their current vote will be counted.
+ """
+
+ def __init__(self, bot: Bot):
+ """Initializes values for the bot to use within the voting commands."""
+
+ self.bot = bot
+ self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json')
+ with open(self.registry_location, 'r') as jason:
+ self.voter_registry = json.load(jason)
+
+ def json_write(self):
+ log.info("Saved Monster Survey Results")
+ with open(self.registry_location, 'w') as jason:
+ json.dump(self.voter_registry, jason, indent=2)
+
+ def cast_vote(self, id: int, monster: str):
+ """
+ :param id: The id of the person voting
+ :param monster: the string key of the json that represents a monster
+ :return: None
+ """
+
+ vr = self.voter_registry
+ for m in vr.keys():
+ if id not in vr[m]['votes'] and m == monster:
+ vr[m]['votes'].append(id)
+ else:
+ if id in vr[m]['votes'] and m != monster:
+ vr[m]['votes'].remove(id)
+
+ def get_name_by_leaderboard_index(self, n):
+ n = n - 1
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True)
+ name = top[n] if n >= 0 else None
+ return name
+
+ @commands.group(
+ name='monster',
+ aliases=('ms',)
+ )
+ async def monster_group(self, ctx: Context):
+ """
+ The base voting command. If nothing is called, then it will return an embed.
+ """
+
+ if ctx.invoked_subcommand is None:
+ async with ctx.typing():
+ default_embed = Embed(
+ title='Monster Voting',
+ color=0xFF6800,
+ description='Vote for your favorite monster!'
+ )
+ default_embed.add_field(
+ name='.monster show monster_name(optional)',
+ value='Show a specific monster. If none is listed, it will give you an error with valid choices.',
+ inline=False)
+ default_embed.add_field(
+ name='.monster vote monster_name',
+ value='Vote for a specific monster. You get one vote, but can change it at any time.',
+ inline=False
+ )
+ default_embed.add_field(
+ name='.monster leaderboard',
+ value='Which monster has the most votes? This command will tell you.',
+ inline=False
+ )
+ default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry.keys())}")
+
+ await ctx.send(embed=default_embed)
+
+ @monster_group.command(
+ name='vote'
+ )
+ async def monster_vote(self, ctx: Context, name=None):
+ """
+ Casts a vote for a particular monster, or displays a list of monsters that can be voted for
+ if one is not given.
+ """
+
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ vote_embed = Embed(
+ name='Monster Voting',
+ color=0xFF6800
+ )
+
+ m = self.voter_registry.get(name)
+ if m is None:
+ vote_embed.description = f'You cannot vote for {name} because it\'s not in the running.'
+ vote_embed.add_field(
+ name='Use `.monster show {monster_name}` for more information on a specific monster',
+ value='or use `.monster vote {monster}` to cast your vote for said monster.',
+ inline=False
+ )
+ vote_embed.add_field(
+ name='You may vote for or show the following monsters:',
+ value=f"{', '.join(self.voter_registry.keys())}"
+ )
+ else:
+ self.cast_vote(ctx.author.id, name)
+ vote_embed.add_field(
+ name='Vote successful!',
+ value=f'You have successfully voted for {m["full_name"]}!',
+ inline=False
+ )
+ vote_embed.set_thumbnail(url=m['image'])
+ vote_embed.set_footer(text="Please note that any previous votes have been removed.")
+ self.json_write()
+
+ await ctx.send(embed=vote_embed)
+
+ @monster_group.command(
+ name='show'
+ )
+ async def monster_show(self, ctx: Context, name=None):
+ """
+ Shows the named monster. If one is not named, it sends the default voting embed instead.
+
+ :param ctx:
+ :param name:
+ :return:
+ """
+
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ m = self.voter_registry.get(name)
+ if not m:
+ await ctx.send('That monster does not exist.')
+ await ctx.invoke(self.monster_vote)
+ return
+
+ embed = Embed(title=m['full_name'], color=0xFF6800)
+ embed.add_field(name='Summary', value=m['summary'])
+ embed.set_image(url=m['image'])
+ embed.set_footer(text=f'To vote for this monster, type .monster vote {name}')
+
+ await ctx.send(embed=embed)
+
+ @monster_group.command(
+ name='leaderboard',
+ aliases=('lb',)
+ )
+ async def monster_leaderboard(self, ctx: Context):
+ """
+ Shows the current standings.
+ :param ctx:
+ :return:
+ """
+
+ async with ctx.typing():
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True)
+ total_votes = sum(len(m['votes']) for m in self.voter_registry.values())
+
+ embed = Embed(title="Monster Survey Leader Board", color=0xFF6800)
+ for rank, m in enumerate(top):
+ votes = len(vr[m]['votes'])
+ percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0
+ embed.add_field(name=f"{rank+1}. {vr[m]['full_name']}",
+ value=(
+ f"{votes} votes. {percentage:.1f}% of total votes.\n"
+ f"Vote for this monster by typing "
+ f"'.monster vote {m}'\n"
+ f"Get more information on this monster by typing "
+ f"'.monster show {m}'"
+ ),
+ inline=False)
+
+ embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(MonsterSurvey(bot))
+ log.debug("MonsterSurvey cog loaded")
diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py
new file mode 100644
index 00000000..b280781e
--- /dev/null
+++ b/bot/seasons/halloween/scarymovie.py
@@ -0,0 +1,141 @@
+import logging
+import random
+from os import environ
+
+import aiohttp
+from discord import Embed
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+TMDB_API_KEY = environ.get('TMDB_API_KEY')
+TMDB_TOKEN = environ.get('TMDB_TOKEN')
+
+
+class ScaryMovie:
+ """
+ Selects a random scary movie and embeds info into discord chat
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command(name='scarymovie', alias=['smovie'])
+ async def random_movie(self, ctx):
+ """
+ Randomly select a scary movie and display information about it.
+ """
+ async with ctx.typing():
+ selection = await self.select_movie()
+ movie_details = await self.format_metadata(selection)
+
+ await ctx.send(embed=movie_details)
+
+ @staticmethod
+ async def select_movie():
+ """
+ Selects a random movie and returns a json of movie details from TMDb
+ """
+
+ url = 'https://api.themoviedb.org/4/discover/movie'
+ params = {
+ 'with_genres': '27',
+ 'vote_count.gte': '5'
+ }
+ headers = {
+ 'Authorization': 'Bearer ' + TMDB_TOKEN,
+ 'Content-Type': 'application/json;charset=utf-8'
+ }
+
+ # Get total page count of horror movies
+ async with aiohttp.ClientSession() as session:
+ response = await session.get(url=url, params=params, headers=headers)
+ total_pages = await response.json()
+ total_pages = total_pages.get('total_pages')
+
+ # Get movie details from one random result on a random page
+ params['page'] = random.randint(1, total_pages)
+ response = await session.get(url=url, params=params, headers=headers)
+ response = await response.json()
+ selection_id = random.choice(response.get('results')).get('id')
+
+ # Get full details and credits
+ selection = await session.get(
+ url='https://api.themoviedb.org/3/movie/' + str(selection_id),
+ params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'}
+ )
+
+ return await selection.json()
+
+ @staticmethod
+ async def format_metadata(movie):
+ """
+ Formats raw TMDb data to be embedded in discord chat
+ """
+
+ # Build the relevant URLs.
+ movie_id = movie.get("id")
+ poster_path = movie.get("poster_path")
+ tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None
+ poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None
+
+ # Get cast names
+ cast = []
+ for actor in movie.get('credits', {}).get('cast', [])[:3]:
+ cast.append(actor.get('name'))
+
+ # Get director name
+ director = movie.get('credits', {}).get('crew', [])
+ if director:
+ director = director[0].get('name')
+
+ # Determine the spookiness rating
+ rating = ''
+ rating_count = movie.get('vote_average', 0)
+
+ if rating_count:
+ rating_count /= 2
+
+ for _ in range(int(rating_count)):
+ rating += ':skull:'
+ if (rating_count % 1) >= .5:
+ rating += ':bat:'
+
+ # Try to get year of release and runtime
+ year = movie.get('release_date', [])[:4]
+ runtime = movie.get('runtime')
+ runtime = f"{runtime} minutes" if runtime else None
+
+ # Not all these attributes will always be present
+ movie_attributes = {
+ "Directed by": director,
+ "Starring": ', '.join(cast),
+ "Running time": runtime,
+ "Release year": year,
+ "Spookiness rating": rating,
+ }
+
+ embed = Embed(
+ colour=0x01d277,
+ title='**' + movie.get('title') + '**',
+ url=tmdb_url,
+ description=movie.get('overview')
+ )
+
+ if poster:
+ embed.set_image(url=poster)
+
+ # Add the attributes that we actually have data for, but not the others.
+ for name, value in movie_attributes.items():
+ if value:
+ embed.add_field(name=name, value=value)
+
+ embed.set_footer(text='powered by themoviedb.org')
+
+ return embed
+
+
+def setup(bot):
+ bot.add_cog(ScaryMovie(bot))
+ log.debug("ScaryMovie cog loaded")
diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py
new file mode 100644
index 00000000..b37a03f9
--- /dev/null
+++ b/bot/seasons/halloween/spookyavatar.py
@@ -0,0 +1,58 @@
+import logging
+import os
+from io import BytesIO
+
+import aiohttp
+import discord
+from discord.ext import commands
+from PIL import Image
+
+from bot.utils.halloween import spookifications
+
+log = logging.getLogger(__name__)
+
+
+class SpookyAvatar:
+
+ """
+ A cog that spookifies an avatar.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def get(self, url):
+ """
+ Returns the contents of the supplied url.
+ """
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as resp:
+ return await resp.read()
+
+ @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'),
+ brief='Spookify an user\'s avatar.')
+ async def spooky_avatar(self, ctx, user: discord.Member = None):
+ """
+ A command to print the user's spookified avatar.
+ """
+ if user is None:
+ user = ctx.message.author
+
+ async with ctx.typing():
+ 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))
+ modified_im = spookifications.get_random_effect(im)
+ modified_im.save(str(ctx.message.id)+'.png')
+ f = discord.File(str(ctx.message.id)+'.png')
+ embed.set_image(url='attachment://'+str(ctx.message.id)+'.png')
+
+ await ctx.send(file=f, embed=embed)
+ os.remove(str(ctx.message.id)+'.png')
+
+
+def setup(bot):
+ bot.add_cog(SpookyAvatar(bot))
+ log.debug("SpookyAvatar cog loaded")
diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py
new file mode 100644
index 00000000..1233773b
--- /dev/null
+++ b/bot/seasons/halloween/spookygif.py
@@ -0,0 +1,43 @@
+import logging
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Tokens
+
+log = logging.getLogger(__name__)
+
+
+class SpookyGif:
+ """
+ A cog to fetch a random spooky gif from the web!
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command(name="spookygif", aliases=("sgif", "scarygif"))
+ async def spookygif(self, ctx):
+ """
+ Fetches a random gif from the GIPHY API and responds with it.
+ """
+
+ async with ctx.typing():
+ async with aiohttp.ClientSession() as session:
+ params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'}
+ # Make a GET request to the Giphy API to get a random halloween gif.
+ async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp:
+ data = await resp.json()
+ url = data['data']['image_url']
+
+ embed = discord.Embed(colour=0x9b59b6)
+ embed.title = "A spooooky gif!"
+ embed.set_image(url=url)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(SpookyGif(bot))
+ log.debug("SpookyGif cog loaded")
diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py
new file mode 100644
index 00000000..f63cd7e5
--- /dev/null
+++ b/bot/seasons/halloween/spookyreact.py
@@ -0,0 +1,72 @@
+import logging
+import re
+
+import discord
+
+log = logging.getLogger(__name__)
+
+SPOOKY_TRIGGERS = {
+ 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"),
+ 'skeleton': (r"\bskeleton\b", "\U0001F480"),
+ 'doot': (r"\bdo{2,}t\b", "\U0001F480"),
+ 'pumpkin': (r"\bpumpkin\b", "\U0001F383"),
+ 'halloween': (r"\bhalloween\b", "\U0001F383"),
+ 'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"),
+ 'danger': (r"\bdanger\b", "\U00002620")
+}
+
+
+class SpookyReact:
+
+ """
+ A cog that makes the bot react to message triggers.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def on_message(self, ctx: discord.Message):
+ """
+ A command to send the seasonalbot github project
+
+ Lines that begin with the bot's command prefix are ignored
+
+ Seasonalbot's own messages are ignored
+ """
+ for trigger in SPOOKY_TRIGGERS.keys():
+ trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower())
+ if trigger_test:
+ # Check message for bot replies and/or command invocations
+ # Short circuit if they're found, logging is handled in _short_circuit_check
+ if await self._short_circuit_check(ctx):
+ return
+ else:
+ await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1])
+ logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}")
+
+ async def _short_circuit_check(self, ctx: discord.Message) -> bool:
+ """
+ Short-circuit helper check.
+
+ Return True if:
+ * author is the bot
+ * prefix is not None
+ """
+ # Check for self reaction
+ if ctx.author == self.bot.user:
+ logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}")
+ return True
+
+ # Check for command invocation
+ # Because on_message doesn't give a full Context object, generate one first
+ tmp_ctx = await self.bot.get_context(ctx)
+ if tmp_ctx.prefix:
+ logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}")
+ return True
+
+ return False
+
+
+def setup(bot):
+ bot.add_cog(SpookyReact(bot))
+ log.debug("SpookyReact cog loaded")
diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py
new file mode 100644
index 00000000..4cab1239
--- /dev/null
+++ b/bot/seasons/halloween/spookysound.py
@@ -0,0 +1,50 @@
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Hacktoberfest
+
+log = logging.getLogger(__name__)
+
+
+class SpookySound:
+ """
+ A cog that plays a spooky sound in a voice channel on command.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.sound_files = list(Path("bot", "resources", "halloween", "spookysounds").glob("*.mp3"))
+ self.channel = None
+
+ @commands.cooldown(rate=1, per=1)
+ @commands.command(brief="Play a spooky sound, restricted to once per 2 mins")
+ async def spookysound(self, ctx):
+ """
+ Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. Cannot be used more than
+ once in 2 minutes.
+ """
+ if not self.channel:
+ await self.bot.wait_until_ready()
+ self.channel = self.bot.get_channel(Hacktoberfest.voice_id)
+
+ await ctx.send("Initiating spooky sound...")
+ file_path = random.choice(self.sound_files)
+ src = discord.FFmpegPCMAudio(str(file_path.resolve()))
+ voice = await self.channel.connect()
+ voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice)))
+
+ @staticmethod
+ async def disconnect(voice):
+ """
+ Helper method to disconnect a given voice client.
+ """
+ await voice.disconnect()
+
+
+def setup(bot):
+ bot.add_cog(SpookySound(bot))
+ log.debug("SpookySound cog loaded")
diff --git a/bot/seasons/season.py b/bot/seasons/season.py
new file mode 100644
index 00000000..5ab364c5
--- /dev/null
+++ b/bot/seasons/season.py
@@ -0,0 +1,180 @@
+import asyncio
+import datetime
+import importlib
+import logging
+import pkgutil
+from pathlib import Path
+
+from discord.ext import commands
+
+from bot.constants import Client, Roles
+from bot.decorators import with_role
+
+log = logging.getLogger(__name__)
+
+
+def get_seasons():
+ """
+ Returns all the Season objects
+ located in bot/seasons/
+ """
+ seasons = []
+
+ for module in pkgutil.iter_modules([Path('bot', 'seasons')]):
+ if module.ispkg:
+ seasons.append(module[1])
+
+ return seasons
+
+
+def get_season_class(season_name):
+ season_lib = importlib.import_module(f'bot.seasons.{season_name}')
+ return getattr(season_lib, season_name.capitalize())
+
+
+def get_season(bot, season_name: str = None, date: datetime.date = None):
+ """
+ Returns a Season object based on either a string or a date.
+ """
+
+ # If either both or neither are set, raise an error.
+ if not bool(season_name) ^ bool(date):
+ raise UserWarning("This function requires either a season or a date in order to run.")
+
+ seasons = get_seasons()
+
+ # Use season override if season name not provided
+ if not season_name and Client.season_override:
+ log.debug(f"Season override found: {Client.season_override}")
+ season_name = Client.season_override
+
+ # If name provided grab the specified class or fallback to evergreen.
+ if season_name:
+ season_name = season_name.lower()
+ if season_name not in seasons:
+ season_name = 'evergreen'
+ season_class = get_season_class(season_name)
+ return season_class(bot)
+
+ # If not, we have to figure out if the date matches any of the seasons.
+ seasons.remove('evergreen')
+ for season_name in seasons:
+ season_class = get_season_class(season_name)
+ # check if date matches before returning an instance
+ if season_class.start() <= date <= season_class.end():
+ return season_class(bot)
+ else:
+ evergreen_class = get_season_class('evergreen')
+ return evergreen_class(bot)
+
+
+class SeasonBase:
+ name = None
+ date_format = "%d/%m-%Y"
+
+ @staticmethod
+ def current_year():
+ return datetime.date.today().year
+
+ @classmethod
+ def start(cls):
+ return datetime.datetime.strptime(f"{cls.start_date}-{cls.current_year()}", cls.date_format).date()
+
+ @classmethod
+ def end(cls):
+ return datetime.datetime.strptime(f"{cls.end_date}-{cls.current_year()}", cls.date_format).date()
+
+ @staticmethod
+ def avatar_path(*path_segments):
+ return Path('bot', 'resources', 'avatars', *path_segments)
+
+ async def load(self):
+ """
+ Loads in the bot name, the bot avatar,
+ and the extensions that are relevant to that season.
+ """
+
+ guild = self.bot.get_guild(Client.guild)
+
+ # Change only nickname if in debug mode due to ratelimits for user edits
+ if Client.debug:
+ if guild.me.display_name != self.bot_name:
+ log.debug(f"Changing nickname to {self.bot_name}")
+ await guild.me.edit(nick=self.bot_name)
+ else:
+ if self.bot.user.name != self.bot_name:
+ # attempt to change user details
+ log.debug(f"Changing username to {self.bot_name}")
+ await self.bot.user.edit(name=self.bot_name, avatar=self.bot_avatar)
+
+ # fallback on nickname if failed due to ratelimit
+ if self.bot.user.name != self.bot_name:
+ log.info(f"User details failed to change: Changing nickname to {self.bot_name}")
+ await guild.me.edit(nick=self.bot_name)
+
+ # remove nickname if an old one exists
+ if guild.me.nick and guild.me.nick != self.bot_name:
+ log.debug(f"Clearing old nickname of {guild.me.nick}")
+ await guild.me.edit(nick=None)
+
+ # Prepare all the seasonal cogs, and then the evergreen ones.
+ extensions = []
+ for ext_folder in {self.name, "evergreen"}:
+ if ext_folder:
+ log.info(f'Start loading extensions from seasons/{ext_folder}/')
+ path = Path('bot', 'seasons', ext_folder)
+ for ext_name in [i[1] for i in pkgutil.iter_modules([path])]:
+ extensions.append(f"bot.seasons.{ext_folder}.{ext_name}")
+
+ # Finally we can load all the cogs we've prepared.
+ self.bot.load_extensions(extensions)
+
+
+class SeasonManager:
+ """
+ A cog for managing seasons.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.season = get_season(bot, date=datetime.date.today())
+ self.season_task = bot.loop.create_task(self.load_seasons())
+
+ # Figure out number of seconds until a minute past midnight
+ tomorrow = datetime.datetime.now() + datetime.timedelta(1)
+ midnight = datetime.datetime(
+ year=tomorrow.year,
+ month=tomorrow.month,
+ day=tomorrow.day,
+ hour=0,
+ minute=0,
+ second=0
+ )
+ self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60
+
+ async def load_seasons(self):
+ await self.bot.wait_until_ready()
+ await self.season.load()
+
+ while True:
+ await asyncio.sleep(self.sleep_time) # sleep until midnight
+ self.sleep_time = 86400 # next time, sleep for 24 hours.
+
+ # If the season has changed, load it.
+ new_season = get_season(self.bot, date=datetime.date.today())
+ if new_season != self.season:
+ await self.season.load()
+
+ @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @commands.command('season')
+ async def change_season(self, ctx, new_season: str):
+ """
+ Changes the currently active season on the bot.
+ """
+
+ self.season = get_season(self.bot, season_name=new_season)
+ await self.season.load()
+ await ctx.send(f"Season changed to {new_season}.")
+
+ def __unload(self):
+ self.season_task.cancel()