diff options
Diffstat (limited to 'bot/seasons')
| -rw-r--r-- | bot/seasons/__init__.py | 12 | ||||
| -rw-r--r-- | bot/seasons/christmas/__init__.py | 16 | ||||
| -rw-r--r-- | bot/seasons/evergreen/__init__.py | 13 | ||||
| -rw-r--r-- | bot/seasons/evergreen/error_handler.py | 109 | ||||
| -rw-r--r-- | bot/seasons/evergreen/uptime.py | 38 | ||||
| -rw-r--r-- | bot/seasons/halloween/__init__.py | 16 | ||||
| -rw-r--r-- | bot/seasons/halloween/candy_collection.py | 234 | ||||
| -rw-r--r-- | bot/seasons/halloween/hacktoberstats.py | 335 | ||||
| -rw-r--r-- | bot/seasons/halloween/halloween_facts.py | 66 | ||||
| -rw-r--r-- | bot/seasons/halloween/halloweenify.py | 55 | ||||
| -rw-r--r-- | bot/seasons/halloween/monstersurvey.py | 218 | ||||
| -rw-r--r-- | bot/seasons/halloween/scarymovie.py | 141 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookyavatar.py | 58 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookygif.py | 43 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookyreact.py | 72 | ||||
| -rw-r--r-- | bot/seasons/halloween/spookysound.py | 50 | ||||
| -rw-r--r-- | bot/seasons/season.py | 180 |
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() |