aboutsummaryrefslogtreecommitdiffstats
path: root/bot/cogs
diff options
context:
space:
mode:
Diffstat (limited to 'bot/cogs')
-rw-r--r--bot/cogs/__init__.py0
-rw-r--r--bot/cogs/error_handler.py106
-rw-r--r--bot/cogs/evergreen/__init__.py0
-rw-r--r--bot/cogs/evergreen/uptime.py33
-rw-r--r--bot/cogs/hacktober/__init__.py0
-rw-r--r--bot/cogs/hacktober/candy_collection.py229
-rw-r--r--bot/cogs/hacktober/hacktoberstats.py326
-rw-r--r--bot/cogs/hacktober/halloween_facts.py (renamed from bot/cogs/halloween_facts.py)7
-rw-r--r--bot/cogs/hacktober/halloweenify.py (renamed from bot/cogs/halloweenify.py)2
-rw-r--r--bot/cogs/hacktober/monstersurvey.py191
-rw-r--r--bot/cogs/hacktober/movie.py (renamed from bot/cogs/movie.py)0
-rw-r--r--bot/cogs/hacktober/spookyavatar.py52
-rw-r--r--bot/cogs/hacktober/spookyreact.py69
-rw-r--r--bot/cogs/hacktober/spookysound.py (renamed from bot/cogs/spookysound.py)11
-rw-r--r--bot/cogs/hacktoberstats.py193
-rw-r--r--bot/cogs/spookyreact.py31
-rw-r--r--bot/cogs/template.py4
17 files changed, 1019 insertions, 235 deletions
diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/cogs/__init__.py
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
new file mode 100644
index 00000000..79780251
--- /dev/null
+++ b/bot/cogs/error_handler.py
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/cogs/evergreen/__init__.py
diff --git a/bot/cogs/evergreen/uptime.py b/bot/cogs/evergreen/uptime.py
new file mode 100644
index 00000000..ec4a3083
--- /dev/null
+++ b/bot/cogs/evergreen/uptime.py
@@ -0,0 +1,33 @@
+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/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/halloween_facts.py b/bot/cogs/hacktober/halloween_facts.py
index e97c80d2..bd164e30 100644
--- a/bot/cogs/halloween_facts.py
+++ b/bot/cogs/hacktober/halloween_facts.py
@@ -7,6 +7,8 @@ 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}",
@@ -18,7 +20,6 @@ SPOOKY_EMOJIS = [
"\N{SPIDER WEB}",
]
PUMPKIN_ORANGE = discord.Color(0xFF7518)
-HACKTOBERBOT_CHANNEL_ID = 498804484324196362
INTERVAL = timedelta(hours=6).total_seconds()
@@ -26,13 +27,13 @@ class HalloweenFacts:
def __init__(self, bot):
self.bot = bot
- with open(Path("./bot/resources", "halloween_facts.json"), "r") as file:
+ 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(HACKTOBERBOT_CHANNEL_ID)
+ self.channel = self.bot.get_channel(HACKTOBER_CHANNEL_ID)
self.bot.loop.create_task(self._fact_publisher_task())
async def _fact_publisher_task(self):
diff --git a/bot/cogs/halloweenify.py b/bot/cogs/hacktober/halloweenify.py
index a5fe45ef..9b93ac99 100644
--- a/bot/cogs/halloweenify.py
+++ b/bot/cogs/hacktober/halloweenify.py
@@ -21,7 +21,7 @@ class Halloweenify:
"""
Change your nickname into a much spookier one!
"""
- with open(Path('./bot/resources', 'halloweenify.json'), 'r') as f:
+ 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.
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/movie.py b/bot/cogs/hacktober/movie.py
index 925f813f..925f813f 100644
--- a/bot/cogs/movie.py
+++ b/bot/cogs/hacktober/movie.py
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/spookysound.py b/bot/cogs/hacktober/spookysound.py
index dd607097..e1598517 100644
--- a/bot/cogs/spookysound.py
+++ b/bot/cogs/hacktober/spookysound.py
@@ -4,7 +4,7 @@ from pathlib import Path
import discord
from discord.ext import commands
-HACKTOBERBOT_VOICE_CHANNEL_ID = 498804789287714816
+from bot.constants import HACKTOBER_VOICE_CHANNEL_ID
class SpookySound:
@@ -17,16 +17,17 @@ class SpookySound:
self.sound_files = list(Path("./bot/resources/spookysounds").glob("*.mp3"))
self.channel = None
- async def on_ready(self):
- self.channel = self.bot.get_channel(HACKTOBERBOT_VOICE_CHANNEL_ID)
-
- @commands.cooldown(rate=1, per=120)
+ @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()))
diff --git a/bot/cogs/hacktoberstats.py b/bot/cogs/hacktoberstats.py
deleted file mode 100644
index ac81b887..00000000
--- a/bot/cogs/hacktoberstats.py
+++ /dev/null
@@ -1,193 +0,0 @@
-import re
-import typing
-from collections import Counter
-from datetime import datetime
-
-import aiohttp
-import discord
-from discord.ext import commands
-
-
-class Stats:
- def __init__(self, bot):
- self.bot = bot
-
- @commands.command(
- name="stats",
- aliases=["getstats", "userstats"],
- brief="Get a user's Hacktoberfest contribution stats",
- )
- async def get_stats(self, ctx, 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 username is provided, an embed is generated and posted to the channel
-
- Otherwise, post a helpful error message
-
- The first input argument is treated as the username, any additional inputs are discarded
- """
- prs = await self.get_october_prs(username)
-
- if prs:
- stats_embed = self.build_embed(username, prs)
- await ctx.send('Here are some stats!', embed=stats_embed)
- else:
- await ctx.send(f"No October GitHub contributions found for '{username}'")
-
- def build_embed(self, username: str, prs: typing.List[dict]) -> discord.Embed:
- """
- Return a stats embed built from username's PRs
- """
- pr_stats = self._summarize_prs(prs)
-
- n = pr_stats['n_prs']
- if n >= 5:
- shirtstr = f"**{username} has earned a tshirt!**"
- elif n == 4:
- shirtstr = f"**{username} is 1 PR away from a tshirt!**"
- else:
- shirtstr = f"**{username} is {5 - n} PRs away from a tshirt!**"
-
- stats_embed = discord.Embed(
- title=f"{username}'s Hacktoberfest",
- color=discord.Color(0x9c4af7),
- description=f"{username} has made {n} {Stats._contributionator(n)} in October\n\n{shirtstr}\n\n"
- )
-
- stats_embed.set_thumbnail(url=f"https://www.github.com/{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)
- )
-
- return stats_embed
-
- @staticmethod
- async def get_october_prs(username: str) -> typing.List[dict]:
- """
- Query GitHub's API for PRs created during the month of October by 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. "discord-python/hacktoberbot")
- "created_at": datetime.datetime
- }
-
- Otherwise, return None
- """
- base_url = "https://api.github.com/search/issues?q="
- not_label = "invalid"
- action_type = "pr"
- is_query = f"public+author:{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
- # In the future, log: jsonresp["errors"][0]["message"]
- return
- else:
- if jsonresp["total_count"] == 0:
- # Short circuit if there aren't any PRs
- return
- else:
- 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/discord-python/hacktoberbot"
- |
- V
- "discord-python/hacktoberbot"
- """
- 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"
-
-
-def setup(bot):
- bot.add_cog(Stats(bot))
diff --git a/bot/cogs/spookyreact.py b/bot/cogs/spookyreact.py
deleted file mode 100644
index 2652a60e..00000000
--- a/bot/cogs/spookyreact.py
+++ /dev/null
@@ -1,31 +0,0 @@
-SPOOKY_TRIGGERS = {
- 'spooky': "\U0001F47B",
- 'skeleton': "\U0001F480",
- 'doot': "\U0001F480",
- 'pumpkin': "\U0001F383",
- 'halloween': "\U0001F383",
- 'jack-o-lantern': "\U0001F383",
- 'danger': "\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):
- """
- A command to send the hacktoberbot github project
- """
- for trigger in SPOOKY_TRIGGERS.keys():
- if trigger in ctx.content.lower():
- await ctx.add_reaction(SPOOKY_TRIGGERS[trigger])
-
-
-def setup(bot):
- bot.add_cog(SpookyReact(bot))
diff --git a/bot/cogs/template.py b/bot/cogs/template.py
index aa01432c..e1b646e3 100644
--- a/bot/cogs/template.py
+++ b/bot/cogs/template.py
@@ -13,9 +13,9 @@ class Template:
@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 hacktoberbot github project
+ A command to send the seasonalbot github project
"""
- await ctx.send('https://github.com/discord-python/hacktoberbot')
+ 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):