From 70d2170a0a6594561d59c7d080c4280f1ebcd70b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Tue, 27 Nov 2018 21:01:18 +0100 Subject: Allows you to create Seasons. (#64) * Allows you to create Season objects which change the bots behavior. For example, a season can determine things like the avatar, the nickname, and which cogs are loaded. Season automatically changes according to the date range you specify when you create it. * removing some hungarian notation. * Automatic season changes will now always happen at a minute past midnight, no matter when the bot started. * catching dunders in the glob. * Refine Season Creation behaviour and structure. * Added channel and role constants, refactored roles into NamedTuples, added role check decorators from the main bot, and added role checks for the season change feature. Yes this is duplicate code from our main bot, but it will just have to be like that for now until we get a bot core running. * replacing the or with an xor and switching out the assert for a UserWarning * New lockfile * changing discord.py to discord-py to prevent pip bug from putting two of them in our lockfile * fixing flake errors * flake8 * Cleaned everything up, but I seem to have introduced some sort of infinite load loop? o.O * Fixing up all bugs in the halloween cogs. This should be ready for merge now. * Add avatar_path baseclass method for consistency. While making it simpler to add avatar urls in new season extensions, it also allows the avatar resource path to be changed in a single place if needed in future. * Avoid shadowing builtin `object`. * Add debug mode, refine bot user editing on season load. The changing of a bot's username and avatar is heavily ratelimited. While testing, restarting the bot and changing seasons is required, and hitting these limits are typical. Instead, when in debug mode, the avatar isn't set and only the nickname is changed to prevent unnecessary account edit requests. In the case that the rate limit is hit when not in debug mode, there's an added fallback to use the nickname instead. * Add cancel load_seasons task on SeasonManager un/reload. Previously the load_seasons task was loaded and looping forever. Even if the cog was unloaded for some reason, it would still be running. On loading the SeasonManager again, it would create a new load_seasons task, while the old one still existed. Adding the cancellation allows the task to end when the cog is unloaded or reloaded, and will help assist with live code changes during development at a later time where it's possible to reload this extension (perhaps when the pending bot core is implemented). * get_season_class helper, season class attribs, fix admin id Changes `get_season`'s date check to not initialise unwanted classes (to avoid needless loading of tasks which would otherwise cause unexpected behaviour). To do this, defining attributes of season classes have been moved from `__init__` as an instance variable, to the class variable level. This also results in `__init__` not needing to be defined for the `SeasonBase` class, and `super().__init__()` not needing to be called in individual season classes, making things cleaner/simpler for them. Adds a helper function for retrieving a season class and combines two unnecessarily separate if statements. Credits to @MarkKoz for the suggestions. Reverts the admin ID mistakenly changed in a previous commit. * Update bot/seasons/halloween/hacktoberstats.py Co-Authored-By: heavysaturn * Update bot/seasons/halloween/halloween_facts.py Co-Authored-By: heavysaturn * No more property in halloweenfacts * Changed all aliases to tuples * Made tokens a seperate namedtuple * Update bot/seasons/halloween/spookyavatar.py Co-Authored-By: heavysaturn --- bot/__init__.py | 5 +- bot/__main__.py | 33 +- bot/bot.py | 42 +++ bot/cogs/__init__.py | 0 bot/cogs/error_handler.py | 106 ------- bot/cogs/evergreen/__init__.py | 0 bot/cogs/evergreen/uptime.py | 33 -- bot/cogs/gif.py | 32 -- bot/cogs/hacktober/__init__.py | 0 bot/cogs/hacktober/candy_collection.py | 229 -------------- bot/cogs/hacktober/hacktoberstats.py | 327 -------------------- bot/cogs/hacktober/halloween_facts.py | 76 ----- bot/cogs/hacktober/halloweenify.py | 51 ---- bot/cogs/hacktober/monstersurvey.py | 218 -------------- bot/cogs/hacktober/scarymovie.py | 137 --------- bot/cogs/hacktober/spookyavatar.py | 54 ---- bot/cogs/hacktober/spookygif.py | 37 --- bot/cogs/hacktober/spookyreact.py | 69 ----- bot/cogs/hacktober/spookysound.py | 46 --- bot/cogs/template.py | 39 --- bot/constants.py | 76 ++++- bot/decorators.py | 48 +++ bot/resources/avatars/christmas.png | Bin 0 -> 44843 bytes bot/resources/avatars/spooky.png | Bin 0 -> 37202 bytes bot/resources/avatars/standard.png | Bin 0 -> 52156 bytes bot/resources/halloween/github_links.json | 0 bot/resources/halloween/monstersurvey.json | 5 +- .../spookysounds/109710__tomlija__horror-gate.mp3 | Bin 0 -> 118125 bytes .../spookysounds/126113__klankbeeld__laugh.mp3 | Bin 0 -> 112365 bytes ...ugh-original-132802-nanakisan-evil-laugh-08.mp3 | Bin 0 -> 137385 bytes .../spookysounds/14570__oscillator__ghost-fx.mp3 | Bin 0 -> 135405 bytes .../spookysounds/168650__0xmusex0__doorcreak.mp3 | Bin 0 -> 162421 bytes ...71078__klankbeeld__horror-scream-woman-long.mp3 | Bin 0 -> 131625 bytes .../193812__geoneo0__four-voices-whispering-6.mp3 | Bin 0 -> 163257 bytes ...37282__devilfish101__frantic-violin-screech.mp3 | Bin 0 -> 131566 bytes .../249686__cylon8472__cthulhu-growl.mp3 | Bin 0 -> 153226 bytes .../spookysounds/35716__analogchill__scream.mp3 | Bin 0 -> 114773 bytes ...15__inspectorj__something-evil-approaches-a.mp3 | Bin 0 -> 298717 bytes .../60571__gabemiller74__breathofdeath.mp3 | Bin 0 -> 177049 bytes .../spookysounds/Female_Monster_Growls_.mp3 | Bin 0 -> 148276 bytes .../halloween/spookysounds/Male_Zombie_Roar_.mp3 | Bin 0 -> 62171 bytes .../spookysounds/Monster_Alien_Growl_Calm_.mp3 | Bin 0 -> 133651 bytes .../spookysounds/Monster_Alien_Grunt_Hiss_.mp3 | Bin 0 -> 74718 bytes bot/resources/halloween/spookysounds/sources.txt | 41 +++ .../spookysounds/109710__tomlija__horror-gate.mp3 | Bin 118125 -> 0 bytes .../spookysounds/126113__klankbeeld__laugh.mp3 | Bin 112365 -> 0 bytes ...ugh-original-132802-nanakisan-evil-laugh-08.mp3 | Bin 137385 -> 0 bytes .../spookysounds/14570__oscillator__ghost-fx.mp3 | Bin 135405 -> 0 bytes .../spookysounds/168650__0xmusex0__doorcreak.mp3 | Bin 162421 -> 0 bytes ...71078__klankbeeld__horror-scream-woman-long.mp3 | Bin 131625 -> 0 bytes .../193812__geoneo0__four-voices-whispering-6.mp3 | Bin 163257 -> 0 bytes ...37282__devilfish101__frantic-violin-screech.mp3 | Bin 131566 -> 0 bytes .../249686__cylon8472__cthulhu-growl.mp3 | Bin 153226 -> 0 bytes .../spookysounds/35716__analogchill__scream.mp3 | Bin 114773 -> 0 bytes ...15__inspectorj__something-evil-approaches-a.mp3 | Bin 298717 -> 0 bytes .../60571__gabemiller74__breathofdeath.mp3 | Bin 177049 -> 0 bytes .../spookysounds/Female_Monster_Growls_.mp3 | Bin 148276 -> 0 bytes bot/resources/spookysounds/Male_Zombie_Roar_.mp3 | Bin 62171 -> 0 bytes .../spookysounds/Monster_Alien_Growl_Calm_.mp3 | Bin 133651 -> 0 bytes .../spookysounds/Monster_Alien_Grunt_Hiss_.mp3 | Bin 74718 -> 0 bytes bot/resources/spookysounds/sources.txt | 41 --- bot/seasons/__init__.py | 12 + bot/seasons/christmas/__init__.py | 16 + bot/seasons/evergreen/__init__.py | 13 + bot/seasons/evergreen/error_handler.py | 109 +++++++ bot/seasons/evergreen/uptime.py | 38 +++ bot/seasons/halloween/__init__.py | 16 + bot/seasons/halloween/candy_collection.py | 234 ++++++++++++++ bot/seasons/halloween/hacktoberstats.py | 335 +++++++++++++++++++++ bot/seasons/halloween/halloween_facts.py | 66 ++++ bot/seasons/halloween/halloweenify.py | 55 ++++ bot/seasons/halloween/monstersurvey.py | 218 ++++++++++++++ bot/seasons/halloween/scarymovie.py | 141 +++++++++ bot/seasons/halloween/spookyavatar.py | 58 ++++ bot/seasons/halloween/spookygif.py | 43 +++ bot/seasons/halloween/spookyreact.py | 72 +++++ bot/seasons/halloween/spookysound.py | 50 +++ bot/seasons/season.py | 180 +++++++++++ bot/utils/halloween/__init__.py | 0 bot/utils/halloween/spookifications.py | 55 ++++ bot/utils/spookifications.py | 55 ---- 81 files changed, 1923 insertions(+), 1588 deletions(-) create mode 100644 bot/bot.py delete mode 100644 bot/cogs/__init__.py delete mode 100644 bot/cogs/error_handler.py delete mode 100644 bot/cogs/evergreen/__init__.py delete mode 100644 bot/cogs/evergreen/uptime.py delete mode 100644 bot/cogs/gif.py delete mode 100644 bot/cogs/hacktober/__init__.py delete mode 100644 bot/cogs/hacktober/candy_collection.py delete mode 100644 bot/cogs/hacktober/hacktoberstats.py delete mode 100644 bot/cogs/hacktober/halloween_facts.py delete mode 100644 bot/cogs/hacktober/halloweenify.py delete mode 100644 bot/cogs/hacktober/monstersurvey.py delete mode 100644 bot/cogs/hacktober/scarymovie.py delete mode 100644 bot/cogs/hacktober/spookyavatar.py delete mode 100644 bot/cogs/hacktober/spookygif.py delete mode 100644 bot/cogs/hacktober/spookyreact.py delete mode 100644 bot/cogs/hacktober/spookysound.py delete mode 100644 bot/cogs/template.py create mode 100644 bot/decorators.py create mode 100644 bot/resources/avatars/christmas.png create mode 100644 bot/resources/avatars/spooky.png create mode 100644 bot/resources/avatars/standard.png create mode 100644 bot/resources/halloween/github_links.json create mode 100644 bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 create mode 100644 bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 create mode 100644 bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 create mode 100644 bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 create mode 100644 bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 create mode 100644 bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 create mode 100644 bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 create mode 100644 bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 create mode 100644 bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 create mode 100644 bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 create mode 100644 bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 create mode 100644 bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 create mode 100644 bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 create mode 100644 bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 create mode 100644 bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 create mode 100644 bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 create mode 100644 bot/resources/halloween/spookysounds/sources.txt delete mode 100644 bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 delete mode 100644 bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 delete mode 100644 bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 delete mode 100644 bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 delete mode 100644 bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 delete mode 100644 bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 delete mode 100644 bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 delete mode 100644 bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 delete mode 100644 bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 delete mode 100644 bot/resources/spookysounds/35716__analogchill__scream.mp3 delete mode 100644 bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 delete mode 100644 bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 delete mode 100644 bot/resources/spookysounds/Female_Monster_Growls_.mp3 delete mode 100644 bot/resources/spookysounds/Male_Zombie_Roar_.mp3 delete mode 100644 bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 delete mode 100644 bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 delete mode 100644 bot/resources/spookysounds/sources.txt create mode 100644 bot/seasons/__init__.py create mode 100644 bot/seasons/christmas/__init__.py create mode 100644 bot/seasons/evergreen/__init__.py create mode 100644 bot/seasons/evergreen/error_handler.py create mode 100644 bot/seasons/evergreen/uptime.py create mode 100644 bot/seasons/halloween/__init__.py create mode 100644 bot/seasons/halloween/candy_collection.py create mode 100644 bot/seasons/halloween/hacktoberstats.py create mode 100644 bot/seasons/halloween/halloween_facts.py create mode 100644 bot/seasons/halloween/halloweenify.py create mode 100644 bot/seasons/halloween/monstersurvey.py create mode 100644 bot/seasons/halloween/scarymovie.py create mode 100644 bot/seasons/halloween/spookyavatar.py create mode 100644 bot/seasons/halloween/spookygif.py create mode 100644 bot/seasons/halloween/spookyreact.py create mode 100644 bot/seasons/halloween/spookysound.py create mode 100644 bot/seasons/season.py create mode 100644 bot/utils/halloween/__init__.py create mode 100644 bot/utils/halloween/spookifications.py delete mode 100644 bot/utils/spookifications.py (limited to 'bot') diff --git a/bot/__init__.py b/bot/__init__.py index 6b3a2a6f..dc97df3d 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -4,6 +4,8 @@ from pathlib import Path import arrow +from bot.constants import Client + # start datetime start_time = arrow.utcnow() @@ -19,7 +21,8 @@ file_handler.setLevel(logging.DEBUG) # console handler prints to terminal console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) +level = logging.DEBUG if Client.debug else logging.INFO +console_handler.setLevel(level) # remove old loggers if any root = logging.getLogger() diff --git a/bot/__main__.py b/bot/__main__.py index b74e4f54..a3b68ec1 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,33 +1,8 @@ import logging -from os import environ -from pathlib import Path -from traceback import format_exc -from discord.ext import commands +from bot.constants import Client, bot -SEASONALBOT_TOKEN = environ.get('SEASONALBOT_TOKEN') -log = logging.getLogger() +log = logging.getLogger(__name__) -if SEASONALBOT_TOKEN: - token_dl = len(SEASONALBOT_TOKEN) // 8 - log.info(f'Bot token loaded: {SEASONALBOT_TOKEN[:token_dl]}...{SEASONALBOT_TOKEN[-token_dl:]}') -else: - log.error(f'Bot token not found: {SEASONALBOT_TOKEN}') - -ghost_unicode = "\N{GHOST}" -bot = commands.Bot(command_prefix=commands.when_mentioned_or(".", f"{ghost_unicode} ", ghost_unicode)) - -log.info('Start loading extensions from ./bot/cogs/halloween/') - - -if __name__ == '__main__': - # Scan for files in the /cogs/ directory and make a list of the file names. - cogs = [file.stem for file in Path('bot', 'cogs', 'hacktober').glob('*.py') if not file.stem.startswith("__")] - for extension in cogs: - try: - bot.load_extension(f'bot.cogs.hacktober.{extension}') - log.info(f'Successfully loaded extension: {extension}') - except Exception as e: - log.error(f'Failed to load extension {extension}: {repr(e)} {format_exc()}') - -bot.run(SEASONALBOT_TOKEN) +bot.load_extension("bot.seasons") +bot.run(Client.token) diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 00000000..24d099ad --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,42 @@ +import logging +import socket +from traceback import format_exc +from typing import List + +from aiohttp import AsyncResolver, ClientSession, TCPConnector +from discord.ext.commands import Bot + +log = logging.getLogger(__name__) + +__all__ = ('SeasonalBot',) + + +class SeasonalBot(Bot): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.http_session = ClientSession( + connector=TCPConnector( + resolver=AsyncResolver(), + family=socket.AF_INET, + ) + ) + + def load_extensions(self, exts: List[str]): + """ + Unload all current cogs, then load in the ones passed into `cogs` + """ + + # Unload all cogs + extensions = list(self.extensions.keys()) + for extension in extensions: + if extension != "bot.seasons": # We shouldn't unload the manager. + self.unload_extension(extension) + + # Load in the list of cogs that was passed in here + for extension in exts: + cog = extension.split(".")[-1] + try: + self.load_extension(extension) + log.info(f'Successfully loaded extension: {cog}') + except Exception as e: + log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}') diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py deleted file mode 100644 index 79780251..00000000 --- a/bot/cogs/error_handler.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -import math -import sys -import traceback - -from discord.ext import commands - - -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)) diff --git a/bot/cogs/evergreen/__init__.py b/bot/cogs/evergreen/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/cogs/evergreen/uptime.py b/bot/cogs/evergreen/uptime.py deleted file mode 100644 index ec4a3083..00000000 --- a/bot/cogs/evergreen/uptime.py +++ /dev/null @@ -1,33 +0,0 @@ -import arrow -from dateutil.relativedelta import relativedelta -from discord.ext import commands - -from bot import start_time - - -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)) diff --git a/bot/cogs/gif.py b/bot/cogs/gif.py deleted file mode 100644 index cacb77ce..00000000 --- a/bot/cogs/gif.py +++ /dev/null @@ -1,32 +0,0 @@ -from os import environ - -import aiohttp -from discord.ext import commands - - -class SpookyGif: - """ - A cog to fetch a random spooky gif from the web! - """ - - def __init__(self, bot): - self.bot = bot - self.GIPHY_TOKEN = environ.get('GIPHY_TOKEN') - - @commands.command() - async def gif(self, ctx): - """ - Fetches a random gif from the GIPHY API and responds with it. - """ - - async with aiohttp.ClientSession() as session: - params = {'api_key': self.GIPHY_TOKEN, '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']['url'] - await ctx.send(url) - - -def setup(bot): - bot.add_cog(SpookyGif(bot)) diff --git a/bot/cogs/hacktober/__init__.py b/bot/cogs/hacktober/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/cogs/hacktober/candy_collection.py b/bot/cogs/hacktober/candy_collection.py deleted file mode 100644 index f5f17abb..00000000 --- a/bot/cogs/hacktober/candy_collection.py +++ /dev/null @@ -1,229 +0,0 @@ -import functools -import json -import os -import random - -import discord -from discord.ext import commands - -from bot.constants import HACKTOBER_CHANNEL_ID - -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 != HACKTOBER_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 != HACKTOBER_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 == self.HACKTOBER_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=HACKTOBER_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)) diff --git a/bot/cogs/hacktober/hacktoberstats.py b/bot/cogs/hacktober/hacktoberstats.py deleted file mode 100644 index c473d3d0..00000000 --- a/bot/cogs/hacktober/hacktoberstats.py +++ /dev/null @@ -1,327 +0,0 @@ -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 - - -class Stats: - 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='stats', - aliases=('hacktoberstats', 'getstats', 'userstats'), - 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 = Stats._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 = Stats._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 = Stats._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} {Stats._contributionator(n)} in October\n\n{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 = Stats._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} {Stats._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(Stats(bot)) diff --git a/bot/cogs/hacktober/halloween_facts.py b/bot/cogs/hacktober/halloween_facts.py deleted file mode 100644 index 7b5b866b..00000000 --- a/bot/cogs/hacktober/halloween_facts.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -import json -import random -from datetime import timedelta -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import HACKTOBER_CHANNEL_ID - -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.last_fact = None - - async def on_ready(self): - self.channel = self.bot.get_channel(HACKTOBER_CHANNEL_ID) - self.bot.loop.create_task(self._fact_publisher_task()) - - async def _fact_publisher_task(self): - """ - A background task that runs forever, sending Halloween facts at random to the Discord channel with id equal to - HACKTOBER_CHANNEL_ID every INTERVAL seconds. - """ - facts = list(enumerate(self.halloween_facts)) - while True: - # Avoid choosing each fact at random to reduce chances of facts being reposted soon. - random.shuffle(facts) - for index, fact in facts: - embed = self._build_embed(index, fact) - await self.channel.send("Your regular serving of random Halloween facts", embed=embed) - self.last_fact = (index, fact) - await asyncio.sleep(INTERVAL) - - @commands.command(name="hallofact", aliases=["hallofacts"], brief="Get the most recent Halloween fact") - async def get_last_fact(self, ctx): - """ - Reply with the most recent Halloween fact. - """ - if ctx.channel != self.channel: - return - index, fact = self.last_fact - embed = self._build_embed(index, fact) - await ctx.send("Halloween fact recap", 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)) diff --git a/bot/cogs/hacktober/halloweenify.py b/bot/cogs/hacktober/halloweenify.py deleted file mode 100644 index 5d270974..00000000 --- a/bot/cogs/hacktober/halloweenify.py +++ /dev/null @@ -1,51 +0,0 @@ -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 - - -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)) diff --git a/bot/cogs/hacktober/monstersurvey.py b/bot/cogs/hacktober/monstersurvey.py deleted file mode 100644 index 2b78abc6..00000000 --- a/bot/cogs/hacktober/monstersurvey.py +++ /dev/null @@ -1,218 +0,0 @@ -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/cogs/hacktober/scarymovie.py b/bot/cogs/hacktober/scarymovie.py deleted file mode 100644 index c2298c65..00000000 --- a/bot/cogs/hacktober/scarymovie.py +++ /dev/null @@ -1,137 +0,0 @@ -import random -from os import environ - -import aiohttp -from discord import Embed -from discord.ext import commands - - -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='movie', alias=['tmdb']) - 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)) diff --git a/bot/cogs/hacktober/spookyavatar.py b/bot/cogs/hacktober/spookyavatar.py deleted file mode 100644 index 6ce4471c..00000000 --- a/bot/cogs/hacktober/spookyavatar.py +++ /dev/null @@ -1,54 +0,0 @@ -import os -from io import BytesIO - -import aiohttp -import discord -from discord.ext import commands -from PIL import Image - -from bot.utils import spookifications - - -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)) diff --git a/bot/cogs/hacktober/spookygif.py b/bot/cogs/hacktober/spookygif.py deleted file mode 100644 index 98a411f6..00000000 --- a/bot/cogs/hacktober/spookygif.py +++ /dev/null @@ -1,37 +0,0 @@ -import aiohttp -import discord -from discord.ext import commands - -from bot.constants import GIPHY_TOKEN - - -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': GIPHY_TOKEN, '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)) diff --git a/bot/cogs/hacktober/spookyreact.py b/bot/cogs/hacktober/spookyreact.py deleted file mode 100644 index 8e9e8db6..00000000 --- a/bot/cogs/hacktober/spookyreact.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging -import re - -import discord - -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.info(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.info(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") - return True - - return False - - -def setup(bot): - bot.add_cog(SpookyReact(bot)) diff --git a/bot/cogs/hacktober/spookysound.py b/bot/cogs/hacktober/spookysound.py deleted file mode 100644 index e1598517..00000000 --- a/bot/cogs/hacktober/spookysound.py +++ /dev/null @@ -1,46 +0,0 @@ -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import HACKTOBER_VOICE_CHANNEL_ID - - -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/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(HACKTOBER_VOICE_CHANNEL_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)) diff --git a/bot/cogs/template.py b/bot/cogs/template.py deleted file mode 100644 index e1b646e3..00000000 --- a/bot/cogs/template.py +++ /dev/null @@ -1,39 +0,0 @@ -from discord.ext import commands - - -class Template: - - """ - A template cog that contains examples of commands and command groups. - """ - - def __init__(self, bot): - self.bot = bot - - @commands.command(name='repo', aliases=['repository', 'project'], brief='A link to the repository of this bot.') - async def repository(self, ctx): - """ - A command to send the seasonalbot github project - """ - await ctx.send('https://github.com/python-discord/seasonalbot') - - @commands.group(name='git', invoke_without_command=True, brief="A link to resources for learning Git") - async def github(self, ctx): - """ - A command group with the name git. You can now create sub-commands such as git commit. - """ - - await ctx.send('Resources to learn **Git**: https://try.github.io/.') - - @github.command() - async def commit(self, ctx): - """ - A command that belongs to the git command group. Invoked using git commit. - """ - - await ctx.send('`git commit -m "First commit"` commits tracked changes.') - - -# Required in order to load the cog, use the class name in the add_cog function. -def setup(bot): - bot.add_cog(Template(bot)) diff --git a/bot/constants.py b/bot/constants.py index 7c2561a7..4294b8e1 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,73 @@ -import os +import logging +from os import environ +from typing import NamedTuple -HACKTOBER_CHANNEL_ID = 414574275865870337 -HACKTOBER_VOICE_CHANNEL_ID = 514420006474219521 -GIPHY_TOKEN = os.environ.get("GIPHY_TOKEN") +from bot.bot import SeasonalBot + +__all__ = ('Client', 'Roles', 'bot') + +log = logging.getLogger(__name__) + + +class Channels(NamedTuple): + admins = 365960823622991872 + announcements = 354619224620138496 + big_brother_logs = 468507907357409333 + bot = 267659945086812160 + checkpoint_test = 422077681434099723 + devalerts = 460181980097675264 + devlog = 409308876241108992 + devtest = 414574275865870337 + help_0 = 303906576991780866 + help_1 = 303906556754395136 + help_2 = 303906514266226689 + help_3 = 439702951246692352 + help_4 = 451312046647148554 + help_5 = 454941769734422538 + helpers = 385474242440986624 + message_log = 467752170159079424 + mod_alerts = 473092532147060736 + modlog = 282638479504965634 + off_topic_0 = 291284109232308226 + off_topic_1 = 463035241142026251 + off_topic_2 = 463035268514185226 + python = 267624335836053506 + reddit = 458224812528238616 + staff_lounge = 464905259261755392 + verification = 352442727016693763 + + +class Client(NamedTuple): + guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506)) + prefix = "." + token = environ.get('SEASONALBOT_TOKEN') + debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true' + season_override = environ.get('SEASON_OVERRIDE') + + +class Hacktoberfest(NamedTuple): + channel_id = 498804484324196362 + voice_id = 514420006474219521 + + +class Roles(NamedTuple): + admin = 267628507062992896 + announcements = 463658397560995840 + champion = 430492892331769857 + contributor = 295488872404484098 + developer = 352427296948486144 + devops = 409416496733880320 + jammer = 423054537079783434 + moderator = 267629731250176001 + muted = 277914926603829249 + owner = 267627879762755584 + verified = 352427296948486144 + helpers = 267630620367257601 + rockstars = 458226413825294336 + + +class Tokens(NamedTuple): + giphy = environ.get("GIPHY_TOKEN") + + +bot = SeasonalBot(command_prefix=Client.prefix) diff --git a/bot/decorators.py b/bot/decorators.py new file mode 100644 index 00000000..b84b2c36 --- /dev/null +++ b/bot/decorators.py @@ -0,0 +1,48 @@ +import logging + +from discord.ext import commands +from discord.ext.commands import Context + +log = logging.getLogger(__name__) + + +def with_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request.") + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.debug(f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected.") + return False + return commands.check(predicate) + + +def without_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request.") + return False + + author_roles = [role.id for role in ctx.author.roles] + check = all(role not in author_roles for role in role_ids) + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}.") + return check + return commands.check(predicate) + + +def in_channel(channel_id): + async def predicate(ctx: Context): + check = ctx.channel.id == channel_id + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the in_channel check was {check}.") + return check + return commands.check(predicate) diff --git a/bot/resources/avatars/christmas.png b/bot/resources/avatars/christmas.png new file mode 100644 index 00000000..55b72fac Binary files /dev/null and b/bot/resources/avatars/christmas.png differ diff --git a/bot/resources/avatars/spooky.png b/bot/resources/avatars/spooky.png new file mode 100644 index 00000000..4ab33188 Binary files /dev/null and b/bot/resources/avatars/spooky.png differ diff --git a/bot/resources/avatars/standard.png b/bot/resources/avatars/standard.png new file mode 100644 index 00000000..c14ff42a Binary files /dev/null and b/bot/resources/avatars/standard.png differ diff --git a/bot/resources/halloween/github_links.json b/bot/resources/halloween/github_links.json new file mode 100644 index 00000000..e69de29b diff --git a/bot/resources/halloween/monstersurvey.json b/bot/resources/halloween/monstersurvey.json index b430b6c0..d8cc72e7 100644 --- a/bot/resources/halloween/monstersurvey.json +++ b/bot/resources/halloween/monstersurvey.json @@ -10,7 +10,6 @@ "summary": "Count Dracula is an undead, centuries-old vampire, and a Transylvanian nobleman who claims to be a Sz\u00c3\u00a9kely descended from Attila the Hun. He inhabits a decaying castle in the Carpathian Mountains near the Borgo Pass. Unlike the vampires of Eastern European folklore, which are portrayed as repulsive, corpse-like creatures, Dracula wears a veneer of aristocratic charm. In his conversations with Jonathan Harker, he reveals himself as deeply proud of his boyar heritage and nostalgic for the past, which he admits have become only a memory of heroism, honour and valour in modern times.", "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/90/Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg/250px-Bela_Lugosi_as_Dracula%2C_anonymous_photograph_from_1931%2C_Universal_Studios.jpg", "votes": [ - 224734305581137921 ] }, "goofy": { @@ -24,8 +23,6 @@ "summary": "Who let this guy write this? That's who the real monster is.", "image": "https://avatars0.githubusercontent.com/u/24819750?s=460&v=4", "votes": [ - 95872159741644800, - 129606635545952258 ] } -} \ No newline at end of file +} diff --git a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 new file mode 100644 index 00000000..495f2bd1 Binary files /dev/null and b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 differ diff --git a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 new file mode 100644 index 00000000..538feabc Binary files /dev/null and b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 differ diff --git a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 new file mode 100644 index 00000000..17f66698 Binary files /dev/null and b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 differ diff --git a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 new file mode 100644 index 00000000..5670657c Binary files /dev/null and b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 differ diff --git a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 new file mode 100644 index 00000000..42f9e9fd Binary files /dev/null and b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 differ diff --git a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 new file mode 100644 index 00000000..1cdb0f4d Binary files /dev/null and b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 differ diff --git a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 new file mode 100644 index 00000000..89150d57 Binary files /dev/null and b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 differ diff --git a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 new file mode 100644 index 00000000..b5f85f8d Binary files /dev/null and b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 differ diff --git a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 new file mode 100644 index 00000000..d141f68e Binary files /dev/null and b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 differ diff --git a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 new file mode 100644 index 00000000..a0614b53 Binary files /dev/null and b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 differ diff --git a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 new file mode 100644 index 00000000..38374316 Binary files /dev/null and b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 differ diff --git a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 new file mode 100644 index 00000000..f769d9d8 Binary files /dev/null and b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 differ diff --git a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 new file mode 100644 index 00000000..8b04f0f5 Binary files /dev/null and b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 differ diff --git a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 new file mode 100644 index 00000000..964d685e Binary files /dev/null and b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 differ diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 new file mode 100644 index 00000000..9e643773 Binary files /dev/null and b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 differ diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 new file mode 100644 index 00000000..ad99cf76 Binary files /dev/null and b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 differ diff --git a/bot/resources/halloween/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt new file mode 100644 index 00000000..7df03c2e --- /dev/null +++ b/bot/resources/halloween/spookysounds/sources.txt @@ -0,0 +1,41 @@ +Female_Monster_Growls_ +Male_Zombie_Roar_ +Monster_Alien_Growl_Calm_ +Monster_Alien_Grunt_Hiss_ +https://www.youtube.com/audiolibrary/soundeffects + +413315__inspectorj__something-evil-approaches-a +https://freesound.org/people/InspectorJ/sounds/413315/ + +133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08 +https://freesound.org/people/klankbeeld/sounds/133674/ + +35716__analogchill__scream +https://freesound.org/people/analogchill/sounds/35716/ + +249686__cylon8472__cthulhu-growl +https://freesound.org/people/cylon8472/sounds/249686/ + +126113__klankbeeld__laugh +https://freesound.org/people/klankbeeld/sounds/126113/ + +14570__oscillator__ghost-fx +https://freesound.org/people/oscillator/sounds/14570/ + +60571__gabemiller74__breathofdeath +https://freesound.org/people/gabemiller74/sounds/60571/ + +168650__0xmusex0__doorcreak +https://freesound.org/people/0XMUSEX0/sounds/168650/ + +193812__geoneo0__four-voices-whispering-6 +https://freesound.org/people/geoneo0/sounds/193812/ + +109710__tomlija__horror-gate +https://freesound.org/people/Tomlija/sounds/109710/ + +171078__klankbeeld__horror-scream-woman-long +https://freesound.org/people/klankbeeld/sounds/171078/ + +237282__devilfish101__frantic-violin-screech +https://freesound.org/people/devilfish101/sounds/237282/ diff --git a/bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 deleted file mode 100644 index 495f2bd1..00000000 Binary files a/bot/resources/spookysounds/109710__tomlija__horror-gate.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 deleted file mode 100644 index 538feabc..00000000 Binary files a/bot/resources/spookysounds/126113__klankbeeld__laugh.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 deleted file mode 100644 index 17f66698..00000000 Binary files a/bot/resources/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 deleted file mode 100644 index 5670657c..00000000 Binary files a/bot/resources/spookysounds/14570__oscillator__ghost-fx.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 deleted file mode 100644 index 42f9e9fd..00000000 Binary files a/bot/resources/spookysounds/168650__0xmusex0__doorcreak.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 deleted file mode 100644 index 1cdb0f4d..00000000 Binary files a/bot/resources/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 deleted file mode 100644 index 89150d57..00000000 Binary files a/bot/resources/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 deleted file mode 100644 index b5f85f8d..00000000 Binary files a/bot/resources/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 deleted file mode 100644 index d141f68e..00000000 Binary files a/bot/resources/spookysounds/249686__cylon8472__cthulhu-growl.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/spookysounds/35716__analogchill__scream.mp3 deleted file mode 100644 index a0614b53..00000000 Binary files a/bot/resources/spookysounds/35716__analogchill__scream.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 deleted file mode 100644 index 38374316..00000000 Binary files a/bot/resources/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 deleted file mode 100644 index f769d9d8..00000000 Binary files a/bot/resources/spookysounds/60571__gabemiller74__breathofdeath.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/spookysounds/Female_Monster_Growls_.mp3 deleted file mode 100644 index 8b04f0f5..00000000 Binary files a/bot/resources/spookysounds/Female_Monster_Growls_.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/spookysounds/Male_Zombie_Roar_.mp3 deleted file mode 100644 index 964d685e..00000000 Binary files a/bot/resources/spookysounds/Male_Zombie_Roar_.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 deleted file mode 100644 index 9e643773..00000000 Binary files a/bot/resources/spookysounds/Monster_Alien_Growl_Calm_.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 deleted file mode 100644 index ad99cf76..00000000 Binary files a/bot/resources/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 and /dev/null differ diff --git a/bot/resources/spookysounds/sources.txt b/bot/resources/spookysounds/sources.txt deleted file mode 100644 index 7df03c2e..00000000 --- a/bot/resources/spookysounds/sources.txt +++ /dev/null @@ -1,41 +0,0 @@ -Female_Monster_Growls_ -Male_Zombie_Roar_ -Monster_Alien_Growl_Calm_ -Monster_Alien_Grunt_Hiss_ -https://www.youtube.com/audiolibrary/soundeffects - -413315__inspectorj__something-evil-approaches-a -https://freesound.org/people/InspectorJ/sounds/413315/ - -133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08 -https://freesound.org/people/klankbeeld/sounds/133674/ - -35716__analogchill__scream -https://freesound.org/people/analogchill/sounds/35716/ - -249686__cylon8472__cthulhu-growl -https://freesound.org/people/cylon8472/sounds/249686/ - -126113__klankbeeld__laugh -https://freesound.org/people/klankbeeld/sounds/126113/ - -14570__oscillator__ghost-fx -https://freesound.org/people/oscillator/sounds/14570/ - -60571__gabemiller74__breathofdeath -https://freesound.org/people/gabemiller74/sounds/60571/ - -168650__0xmusex0__doorcreak -https://freesound.org/people/0XMUSEX0/sounds/168650/ - -193812__geoneo0__four-voices-whispering-6 -https://freesound.org/people/geoneo0/sounds/193812/ - -109710__tomlija__horror-gate -https://freesound.org/people/Tomlija/sounds/109710/ - -171078__klankbeeld__horror-scream-woman-long -https://freesound.org/people/klankbeeld/sounds/171078/ - -237282__devilfish101__frantic-violin-screech -https://freesound.org/people/devilfish101/sounds/237282/ 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() diff --git a/bot/utils/halloween/__init__.py b/bot/utils/halloween/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py new file mode 100644 index 00000000..5f2369ae --- /dev/null +++ b/bot/utils/halloween/spookifications.py @@ -0,0 +1,55 @@ +import logging +from random import choice, randint + +from PIL import Image +from PIL import ImageOps + +log = logging.getLogger() + + +def inversion(im): + """Inverts an image. + + Returns an inverted image when supplied with an Image object. + """ + im = im.convert('RGB') + inv = ImageOps.invert(im) + return inv + + +def pentagram(im): + """Adds pentagram to image.""" + im = im.convert('RGB') + wt, ht = im.size + penta = Image.open('bot/resources/halloween/bloody-pentagram.png') + penta = penta.resize((wt, ht)) + im.paste(penta, (0, 0), penta) + return im + + +def bat(im): + """Adds a bat silhoutte to the image. + + The bat silhoutte is of a size at least one-fifths that of the original + image and may be rotated upto 90 degrees anti-clockwise.""" + im = im.convert('RGB') + wt, ht = im.size + bat = Image.open('bot/resources/halloween/bat-clipart.png') + bat_size = randint(wt//10, wt//7) + rot = randint(0, 90) + bat = bat.resize((bat_size, bat_size)) + bat = bat.rotate(rot) + x = randint(wt-(bat_size * 3), wt-bat_size) + y = randint(10, bat_size) + im.paste(bat, (x, y), bat) + im.paste(bat, (x + bat_size, y + (bat_size // 4)), bat) + im.paste(bat, (x - bat_size, y - (bat_size // 2)), bat) + return im + + +def get_random_effect(im): + """Randomly selects and applies an effect.""" + effects = [inversion, pentagram, bat] + effect = choice(effects) + log.info("Spookyavatar's chosen effect: " + effect.__name__) + return effect(im) diff --git a/bot/utils/spookifications.py b/bot/utils/spookifications.py deleted file mode 100644 index 5f2369ae..00000000 --- a/bot/utils/spookifications.py +++ /dev/null @@ -1,55 +0,0 @@ -import logging -from random import choice, randint - -from PIL import Image -from PIL import ImageOps - -log = logging.getLogger() - - -def inversion(im): - """Inverts an image. - - Returns an inverted image when supplied with an Image object. - """ - im = im.convert('RGB') - inv = ImageOps.invert(im) - return inv - - -def pentagram(im): - """Adds pentagram to image.""" - im = im.convert('RGB') - wt, ht = im.size - penta = Image.open('bot/resources/halloween/bloody-pentagram.png') - penta = penta.resize((wt, ht)) - im.paste(penta, (0, 0), penta) - return im - - -def bat(im): - """Adds a bat silhoutte to the image. - - The bat silhoutte is of a size at least one-fifths that of the original - image and may be rotated upto 90 degrees anti-clockwise.""" - im = im.convert('RGB') - wt, ht = im.size - bat = Image.open('bot/resources/halloween/bat-clipart.png') - bat_size = randint(wt//10, wt//7) - rot = randint(0, 90) - bat = bat.resize((bat_size, bat_size)) - bat = bat.rotate(rot) - x = randint(wt-(bat_size * 3), wt-bat_size) - y = randint(10, bat_size) - im.paste(bat, (x, y), bat) - im.paste(bat, (x + bat_size, y + (bat_size // 4)), bat) - im.paste(bat, (x - bat_size, y - (bat_size // 2)), bat) - return im - - -def get_random_effect(im): - """Randomly selects and applies an effect.""" - effects = [inversion, pentagram, bat] - effect = choice(effects) - log.info("Spookyavatar's chosen effect: " + effect.__name__) - return effect(im) -- cgit v1.2.3