diff options
| author | 2019-11-28 18:36:04 +0530 | |
|---|---|---|
| committer | 2019-11-28 18:36:04 +0530 | |
| commit | adf81472c3509e380ef06b53d699bbcedcd04aaf (patch) | |
| tree | 6c1e75b409343e76129917c686a32c7fe85475f1 /bot/seasons | |
| parent | Grammar fixes (diff) | |
| parent | Add the new blinky server guild icon (#314) (diff) | |
Merge branch 'master' into quiz_fix
Diffstat (limited to 'bot/seasons')
| -rw-r--r-- | bot/seasons/christmas/adventofcode.py | 14 | ||||
| -rw-r--r-- | bot/seasons/evergreen/__init__.py | 11 | ||||
| -rw-r--r-- | bot/seasons/evergreen/issues.py | 73 | ||||
| -rw-r--r-- | bot/seasons/halloween/hacktober-issue-finder.py | 107 | ||||
| -rw-r--r-- | bot/seasons/halloween/hacktoberstats.py | 2 | ||||
| -rw-r--r-- | bot/seasons/halloween/timeleft.py | 2 | ||||
| -rw-r--r-- | bot/seasons/pride/drag_queen_name.py | 33 | ||||
| -rw-r--r-- | bot/seasons/pride/pride_facts.py | 106 |
8 files changed, 315 insertions, 33 deletions
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 513c1020..007e4783 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -13,7 +13,7 @@ from bs4 import BeautifulSoup from discord.ext import commands from pytz import timezone -from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens, WHITELISTED_CHANNELS from bot.decorators import override_in_channel log = logging.getLogger(__name__) @@ -24,6 +24,8 @@ AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) + def is_in_advent() -> bool: """Utility function to check if we are between December 1st and December 25th.""" @@ -126,7 +128,7 @@ class AdventOfCode(commands.Cog): self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro)) @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) - @override_in_channel() + @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" await ctx.send_help(ctx.command) @@ -136,6 +138,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) + @override_in_channel(AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -150,6 +153,7 @@ class AdventOfCode(commands.Cog): f"If you don't want them any more, run `{unsubscribe_command}` instead.") @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") + @override_in_channel(AOC_WHITELIST) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -161,6 +165,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") + @override_in_channel(AOC_WHITELIST) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not is_in_advent(): @@ -178,11 +183,13 @@ class AdventOfCode(commands.Cog): await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") + @override_in_channel(AOC_WHITELIST) async def about_aoc(self, ctx: commands.Context) -> None: """Respond with an explanation of all things Advent of Code.""" await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard") + @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the PyDis AoC private leaderboard.""" author = ctx.message.author @@ -203,6 +210,7 @@ class AdventOfCode(commands.Cog): aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) + @override_in_channel(AOC_WHITELIST) async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: """ Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. @@ -244,6 +252,7 @@ class AdventOfCode(commands.Cog): aliases=("dailystats", "ds"), brief="Get daily statistics for the PyDis private leaderboard" ) + @override_in_channel(AOC_WHITELIST) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """ Respond with a table of the daily completion statistics for the PyDis private leaderboard. @@ -287,6 +296,7 @@ class AdventOfCode(commands.Cog): aliases=("globalboard", "gb"), brief="Get a snapshot of the global AoC leaderboard", ) + @override_in_channel(AOC_WHITELIST) async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: """ Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index b95f3528..c2746552 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -6,8 +6,11 @@ class Evergreen(SeasonBase): bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" icon = ( - "/logos/logo_animated/heartbeat/heartbeat.gif", - "/logos/logo_animated/spinner/spinner.gif", - "/logos/logo_animated/tongues/tongues.gif", - "/logos/logo_animated/winky/winky.gif", + "/logos/logo_animated/heartbeat/heartbeat_512.gif", + "/logos/logo_animated/spinner/spinner_512.gif", + "/logos/logo_animated/tongues/tongues_512.gif", + "/logos/logo_animated/winky/winky_512.gif", + "/logos/logo_animated/jumper/jumper_512.gif", + "/logos/logo_animated/apple/apple_512.gif", + "/logos/logo_animated/blinky/blinky_512.gif", ) diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py index 438ab475..c7501a5d 100644 --- a/bot/seasons/evergreen/issues.py +++ b/bot/seasons/evergreen/issues.py @@ -3,10 +3,16 @@ import logging import discord from discord.ext import commands -from bot.constants import Colours +from bot.constants import Channels, Colours, Emojis, WHITELISTED_CHANNELS from bot.decorators import override_in_channel log = logging.getLogger(__name__) +ISSUE_WHITELIST = WHITELISTED_CHANNELS + (Channels.seasonalbot_chat,) + +BAD_RESPONSE = { + 404: "Issue/pull request not located! Please enter a valid number!", + 403: "Rate limit has been hit! Please try again later!" +} class Issues(commands.Cog): @@ -15,43 +21,58 @@ class Issues(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - @commands.command(aliases=("issues",)) - @override_in_channel() + @commands.command(aliases=("pr",)) + @override_in_channel(ISSUE_WHITELIST) async def issue( self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord" ) -> None: """Command to retrieve issues from a GitHub repository.""" - api_url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" - failed_status = { - 404: f"Issue #{number} doesn't exist in the repository {user}/{repository}.", - 403: f"Rate limit exceeded. Please wait a while before trying again!" - } + url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" + merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" - async with self.bot.http_session.get(api_url) as r: + log.trace(f"Querying GH issues API: {url}") + async with self.bot.http_session.get(url) as r: json_data = await r.json() - response_code = r.status - - if response_code in failed_status: - return await ctx.send(failed_status[response_code]) - repo_url = f"https://github.com/{user}/{repository}" - issue_embed = discord.Embed(colour=Colours.bright_green) - issue_embed.add_field(name="Repository", value=f"[{user}/{repository}]({repo_url})", inline=False) - issue_embed.add_field(name="Issue Number", value=f"#{number}", inline=False) - issue_embed.add_field(name="Status", value=json_data["state"].title()) - issue_embed.add_field(name="Link", value=json_data["html_url"], inline=False) + if r.status in BAD_RESPONSE: + log.warning(f"Received response {r.status} from: {url}") + return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}") - description = json_data["body"] - if len(description) > 1024: - placeholder = " [...]" - description = f"{description[:1024 - len(placeholder)]}{placeholder}" + # The initial API request is made to the issues API endpoint, which will return information + # if the issue or PR is present. However, the scope of information returned for PRs differs + # from issues: if the 'issues' key is present in the response then we can pull the data we + # need from the initial API call. + if "issues" in json_data.get("html_url"): + if json_data.get("state") == "open": + icon_url = Emojis.issue + else: + icon_url = Emojis.issue_closed - issue_embed.add_field(name="Description", value=description, inline=False) + # If the 'issues' key is not contained in the API response and there is no error code, then + # we know that a PR has been requested and a call to the pulls API endpoint is necessary + # to get the desired information for the PR. + else: + log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}") + async with self.bot.http_session.get(merge_url) as m: + if json_data.get("state") == "open": + icon_url = Emojis.pull_request + # When the status is 204 this means that the state of the PR is merged + elif m.status == 204: + icon_url = Emojis.merge + else: + icon_url = Emojis.pull_request_closed - await ctx.send(embed=issue_embed) + issue_url = json_data.get("html_url") + description_text = f"[{repository}] #{number} {json_data.get('title')}" + resp = discord.Embed( + colour=Colours.bright_green, + description=f"{icon_url} [{description_text}]({issue_url})" + ) + resp.set_author(name="GitHub", url=issue_url) + await ctx.send(embed=resp) def setup(bot: commands.Bot) -> None: - """Github Issues Cog Load.""" + """Cog Retrieves Issues From Github.""" bot.add_cog(Issues(bot)) log.info("Issues cog loaded") diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/seasons/halloween/hacktober-issue-finder.py new file mode 100644 index 00000000..10732374 --- /dev/null +++ b/bot/seasons/halloween/hacktober-issue-finder.py @@ -0,0 +1,107 @@ +import datetime +import logging +import random +from typing import Dict, Optional + +import aiohttp +import discord +from discord.ext import commands + +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) + + @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/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 9ad44e3f..ab8d865c 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -58,6 +58,7 @@ class HacktoberStats(commands.Cog): await self.get_stats(ctx, github_username) @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. @@ -91,6 +92,7 @@ class HacktoberStats(commands.Cog): await ctx.send(f"{author_mention}, a GitHub username is required to link your account") @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) diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py index 77767baa..8cb3f4f6 100644 --- a/bot/seasons/halloween/timeleft.py +++ b/bot/seasons/halloween/timeleft.py @@ -25,7 +25,7 @@ class TimeLeft(commands.Cog): year = now.year if now.month > 10: year += 1 - end = datetime(year, 10, 31, 11, 59, 59) + end = datetime(year, 11, 1, 11, 59, 59) start = datetime(year, 10, 1) return now, end, start diff --git a/bot/seasons/pride/drag_queen_name.py b/bot/seasons/pride/drag_queen_name.py new file mode 100644 index 00000000..43813fbd --- /dev/null +++ b/bot/seasons/pride/drag_queen_name.py @@ -0,0 +1,33 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class DragNames(commands.Cog): + """Gives a random drag queen name!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.names = self.load_names() + + @staticmethod + def load_names() -> list: + """Loads a list of drag queen names.""" + with open(Path("bot/resources/pride/drag_queen_names.json"), "r", encoding="utf-8") as f: + return json.load(f) + + @commands.command(name="dragname", aliases=["dragqueenname", "queenme"]) + async def dragname(self, ctx: commands.Context) -> None: + """Sends a message with a drag queen name.""" + await ctx.send(random.choice(self.names)) + + +def setup(bot: commands.Bot) -> None: + """Cog loader for drag queen name generator.""" + bot.add_cog(DragNames(bot)) + log.info("Drag queen name generator cog loaded!") diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py new file mode 100644 index 00000000..b705bfb4 --- /dev/null +++ b/bot/seasons/pride/pride_facts.py @@ -0,0 +1,106 @@ +import asyncio +import json +import logging +import random +from datetime import datetime +from pathlib import Path +from typing import Union + +import dateutil.parser +import discord +from discord.ext import commands + +from bot.constants import Channels +from bot.constants import Colours + +log = logging.getLogger(__name__) + +Sendable = Union[commands.Context, discord.TextChannel] + + +class PrideFacts(commands.Cog): + """Provides a new fact every day during the Pride season!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.facts = self.load_facts() + + @staticmethod + def load_facts() -> dict: + """Loads a dictionary of years mapping to lists of facts.""" + with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf-8") as f: + return json.load(f) + + async def send_pride_fact_daily(self) -> None: + """Background task to post the daily pride fact every day.""" + channel = self.bot.get_channel(Channels.seasonalbot_chat) + while True: + await self.send_select_fact(channel, datetime.utcnow()) + await asyncio.sleep(24 * 60 * 60) + + async def send_random_fact(self, ctx: commands.Context) -> None: + """Provides a fact from any previous day, or today.""" + now = datetime.utcnow() + previous_years_facts = (self.facts[x] for x in self.facts.keys() if int(x) < now.year) + current_year_facts = self.facts.get(str(now.year), [])[:now.day] + previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] + try: + await ctx.send(embed=self.make_embed(random.choice(previous_facts))) + except IndexError: + await ctx.send("No facts available") + + async def send_select_fact(self, target: Sendable, _date: Union[str, datetime]) -> None: + """Provides the fact for the specified day, if the day is today, or is in the past.""" + now = datetime.utcnow() + if isinstance(_date, str): + try: + date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) + except (ValueError, OverflowError) as err: + await target.send(f"Error parsing date: {err}") + return + else: + date = _date + if date.year < now.year or (date.year == now.year and date.day <= now.day): + try: + await target.send(embed=self.make_embed(self.facts[str(date.year)][date.day - 1])) + except KeyError: + await target.send(f"The year {date.year} is not yet supported") + return + except IndexError: + await target.send(f"Day {date.day} of {date.year} is not yet support") + return + else: + await target.send("The fact for the selected day is not yet available.") + + @commands.command(name="pridefact", aliases=["pridefacts"]) + async def pridefact(self, ctx: commands.Context) -> None: + """ + Sends a message with a pride fact of the day. + + If "random" is given as an argument, a random previous fact will be provided. + + If a date is given as an argument, and the date is in the past, the fact from that day + will be provided. + """ + message_body = ctx.message.content[len(ctx.invoked_with) + 2:] + if message_body == "": + await self.send_select_fact(ctx, datetime.utcnow()) + elif message_body.lower().startswith("rand"): + await self.send_random_fact(ctx) + else: + await self.send_select_fact(ctx, message_body) + + def make_embed(self, fact: str) -> discord.Embed: + """Makes a nice embed for the fact to be sent.""" + return discord.Embed( + colour=Colours.pink, + title="Pride Fact!", + description=fact + ) + + +def setup(bot: commands.Bot) -> None: + """Cog loader for pride facts.""" + bot.loop.create_task(PrideFacts(bot).send_pride_fact_daily()) + bot.add_cog(PrideFacts(bot)) + log.info("Pride facts cog loaded!") |