diff options
Diffstat (limited to 'bot/cogs/hacktober')
-rw-r--r-- | bot/cogs/hacktober/__init__.py | 0 | ||||
-rw-r--r-- | bot/cogs/hacktober/candy_collection.py | 229 | ||||
-rw-r--r-- | bot/cogs/hacktober/hacktoberstats.py | 326 | ||||
-rw-r--r-- | bot/cogs/hacktober/halloween_facts.py | 76 | ||||
-rw-r--r-- | bot/cogs/hacktober/halloweenify.py | 48 | ||||
-rw-r--r-- | bot/cogs/hacktober/monstersurvey.py | 191 | ||||
-rw-r--r-- | bot/cogs/hacktober/movie.py | 136 | ||||
-rw-r--r-- | bot/cogs/hacktober/spookyavatar.py | 52 | ||||
-rw-r--r-- | bot/cogs/hacktober/spookyreact.py | 69 | ||||
-rw-r--r-- | bot/cogs/hacktober/spookysound.py | 46 |
10 files changed, 1173 insertions, 0 deletions
diff --git a/bot/cogs/hacktober/__init__.py b/bot/cogs/hacktober/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/bot/cogs/hacktober/__init__.py diff --git a/bot/cogs/hacktober/candy_collection.py b/bot/cogs/hacktober/candy_collection.py new file mode 100644 index 00000000..f5f17abb --- /dev/null +++ b/bot/cogs/hacktober/candy_collection.py @@ -0,0 +1,229 @@ +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 new file mode 100644 index 00000000..0755503c --- /dev/null +++ b/bot/cogs/hacktober/hacktoberstats.py @@ -0,0 +1,326 @@ +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 + """ + 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 new file mode 100644 index 00000000..bd164e30 --- /dev/null +++ b/bot/cogs/hacktober/halloween_facts.py @@ -0,0 +1,76 @@ +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 + HACKTOBERFEST_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 new file mode 100644 index 00000000..9b93ac99 --- /dev/null +++ b/bot/cogs/hacktober/halloweenify.py @@ -0,0 +1,48 @@ +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! + """ + 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 new file mode 100644 index 00000000..45587fe1 --- /dev/null +++ b/bot/cogs/hacktober/monstersurvey.py @@ -0,0 +1,191 @@ +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: + 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())}") + return 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 + vote_embed = Embed( + name='Monster Voting', + color=0xFF6800 + ) + if isinstance(name, int): + name = self.get_name_by_leaderboard_index(name) + else: + name = name.lower() + 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())}" + ) + return await ctx.send(embed=vote_embed) + 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() + return 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 + if isinstance(name, int): + m = self.voter_registry.get(self.get_name_by_leaderboard_index(name)) + else: + name = name.lower() + m = self.voter_registry.get(name) + if not m: + await ctx.send('That monster does not exist.') + return await ctx.invoke(self.monster_vote) + 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}') + return 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: + """ + vr = self.voter_registry + top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) + + embed = Embed(title="Monster Survey Leader Board", color=0xFF6800) + total_votes = sum(len(m['votes']) for m in self.voter_registry.values()) + 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/movie.py b/bot/cogs/hacktober/movie.py new file mode 100644 index 00000000..925f813f --- /dev/null +++ b/bot/cogs/hacktober/movie.py @@ -0,0 +1,136 @@ +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 Movie: + """ + 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. + """ + 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(Movie(bot)) diff --git a/bot/cogs/hacktober/spookyavatar.py b/bot/cogs/hacktober/spookyavatar.py new file mode 100644 index 00000000..ad8a9242 --- /dev/null +++ b/bot/cogs/hacktober/spookyavatar.py @@ -0,0 +1,52 @@ +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 + + 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/spookyreact.py b/bot/cogs/hacktober/spookyreact.py new file mode 100644 index 00000000..8e9e8db6 --- /dev/null +++ b/bot/cogs/hacktober/spookyreact.py @@ -0,0 +1,69 @@ +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 new file mode 100644 index 00000000..e1598517 --- /dev/null +++ b/bot/cogs/hacktober/spookysound.py @@ -0,0 +1,46 @@ +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)) |