aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/halloween
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/halloween')
-rw-r--r--bot/exts/halloween/8ball.py34
-rw-r--r--bot/exts/halloween/__init__.py0
-rw-r--r--bot/exts/halloween/candy_collection.py225
-rw-r--r--bot/exts/halloween/hacktober-issue-finder.py111
-rw-r--r--bot/exts/halloween/hacktoberstats.py342
-rw-r--r--bot/exts/halloween/halloween_facts.py59
-rw-r--r--bot/exts/halloween/halloweenify.py52
-rw-r--r--bot/exts/halloween/monsterbio.py56
-rw-r--r--bot/exts/halloween/monstersurvey.py206
-rw-r--r--bot/exts/halloween/scarymovie.py132
-rw-r--r--bot/exts/halloween/spookyavatar.py53
-rw-r--r--bot/exts/halloween/spookygif.py39
-rw-r--r--bot/exts/halloween/spookyrating.py67
-rw-r--r--bot/exts/halloween/spookyreact.py76
-rw-r--r--bot/exts/halloween/spookysound.py48
-rw-r--r--bot/exts/halloween/timeleft.py60
16 files changed, 1560 insertions, 0 deletions
diff --git a/bot/exts/halloween/8ball.py b/bot/exts/halloween/8ball.py
new file mode 100644
index 00000000..2e1c2804
--- /dev/null
+++ b/bot/exts/halloween/8ball.py
@@ -0,0 +1,34 @@
+import asyncio
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f:
+ responses = json.load(f)
+
+
+class SpookyEightBall(commands.Cog):
+ """Spooky Eightball answers."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=('spooky8ball',))
+ async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None:
+ """Responds with a random response to a question."""
+ choice = random.choice(responses['responses'])
+ msg = await ctx.send(choice[0])
+ if len(choice) > 1:
+ await asyncio.sleep(random.randint(2, 5))
+ await msg.edit(content=f"{choice[0]} \n{choice[1]}")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky Eight Ball Cog Load."""
+ bot.add_cog(SpookyEightBall(bot))
+ log.info("SpookyEightBall cog loaded")
diff --git a/bot/exts/halloween/__init__.py b/bot/exts/halloween/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/halloween/__init__.py
diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
new file mode 100644
index 00000000..3f2b895e
--- /dev/null
+++ b/bot/exts/halloween/candy_collection.py
@@ -0,0 +1,225 @@
+import functools
+import json
+import logging
+import os
+import random
+from typing import List, Union
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Month
+from bot.utils.decorators import in_month
+
+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(commands.Cog):
+ """Candy collection game Cog."""
+
+ def __init__(self, bot: commands.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
+
+ @in_month(Month.october)
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
+ # make sure its a human message
+ if message.author.bot:
+ return
+ # ensure it's hacktober channel
+ if message.channel.id != Channels.seasonalbot_commands:
+ 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}')
+
+ @in_month(Month.october)
+ @commands.Cog.listener()
+ async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
+ """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 != Channels.seasonalbot_commands:
+ 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: discord.Message) -> None:
+ """
+ Randomly add a skull or candy reaction to a message if there is a reaction there already.
+
+ This event has a higher probability of occurring than a reaction add to a message without an
+ existing reaction.
+ """
+ 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) -> List[int]:
+ """Get the last 10 messages sent in the channel."""
+ ten_recent = []
+ recent_msg_id = max(
+ message.id for message in self.bot._connection._messages
+ if message.channel.id == Channels.seasonalbot_commands
+ )
+
+ 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: int) -> Union[discord.Message, None]:
+ """Get the message from its 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) -> discord.TextChannel:
+ """Get #hacktoberbot channel from its ID."""
+ return self.bot.get_channel(id=Channels.seasonalbot_commands)
+
+ async def remove_reactions(self, reaction: discord.Reaction) -> None:
+ """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: discord.Member, channel: discord.TextChannel, candies: int) -> None:
+ """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) -> None:
+ """Save JSON to a local file."""
+ with open(json_location, 'w') as outfile:
+ json.dump(self.candy_json, outfile)
+
+ @in_month(Month.october)
+ @commands.command()
+ async def candy(self, ctx: commands.Context) -> None:
+ """Get the candy leaderboard and save to JSON."""
+ # 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: commands.Bot) -> None:
+ """Candy Collection game Cog load."""
+ bot.add_cog(CandyCollection(bot))
+ log.info("CandyCollection cog loaded")
diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py
new file mode 100644
index 00000000..f15a665a
--- /dev/null
+++ b/bot/exts/halloween/hacktober-issue-finder.py
@@ -0,0 +1,111 @@
+import datetime
+import logging
+import random
+from typing import Dict, Optional
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open"
+HEADERS = {"Accept": "application / vnd.github.v3 + json"}
+
+
+class HacktoberIssues(commands.Cog):
+ """Find a random hacktober python issue on GitHub."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.cache_normal = None
+ self.cache_timer_normal = datetime.datetime(1, 1, 1)
+ self.cache_beginner = None
+ self.cache_timer_beginner = datetime.datetime(1, 1, 1)
+
+ @in_month(Month.october)
+ @commands.command()
+ async def hacktoberissues(self, ctx: commands.Context, option: str = "") -> None:
+ """
+ Get a random python hacktober issue from Github.
+
+ If the command is run with beginner (`.hacktoberissues beginner`):
+ It will also narrow it down to the "first good issue" label.
+ """
+ with ctx.typing():
+ issues = await self.get_issues(ctx, option)
+ if issues is None:
+ return
+ issue = random.choice(issues["items"])
+ embed = self.format_embed(issue)
+ await ctx.send(embed=embed)
+
+ async def get_issues(self, ctx: commands.Context, option: str) -> Optional[Dict]:
+ """Get a list of the python issues with the label 'hacktoberfest' from the Github api."""
+ if option == "beginner":
+ if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60:
+ log.debug("using cache")
+ return self.cache_beginner
+ elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60:
+ log.debug("using cache")
+ return self.cache_normal
+
+ async with aiohttp.ClientSession() as session:
+ if option == "beginner":
+ url = URL + '+label:"good first issue"'
+ if self.cache_beginner is not None:
+ page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100)
+ url += f"&page={page}"
+ else:
+ url = URL
+ if self.cache_normal is not None:
+ page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100)
+ url += f"&page={page}"
+
+ log.debug(f"making api request to url: {url}")
+ async with session.get(url, headers=HEADERS) as response:
+ if response.status != 200:
+ log.error(f"expected 200 status (got {response.status}) from the GitHub api.")
+ await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.")
+ await ctx.send(await response.text())
+ return None
+ data = await response.json()
+
+ if len(data["items"]) == 0:
+ log.error(f"no issues returned from GitHub api. with url: {response.url}")
+ await ctx.send(f"ERROR: no issues returned from GitHub api. with url: {response.url}")
+ return None
+
+ if option == "beginner":
+ self.cache_beginner = data
+ self.cache_timer_beginner = ctx.message.created_at
+ else:
+ self.cache_normal = data
+ self.cache_timer_normal = ctx.message.created_at
+
+ return data
+
+ @staticmethod
+ def format_embed(issue: Dict) -> discord.Embed:
+ """Format the issue data into a embed."""
+ title = issue["title"]
+ issue_url = issue["url"].replace("api.", "").replace("/repos/", "/")
+ body = issue["body"]
+ labels = [label["name"] for label in issue["labels"]]
+
+ embed = discord.Embed(title=title)
+ embed.description = body
+ embed.add_field(name="labels", value="\n".join(labels))
+ embed.url = issue_url
+ embed.set_footer(text=issue_url)
+
+ return embed
+
+
+def setup(bot: commands.Bot) -> None:
+ """Hacktober issue finder Cog Load."""
+ bot.add_cog(HacktoberIssues(bot))
+ log.info("hacktober-issue-finder cog loaded")
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
new file mode 100644
index 00000000..5dfa2f51
--- /dev/null
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -0,0 +1,342 @@
+import json
+import logging
+import re
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+from typing import List, Tuple
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels, Month, WHITELISTED_CHANNELS
+from bot.utils.decorators import in_month, override_in_channel
+from bot.utils.persist import make_persistent
+
+log = logging.getLogger(__name__)
+
+CURRENT_YEAR = datetime.now().year # Used to construct GH API query
+PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded
+HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,)
+
+
+class HacktoberStats(commands.Cog):
+ """Hacktoberfest statistics Cog."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json"))
+ self.linked_accounts = self.load_linked_users()
+
+ @in_month(Month.october)
+ @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
+ """
+ Display an embed for a user's Hacktoberfest contributions.
+
+ 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}hackstats link github_username```\n"
+ f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```"
+ )
+ await ctx.send(msg)
+ return
+
+ await self.get_stats(ctx, github_username)
+
+ @in_month(Month.october)
+ @hacktoberstats_group.command(name="link")
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def link_user(self, ctx: commands.Context, github_username: str = None) -> 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")
+
+ @in_month(Month.october)
+ @hacktoberstats_group.command(name="unlink")
+ @override_in_channel(HACKTOBER_WHITELIST)
+ async def unlink_user(self, ctx: commands.Context) -> None:
+ """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) -> 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 file:
+ linked_accounts = json.load(file)
+
+ 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) -> None:
+ """
+ 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 file:
+ json.dump(self.linked_accounts, file, default=str)
+ logging.info(f"linked_accounts saved to '{self.link_json}'")
+
+ async def get_stats(self, ctx: commands.Context, github_username: str) -> None:
+ """
+ Query GitHub's API for PRs created by a GitHub user during the month of October.
+
+ PRs with the 'invalid' tag are ignored
+
+ 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: 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 >= PRS_FOR_SHIRT:
+ shirtstr = f"**{github_username} has earned a tshirt!**"
+ elif n == PRS_FOR_SHIRT - 1:
+ shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
+ else:
+ shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - 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/pretty_logo.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) -> List[dict]:
+ """
+ Query GitHub's API for PRs created during the month of October by github_username.
+
+ PRs with an 'invalid' tag are ignored
+
+ 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}"
+ not_query = "draft"
+ date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00"
+ per_page = "300"
+ query_url = (
+ f"{base_url}"
+ f"-label:{not_label}"
+ f"+type:{action_type}"
+ f"+is:{is_query}"
+ f"+-is:{not_query}"
+ f"+created:{date_range}"
+ f"&per_page={per_page}"
+ )
+
+ headers = {"user-agent": "Discord Python Hacktoberbot"}
+ 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: List[dict]) -> 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: 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)
+ ...
+ """
+ base_url = "https://www.github.com/"
+ contributionstrs = []
+ for repo in stats['top5']:
+ n = repo[1]
+ contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{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) -> 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: commands.Bot) -> None:
+ """Hacktoberstats Cog load."""
+ bot.add_cog(HacktoberStats(bot))
+ log.info("HacktoberStats cog loaded")
diff --git a/bot/exts/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py
new file mode 100644
index 00000000..222768f4
--- /dev/null
+++ b/bot/exts/halloween/halloween_facts.py
@@ -0,0 +1,59 @@
+import json
+import logging
+import random
+from datetime import timedelta
+from pathlib import Path
+from typing import Tuple
+
+import discord
+from discord.ext import commands
+
+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(commands.Cog):
+ """A Cog for displaying interesting facts about Halloween."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file:
+ self.halloween_facts = json.load(file)
+ self.facts = list(enumerate(self.halloween_facts))
+ random.shuffle(self.facts)
+
+ def random_fact(self) -> Tuple[int, str]:
+ """Return a random fact from the loaded facts."""
+ 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: commands.Context) -> None:
+ """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: int, fact: str) -> discord.Embed:
+ """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: commands.Bot) -> None:
+ """Halloween facts Cog load."""
+ bot.add_cog(HalloweenFacts(bot))
+ log.info("HalloweenFacts cog loaded")
diff --git a/bot/exts/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py
new file mode 100644
index 00000000..dfcc2b1e
--- /dev/null
+++ b/bot/exts/halloween/halloweenify.py
@@ -0,0 +1,52 @@
+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(commands.Cog):
+ """A cog to change a invokers nickname to a spooky one!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.cooldown(1, 300, BucketType.user)
+ @commands.command()
+ async def halloweenify(self, ctx: commands.Context) -> None:
+ """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: commands.Bot) -> None:
+ """Halloweenify Cog load."""
+ bot.add_cog(Halloweenify(bot))
+ log.info("Halloweenify cog loaded")
diff --git a/bot/exts/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py
new file mode 100644
index 00000000..bfa8a026
--- /dev/null
+++ b/bot/exts/halloween/monsterbio.py
@@ -0,0 +1,56 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f:
+ TEXT_OPTIONS = json.load(f) # Data for a mad-lib style generation of text
+
+
+class MonsterBio(commands.Cog):
+ """A cog that generates a spooky monster biography."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def generate_name(self, seeded_random: random.Random) -> str:
+ """Generates a name (for either monster species or monster name)."""
+ n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"]))
+ return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings))
+
+ @commands.command(brief="Sends your monster bio!")
+ async def monsterbio(self, ctx: commands.Context) -> None:
+ """Sends a description of a monster."""
+ seeded_random = random.Random(ctx.message.author.id) # Seed a local Random instance rather than the system one
+
+ name = self.generate_name(seeded_random)
+ species = self.generate_name(seeded_random)
+ biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"])
+ words = {"monster_name": name, "monster_species": species}
+ for key, value in biography_text.items():
+ if key == "text":
+ continue
+
+ options = seeded_random.sample(TEXT_OPTIONS[key], value)
+ words[key] = ' '.join(options)
+
+ embed = discord.Embed(
+ title=f"{name}'s Biography",
+ color=seeded_random.choice([Colours.orange, Colours.purple]),
+ description=biography_text["text"].format_map(words),
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Monster bio Cog load."""
+ bot.add_cog(MonsterBio(bot))
+ log.info("MonsterBio cog loaded.")
diff --git a/bot/exts/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py
new file mode 100644
index 00000000..12e1d022
--- /dev/null
+++ b/bot/exts/halloween/monstersurvey.py
@@ -0,0 +1,206 @@
+import json
+import logging
+import os
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context
+
+log = logging.getLogger(__name__)
+
+EMOJIS = {
+ 'SUCCESS': u'\u2705',
+ 'ERROR': u'\u274C'
+}
+
+
+class MonsterSurvey(Cog):
+ """
+ Vote for your favorite monster.
+
+ This Cog 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) -> None:
+ """Write voting results to a local JSON file."""
+ 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) -> None:
+ """
+ Cast a user's vote for the specified monster.
+
+ If the user has already voted, their existing vote is removed.
+ """
+ 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: int) -> str:
+ """Return the monster at the specified leaderboard index."""
+ 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=('mon',)
+ )
+ async def monster_group(self, ctx: Context) -> None:
+ """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: str = None) -> None:
+ """
+ Cast a vote for a particular monster.
+
+ Displays a list of monsters that can be voted for if one is not specified.
+ """
+ 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: str = None) -> None:
+ """Shows the named monster. If one is not named, it sends the default voting embed instead."""
+ 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) -> None:
+ """Shows the current standings."""
+ 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) -> None:
+ """Monster survey Cog load."""
+ bot.add_cog(MonsterSurvey(bot))
+ log.info("MonsterSurvey cog loaded")
diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py
new file mode 100644
index 00000000..3823a3e4
--- /dev/null
+++ b/bot/exts/halloween/scarymovie.py
@@ -0,0 +1,132 @@
+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(commands.Cog):
+ """Selects a random scary movie and embeds info into Discord chat."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name='scarymovie', alias=['smovie'])
+ async def random_movie(self, ctx: commands.Context) -> None:
+ """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() -> dict:
+ """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: dict) -> Embed:
+ """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: commands.Bot) -> None:
+ """Scary movie Cog load."""
+ bot.add_cog(ScaryMovie(bot))
+ log.info("ScaryMovie cog loaded")
diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py
new file mode 100644
index 00000000..268de3fb
--- /dev/null
+++ b/bot/exts/halloween/spookyavatar.py
@@ -0,0 +1,53 @@
+import logging
+import os
+from io import BytesIO
+
+import aiohttp
+import discord
+from PIL import Image
+from discord.ext import commands
+
+from bot.utils.halloween import spookifications
+
+log = logging.getLogger(__name__)
+
+
+class SpookyAvatar(commands.Cog):
+ """A cog that spookifies an avatar."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ async def get(self, url: str) -> bytes:
+ """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: commands.Context, user: discord.Member = None) -> 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)
+
+ image_bytes = await ctx.author.avatar_url.read()
+ im = Image.open(BytesIO(image_bytes))
+ 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: commands.Bot) -> None:
+ """Spooky avatar Cog load."""
+ bot.add_cog(SpookyAvatar(bot))
+ log.info("SpookyAvatar cog loaded")
diff --git a/bot/exts/halloween/spookygif.py b/bot/exts/halloween/spookygif.py
new file mode 100644
index 00000000..818de8cd
--- /dev/null
+++ b/bot/exts/halloween/spookygif.py
@@ -0,0 +1,39 @@
+import logging
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+from bot.constants import Tokens
+
+log = logging.getLogger(__name__)
+
+
+class SpookyGif(commands.Cog):
+ """A cog to fetch a random spooky gif from the web!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="spookygif", aliases=("sgif", "scarygif"))
+ async def spookygif(self, ctx: commands.Context) -> None:
+ """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: commands.Bot) -> None:
+ """Spooky GIF Cog load."""
+ bot.add_cog(SpookyGif(bot))
+ log.info("SpookyGif cog loaded")
diff --git a/bot/exts/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py
new file mode 100644
index 00000000..7f78f536
--- /dev/null
+++ b/bot/exts/halloween/spookyrating.py
@@ -0,0 +1,67 @@
+import bisect
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with Path("bot/resources/halloween/spooky_rating.json").open() as file:
+ SPOOKY_DATA = json.load(file)
+ SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items())
+
+
+class SpookyRating(commands.Cog):
+ """A cog for calculating one's spooky rating."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.local_random = random.Random()
+
+ @commands.command()
+ @commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
+ async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None:
+ """
+ Calculates the spooky rating of someone.
+
+ Any user will always yield the same result, no matter who calls the command
+ """
+ if who is None:
+ who = ctx.author
+
+ # This ensures that the same result over multiple runtimes
+ self.local_random.seed(who.id)
+ spooky_percent = self.local_random.randint(1, 101)
+
+ # We need the -1 due to how bisect returns the point
+ # see the documentation for further detail
+ # https://docs.python.org/3/library/bisect.html#bisect.bisect
+ index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1
+
+ _, data = SPOOKY_DATA[index]
+
+ embed = discord.Embed(
+ title=data['title'],
+ description=f'{who} scored {spooky_percent}%!',
+ color=Colours.orange
+ )
+ embed.add_field(
+ name='A whisper from Satan',
+ value=data['text']
+ )
+ embed.set_thumbnail(
+ url=data['image']
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky Rating Cog load."""
+ bot.add_cog(SpookyRating(bot))
+ log.info("SpookyRating cog loaded")
diff --git a/bot/exts/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py
new file mode 100644
index 00000000..16f18019
--- /dev/null
+++ b/bot/exts/halloween/spookyreact.py
@@ -0,0 +1,76 @@
+import logging
+import re
+
+import discord
+from discord.ext.commands import Bot, Cog
+
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
+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(Cog):
+ """A cog that makes the bot react to message triggers."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @in_month(Month.october)
+ @Cog.listener()
+ async def on_message(self, ctx: discord.Message) -> None:
+ """
+ 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) -> None:
+ """Spooky reaction Cog load."""
+ bot.add_cog(SpookyReact(bot))
+ log.info("SpookyReact cog loaded")
diff --git a/bot/exts/halloween/spookysound.py b/bot/exts/halloween/spookysound.py
new file mode 100644
index 00000000..e0676d0a
--- /dev/null
+++ b/bot/exts/halloween/spookysound.py
@@ -0,0 +1,48 @@
+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(commands.Cog):
+ """A cog that plays a spooky sound in a voice channel on command."""
+
+ def __init__(self, bot: commands.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: commands.Context) -> None:
+ """
+ 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: discord.VoiceClient) -> None:
+ """Helper method to disconnect a given voice client."""
+ await voice.disconnect()
+
+
+def setup(bot: commands.Bot) -> None:
+ """Spooky sound Cog load."""
+ bot.add_cog(SpookySound(bot))
+ log.info("SpookySound cog loaded")
diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py
new file mode 100644
index 00000000..8cb3f4f6
--- /dev/null
+++ b/bot/exts/halloween/timeleft.py
@@ -0,0 +1,60 @@
+import logging
+from datetime import datetime
+from typing import Tuple
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+
+class TimeLeft(commands.Cog):
+ """A Cog that tells you how long left until Hacktober is over!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def in_october() -> bool:
+ """Return True if the current month is October."""
+ return datetime.utcnow().month == 10
+
+ @staticmethod
+ def load_date() -> Tuple[int, datetime, datetime]:
+ """Return of a tuple of the current time and the end and start times of the next October."""
+ now = datetime.utcnow()
+ year = now.year
+ if now.month > 10:
+ year += 1
+ end = datetime(year, 11, 1, 11, 59, 59)
+ start = datetime(year, 10, 1)
+ return now, end, start
+
+ @commands.command()
+ async def timeleft(self, ctx: commands.Context) -> None:
+ """
+ Calculates the time left until the end of Hacktober.
+
+ Whilst in October, displays the days, hours and minutes left.
+ Only displays the days left until the beginning and end whilst in a different month
+ """
+ now, end, start = self.load_date()
+ diff = end - now
+ days, seconds = diff.days, diff.seconds
+ if self.in_october():
+ minutes = seconds // 60
+ hours, minutes = divmod(minutes, 60)
+ await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}"
+ "minutes left until the end of Hacktober.")
+ else:
+ start_diff = start - now
+ start_days = start_diff.days
+ await ctx.send(
+ f"It is not currently Hacktober. However, the next one will start in {start_days} days "
+ f"and will finish in {days} days."
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(TimeLeft(bot))
+ log.info("TimeLeft cog loaded")