diff options
author | 2020-10-31 13:00:46 +0000 | |
---|---|---|
committer | 2020-10-31 13:00:46 +0000 | |
commit | a81deec68df39cd5cb91269909f65208afc56716 (patch) | |
tree | db85be372d82fab69639b7a52501424fe12f04f9 | |
parent | Intents: use 'default' factory (diff) | |
parent | Merge pull request #479 from Anubhav1603/bug_ec (diff) |
Merge branch 'master' into discordpy-15
-rwxr-xr-x | README.md | 2 | ||||
-rw-r--r-- | bot/constants.py | 36 | ||||
-rw-r--r-- | bot/exts/easter/easter_riddle.py | 6 | ||||
-rw-r--r-- | bot/exts/easter/save_the_planet.py | 29 | ||||
-rw-r--r-- | bot/exts/evergreen/emoji_count.py | 94 | ||||
-rw-r--r-- | bot/exts/evergreen/fun.py | 24 | ||||
-rw-r--r-- | bot/exts/evergreen/githubinfo.py | 98 | ||||
-rw-r--r-- | bot/exts/evergreen/issues.py | 5 | ||||
-rw-r--r-- | bot/exts/evergreen/minesweeper.py | 12 | ||||
-rw-r--r-- | bot/exts/evergreen/movie.py | 5 | ||||
-rw-r--r-- | bot/exts/evergreen/snakes/_snakes_cog.py | 4 | ||||
-rw-r--r-- | bot/exts/evergreen/trivia_quiz.py | 6 | ||||
-rw-r--r-- | bot/exts/halloween/hacktober-issue-finder.py | 2 | ||||
-rw-r--r-- | bot/exts/halloween/hacktoberstats.py | 276 | ||||
-rw-r--r-- | bot/exts/halloween/scarymovie.py | 3 | ||||
-rw-r--r-- | bot/exts/valentines/movie_generator.py | 2 | ||||
-rw-r--r-- | bot/resources/easter/save_the_planet.json | 77 |
17 files changed, 536 insertions, 145 deletions
@@ -1,7 +1,7 @@ # SeasonalBot [)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=3) -[](https://discord.gg/2B963hn) +[](https://discord.gg/2B963hn) A Discord bot for the Python Discord community which changes with the seasons, and provides useful event features. diff --git a/bot/constants.py b/bot/constants.py index e113428e..f1f34886 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -75,6 +75,24 @@ class Channels(NamedTuple): show_your_projects = int(environ.get("CHANNEL_SHOW_YOUR_PROJECTS", 303934982764625920)) show_your_projects_discussion = 360148304664723466 hacktoberfest_2020 = 760857070781071431 + voice_chat = 412357430186344448 + + # Core Dev Sprint channels + sprint_announcements = 755958119963557958 + sprint_information = 753338352136224798 + sprint_organisers = 753340132639375420 + sprint_general = 753340631538991305 + sprint_social1_cheese_shop = 758779754789863514 + sprint_social2_pet_shop = 758780951978573824 + sprint_escape_room = 761031075942105109 + sprint_stdlib = 758553316732698634 + sprint_asyncio = 762904152438472714 + sprint_typing = 762904690341838888 + sprint_discussion_capi = 758553358587527218 + sprint_discussion_triage = 758553458365300746 + sprint_discussion_design = 758553492662255616 + sprint_discussion_mentor = 758553536623280159 + sprint_documentation = 761038271127093278 class Client(NamedTuple): @@ -209,6 +227,24 @@ WHITELISTED_CHANNELS = ( Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2, + Channels.voice_chat, + + # Core Dev Sprint Channels + Channels.sprint_announcements, + Channels.sprint_information, + Channels.sprint_organisers, + Channels.sprint_general, + Channels.sprint_social1_cheese_shop, + Channels.sprint_social2_pet_shop, + Channels.sprint_escape_room, + Channels.sprint_stdlib, + Channels.sprint_asyncio, + Channels.sprint_typing, + Channels.sprint_discussion_capi, + Channels.sprint_discussion_triage, + Channels.sprint_discussion_design, + Channels.sprint_discussion_mentor, + Channels.sprint_documentation, ) # Bot replies diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py index 8977534f..3c612eb1 100644 --- a/bot/exts/easter/easter_riddle.py +++ b/bot/exts/easter/easter_riddle.py @@ -22,7 +22,7 @@ class EasterRiddle(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.winners = [] + self.winners = set() self.correct = "" self.current_channel = None @@ -79,7 +79,7 @@ class EasterRiddle(commands.Cog): await ctx.send(content, embed=answer_embed) - self.winners = [] + self.winners.clear() self.current_channel = None @commands.Cog.listener() @@ -92,7 +92,7 @@ class EasterRiddle(commands.Cog): return if message.content.lower() == self.correct.lower(): - self.winners.append(message.author.mention) + self.winners.add(message.author.mention) def setup(bot: commands.Bot) -> None: diff --git a/bot/exts/easter/save_the_planet.py b/bot/exts/easter/save_the_planet.py new file mode 100644 index 00000000..8f644259 --- /dev/null +++ b/bot/exts/easter/save_the_planet.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path + +from discord import Embed +from discord.ext import commands + +from bot.utils.randomization import RandomCycle + + +with Path("bot/resources/easter/save_the_planet.json").open('r', encoding='utf8') as f: + EMBED_DATA = RandomCycle(json.load(f)) + + +class SaveThePlanet(commands.Cog): + """A cog that teaches users how they can help our planet.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @commands.command(aliases=('savetheearth', 'saveplanet', 'saveearth')) + async def savetheplanet(self, ctx: commands.Context) -> None: + """Responds with a random tip on how to be eco-friendly and help our planet.""" + return_embed = Embed.from_dict(next(EMBED_DATA)) + await ctx.send(embed=return_embed) + + +def setup(bot: commands.Bot) -> None: + """Save the Planet Cog load.""" + bot.add_cog(SaveThePlanet(bot)) diff --git a/bot/exts/evergreen/emoji_count.py b/bot/exts/evergreen/emoji_count.py index ef900199..cc43e9ab 100644 --- a/bot/exts/evergreen/emoji_count.py +++ b/bot/exts/evergreen/emoji_count.py @@ -1,12 +1,14 @@ import datetime import logging import random -from typing import Dict, Optional +from collections import defaultdict +from typing import List, Tuple import discord from discord.ext import commands from bot.constants import Colours, ERROR_REPLIES +from bot.utils.pagination import LinePaginator log = logging.getLogger(__name__) @@ -17,73 +19,77 @@ class EmojiCount(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - def embed_builder(self, emoji: dict) -> discord.Embed: + @staticmethod + def embed_builder(emoji: dict) -> Tuple[discord.Embed, List[str]]: """Generates an embed with the emoji names and count.""" embed = discord.Embed( color=Colours.orange, title="Emoji Count", timestamp=datetime.datetime.utcnow() ) + msg = [] if len(emoji) == 1: - for key, value in emoji.items(): - embed.description = f"There are **{len(value)}** emojis in the **{key}** category" - embed.set_thumbnail(url=random.choice(value).url) + for category_name, category_emojis in emoji.items(): + if len(category_emojis) == 1: + msg.append(f"There is **{len(category_emojis)}** emoji in **{category_name}** category") + else: + msg.append(f"There are **{len(category_emojis)}** emojis in **{category_name}** category") + embed.set_thumbnail(url=random.choice(category_emojis).url) + else: - msg = '' - for key, value in emoji.items(): - emoji_choice = random.choice(value) - emoji_info = f'There are **{len(value)}** emojis in the **{key}** category\n' - msg += f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}' - embed.description = msg - return embed + for category_name, category_emojis in emoji.items(): + emoji_choice = random.choice(category_emojis) + if len(category_emojis) > 1: + emoji_info = f"There are **{len(category_emojis)}** emojis in **{category_name}** category" + else: + emoji_info = f"There is **{len(category_emojis)}** emoji in **{category_name}** category" + if emoji_choice.animated: + msg.append(f'<a:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}') + else: + msg.append(f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}') + return embed, msg @staticmethod - def generate_invalid_embed(ctx: commands.Context) -> discord.Embed: - """Genrates error embed.""" + def generate_invalid_embed(emojis: list) -> Tuple[discord.Embed, List[str]]: + """Generates error embed.""" embed = discord.Embed( color=Colours.soft_red, title=random.choice(ERROR_REPLIES) ) + msg = [] - emoji_dict = {} - for emoji in ctx.guild.emojis: - emoji_dict[emoji.name.split("_")[0]] = [] + emoji_dict = defaultdict(list) + for emoji in emojis: + emoji_dict[emoji.name.split("_")[0]].append(emoji) - error_comp = ', '.join(key for key in emoji_dict.keys()) - embed.description = f"These are the valid categories\n```{error_comp}```" - return embed + error_comp = ', '.join(emoji_dict) + msg.append(f"These are the valid categories\n```{error_comp}```") + return embed, msg - def emoji_list(self, ctx: commands.Context, categories: dict) -> Dict: - """Generates an embed with the emoji names and count.""" - out = {category: [] for category in categories} + @commands.command(name="emojicount", aliases=["ec", "emojis"]) + async def emoji_count(self, ctx: commands.Context, *, category_query: str = None) -> None: + """Returns embed with emoji category and info given by the user.""" + emoji_dict = defaultdict(list) + if not ctx.guild.emojis: + await ctx.send("No emojis found.") + return + log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user") for emoji in ctx.guild.emojis: - category = emoji.name.split('_')[0] - if category in out: - out[category].append(emoji) - return out - - @commands.command(name="emoji_count", aliases=["ec"]) - async def ec(self, ctx: commands.Context, *, emoji: str = None) -> Optional[str]: - """Returns embed with emoji category and info given by the user.""" - emoji_dict = {} + emoji_category = emoji.name.split("_")[0] - for a in ctx.guild.emojis: - if emoji is None: - log.trace("Emoji Category not provided by the user") - emoji_dict.update({a.name.split("_")[0]: []}) - elif a.name.split("_")[0] in emoji: - log.trace("Emoji Category provided by the user") - emoji_dict.update({a.name.split("_")[0]: []}) + if category_query is not None and emoji_category not in category_query: + continue - emoji_dict = self.emoji_list(ctx, emoji_dict) + emoji_dict[emoji_category].append(emoji) - if len(emoji_dict) == 0: - embed = self.generate_invalid_embed(ctx) + if not emoji_dict: + log.trace("Invalid name provided by the user") + embed, msg = self.generate_invalid_embed(ctx.guild.emojis) else: - embed = self.embed_builder(emoji_dict) - await ctx.send(embed=embed) + embed, msg = self.embed_builder(emoji_dict) + await LinePaginator.paginate(lines=msg, ctx=ctx, embed=embed) def setup(bot: commands.Bot) -> None: diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py index de6a92c6..101725da 100644 --- a/bot/exts/evergreen/fun.py +++ b/bot/exts/evergreen/fun.py @@ -7,10 +7,10 @@ from typing import Callable, Iterable, Tuple, Union from discord import Embed, Message from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context, MessageConverter, clean_content +from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverter, clean_content from bot import utils -from bot.constants import Colours, Emojis +from bot.constants import Client, Colours, Emojis log = logging.getLogger(__name__) @@ -57,18 +57,20 @@ class Fun(Cog): with Path("bot/resources/evergreen/caesar_info.json").open("r", encoding="UTF-8") as f: self._caesar_cipher_embed = json.load(f) + @staticmethod + def _get_random_die() -> str: + """Generate a random die emoji, ready to be sent on Discord.""" + die_name = f"dice_{random.randint(1, 6)}" + return getattr(Emojis, die_name) + @commands.command() async def roll(self, ctx: Context, num_rolls: int = 1) -> None: """Outputs a number of random dice emotes (up to 6).""" - output = "" - if num_rolls > 6: - num_rolls = 6 - elif num_rolls < 1: - output = ":no_entry: You must roll at least once." - for _ in range(num_rolls): - dice = f"dice_{random.randint(1, 6)}" - output += getattr(Emojis, dice, '') - await ctx.send(output) + if 1 <= num_rolls <= 6: + dice = " ".join(self._get_random_die() for _ in range(num_rolls)) + await ctx.send(dice) + else: + raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.") @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py new file mode 100644 index 00000000..2e38e3ab --- /dev/null +++ b/bot/exts/evergreen/githubinfo.py @@ -0,0 +1,98 @@ +import logging +import random +from datetime import datetime +from typing import Optional + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +from bot.constants import NEGATIVE_REPLIES + +log = logging.getLogger(__name__) + + +class GithubInfo(commands.Cog): + """Fetches info from GitHub.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + async def fetch_data(self, url: str) -> dict: + """Retrieve data as a dictionary.""" + async with self.bot.http_session.get(url) as r: + return await r.json() + + @commands.command(name='github', aliases=['gh']) + @commands.cooldown(1, 60, BucketType.user) + async def get_github_info(self, ctx: commands.Context, username: Optional[str]) -> None: + """ + Fetches a user's GitHub information. + + Username is optional and sends the help command if not specified. + """ + if username is None: + await ctx.invoke(self.bot.get_command('help'), 'github') + ctx.command.reset_cooldown(ctx) + return + + async with ctx.typing(): + user_data = await self.fetch_data(f"https://api.github.com/users/{username}") + + # User_data will not have a message key if the user exists + if user_data.get('message') is not None: + await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES), + description=f"The profile for `{username}` was not found.", + colour=discord.Colour.red())) + return + + org_data = await self.fetch_data(user_data['organizations_url']) + orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data] + orgs_to_add = ' | '.join(orgs) + + gists = user_data['public_gists'] + + # Forming blog link + if user_data['blog'].startswith("http"): # Blog link is complete + blog = user_data['blog'] + elif user_data['blog']: # Blog exists but the link is not complete + blog = f"https://{user_data['blog']}" + else: + blog = "No website link available" + + embed = discord.Embed( + title=f"`{user_data['login']}`'s GitHub profile info", + description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "", + colour=0x7289da, + url=user_data['html_url'], + timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ") + ) + embed.set_thumbnail(url=user_data['avatar_url']) + embed.set_footer(text="Account created at") + + if user_data['type'] == "User": + + embed.add_field(name="Followers", + value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)") + embed.add_field(name="\u200b", value="\u200b") + embed.add_field(name="Following", + value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)") + + embed.add_field(name="Public repos", + value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)") + embed.add_field(name="\u200b", value="\u200b") + + if user_data['type'] == "User": + embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{username})") + + embed.add_field(name=f"Organization{'s' if len(orgs)!=1 else ''}", + value=orgs_to_add if orgs else "No organizations") + embed.add_field(name="\u200b", value="\u200b") + embed.add_field(name="Website", value=blog) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Adding the cog to the bot.""" + bot.add_cog(GithubInfo(bot)) diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py index 5a5c82e7..97ee6a12 100644 --- a/bot/exts/evergreen/issues.py +++ b/bot/exts/evergreen/issues.py @@ -38,7 +38,7 @@ class Issues(commands.Cog): ) -> None: """Command to retrieve issue(s) from a GitHub repository.""" links = [] - numbers = set(numbers) + numbers = set(numbers) # Convert from list to set to remove duplicates, if any if not numbers: await ctx.invoke(self.bot.get_command('help'), 'issue') @@ -53,8 +53,7 @@ class Issues(commands.Cog): await ctx.send(embed=embed) return - for number in set(numbers): - # Convert from list to set to remove duplicates, if any. + for number in numbers: url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py index 3e40f493..286ac7a5 100644 --- a/bot/exts/evergreen/minesweeper.py +++ b/bot/exts/evergreen/minesweeper.py @@ -120,14 +120,14 @@ class Minesweeper(commands.Cog): def format_for_discord(board: GameBoard) -> str: """Format the board as a string for Discord.""" discord_msg = ( - ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:" - ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" - ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n" + ":stop_button: :regional_indicator_a: :regional_indicator_b: :regional_indicator_c: " + ":regional_indicator_d: :regional_indicator_e: :regional_indicator_f: :regional_indicator_g: " + ":regional_indicator_h: :regional_indicator_i: :regional_indicator_j:\n\n" ) rows = [] for row_number, row in enumerate(board): new_row = f"{MESSAGE_MAPPING[row_number + 1]} " - new_row += "".join(MESSAGE_MAPPING[cell] for cell in row) + new_row += " ".join(MESSAGE_MAPPING[cell] for cell in row) rows.append(new_row) discord_msg += "\n".join(rows) @@ -158,7 +158,7 @@ class Minesweeper(commands.Cog): if ctx.guild: await ctx.send(f"{ctx.author.mention} is playing Minesweeper") - chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}") + chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}") else: chat_msg = None @@ -176,7 +176,7 @@ class Minesweeper(commands.Cog): await game.dm_msg.delete() game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") if game.activated_on_server: - await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}") + await game.chat_msg.edit(content=f"Here's their board!\n{self.format_for_discord(game.revealed)}") @commands.dm_only() @minesweeper_group.command(name="flag") diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py index 93aeef30..340a5724 100644 --- a/bot/exts/evergreen/movie.py +++ b/bot/exts/evergreen/movie.py @@ -190,7 +190,10 @@ class Movie(Cog): async def get_embed(self, name: str) -> Embed: """Return embed of random movies. Uses name in title.""" - return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)') + embed = Embed(title=f"Random {name} Movies") + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") + return embed def setup(bot: Bot) -> None: diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index a846274b..70bb0e73 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -1083,13 +1083,13 @@ class Snakes(Cog): url, params={ "part": "snippet", - "q": urllib.parse.quote(query), + "q": urllib.parse.quote_plus(query), "type": "video", "key": Tokens.youtube } ) response = await response.json() - data = response['items'] + data = response.get("items", []) # Send the user a video if len(data) > 0: diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index 8dceceac..fe692c2a 100644 --- a/bot/exts/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py @@ -121,8 +121,10 @@ class TriviaQuiz(commands.Cog): # A function to check whether user input is the correct answer(close to the right answer) def check(m: discord.Message) -> bool: - ratio = fuzz.ratio(answer.lower(), m.content.lower()) - return ratio > 85 and m.channel == ctx.channel + return ( + m.channel == ctx.channel + and fuzz.ratio(answer.lower(), m.content.lower()) > 85 + ) try: msg = await self.bot.wait_for('message', check=check, timeout=10) diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py index 78acf391..9deadde9 100644 --- a/bot/exts/halloween/hacktober-issue-finder.py +++ b/bot/exts/halloween/hacktober-issue-finder.py @@ -103,7 +103,7 @@ class HacktoberIssues(commands.Cog): labels = [label["name"] for label in issue["labels"]] embed = discord.Embed(title=title) - embed.description = body + embed.description = body[:500] + '...' if len(body) > 500 else body embed.add_field(name="labels", value="\n".join(labels)) embed.url = issue_url embed.set_footer(text=issue_url) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index ed1755e3..d2762513 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -2,9 +2,9 @@ import json import logging import re from collections import Counter -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path -from typing import List, Tuple +from typing import List, Tuple, Union import aiohttp import discord @@ -18,11 +18,15 @@ 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 +REVIEW_DAYS = 14 # number of days needed after PR can be mature HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2020,) REQUEST_HEADERS = {"User-Agent": "Python Discord Hacktoberbot"} +# using repo topics API during preview period requires an accept header +GITHUB_TOPICS_ACCEPT_HEADER = {"Accept": "application/vnd.github.mercy-preview+json"} if GITHUB_TOKEN := Tokens.github: REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" + GITHUB_TOPICS_ACCEPT_HEADER["Authorization"] = f"token {GITHUB_TOKEN}" GITHUB_NONEXISTENT_USER_MESSAGE = ( "The listed users cannot be searched either because the users do not exist " @@ -50,7 +54,7 @@ class HacktoberStats(commands.Cog): get that user's contributions """ if not github_username: - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) + author_id, author_mention = self._author_mention_from_context(ctx) if str(author_id) in self.linked_accounts.keys(): github_username = self.linked_accounts[author_id]["github_username"] @@ -81,7 +85,7 @@ class HacktoberStats(commands.Cog): } } """ - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) + author_id, author_mention = self._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"] @@ -106,7 +110,7 @@ class HacktoberStats(commands.Cog): @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) + author_id, author_mention = self._author_mention_from_context(ctx) stored_user = self.linked_accounts.pop(author_id, None) if stored_user: @@ -162,7 +166,11 @@ class HacktoberStats(commands.Cog): """ Query GitHub's API for PRs created by a GitHub user during the month of October. - PRs with the 'invalid' tag are ignored + PRs with an 'invalid' or 'spam' label are ignored + + For PRs created after October 3rd, they have to be in a repository that has a + 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it + to count. If a valid github_username is provided, an embed is generated and posted to the channel @@ -172,19 +180,19 @@ class HacktoberStats(commands.Cog): prs = await self.get_october_prs(github_username) if prs: - stats_embed = self.build_embed(github_username, prs) + stats_embed = await 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}'") + await ctx.send(f"No valid October GitHub contributions found for '{github_username}'") - def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: + async 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) + in_review, accepted = await self._categorize_prs(prs) - n = pr_stats['n_prs'] + n = len(accepted) + len(in_review) # total number of PRs if n >= PRS_FOR_SHIRT: - shirtstr = f"**{github_username} has earned a T-shirt or a tree!**" + shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**" elif n == PRS_FOR_SHIRT - 1: shirtstr = f"**{github_username} is 1 PR away from a T-shirt or a tree!**" else: @@ -194,8 +202,8 @@ class HacktoberStats(commands.Cog): title=f"{github_username}'s Hacktoberfest", color=discord.Color(0x9c4af7), description=( - f"{github_username} has made {n} " - f"{HacktoberStats._contributionator(n)} in " + f"{github_username} has made {n} valid " + f"{self._contributionator(n)} in " f"October\n\n" f"{shirtstr}\n\n" ) @@ -207,54 +215,64 @@ class HacktoberStats(commands.Cog): url="https://hacktoberfest.digitalocean.com", icon_url="https://avatars1.githubusercontent.com/u/35706162?s=200&v=4" ) + + # this will handle when no PRs in_review or accepted + review_str = self._build_prs_string(in_review, github_username) or "None" + accepted_str = self._build_prs_string(accepted, github_username) or "None" stats_embed.add_field( - name="Top 5 Repositories:", - value=self._build_top5str(pr_stats) + name=":clock1: In Review", + value=review_str + ) + stats_embed.add_field( + name=":tada: Accepted", + value=accepted_str ) 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]: + async def get_october_prs(github_username: str) -> Union[List[dict], None]: """ Query GitHub's API for PRs created during the month of October by github_username. - PRs with an 'invalid' tag are ignored + PRs with an 'invalid' or 'spam' label are ignored unless it is merged or approved + + For PRs created after October 3rd, they have to be in a repository that has a + 'hacktoberfest' topic, unless the PR is labelled 'hacktoberfest-accepted' for it + to count. 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 - } + "number": int + } Otherwise, return None """ - logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") + logging.info(f"Fetching Hacktoberfest Stats 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}" + is_query = "public" not_query = "draft" - date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00" + date_range = f"{CURRENT_YEAR}-09-30T10:00Z..{CURRENT_YEAR}-11-01T12:00Z" per_page = "300" query_url = ( f"{base_url}" - f"-label:{not_label}" f"+type:{action_type}" f"+is:{is_query}" + f"+author:{github_username}" f"+-is:{not_query}" f"+created:{date_range}" f"&per_page={per_page}" ) + logging.debug(f"GitHub query URL generated: {query_url}") - async with aiohttp.ClientSession() as session: - async with session.get(query_url, headers=REQUEST_HEADERS) as resp: - jsonresp = await resp.json() - + jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) if "message" in jsonresp.keys(): # One of the parameters is invalid, short circuit for now api_message = jsonresp["errors"][0]["message"] @@ -264,28 +282,132 @@ class HacktoberStats(commands.Cog): logging.debug(f"No GitHub user found named '{github_username}'") else: logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") + return + 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: - 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" - ), - } + logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") + outlist = [] # list of pr information dicts that will get returned + oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None) + hackto_topics = {} # cache whether each repo has the appropriate topic (bool values) + 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" + ), + "number": item["number"] + } + + # if the PR has 'invalid' or 'spam' labels, the PR must be + # either merged or approved for it to be included + if HacktoberStats._has_label(item, ["invalid", "spam"]): + if not await HacktoberStats._is_accepted(itemdict): + continue + + # PRs before oct 3 no need to check for topics + # continue the loop if 'hacktoberfest-accepted' is labelled then + # there is no need to check for its topics + if itemdict["created_at"] < oct3: + outlist.append(itemdict) + continue + + # checking PR's labels for "hacktoberfest-accepted" + if HacktoberStats._has_label(item, "hacktoberfest-accepted"): + outlist.append(itemdict) + continue + + # no need to query github if repo topics are fetched before already + if shortname in hackto_topics.keys(): + if hackto_topics[shortname]: outlist.append(itemdict) - return outlist + continue + # fetch topics for the pr repo + topics_query_url = f"https://api.github.com/repos/{shortname}/topics" + logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") + jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) + if jsonresp2.get("names") is None: + logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") + return + + # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label + # must be in repo with 'hacktoberfest' topic + if "hacktoberfest" in jsonresp2["names"]: + hackto_topics[shortname] = True # cache result in the dict for later use if needed + outlist.append(itemdict) + return outlist + + @staticmethod + async def _fetch_url(url: str, headers: dict) -> dict: + """Retrieve API response from URL.""" + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as resp: + jsonresp = await resp.json() + return jsonresp + + @staticmethod + def _has_label(pr: dict, labels: Union[List[str], str]) -> bool: + """ + Check if a PR has label 'labels'. + + 'labels' can be a string or a list of strings, if it's a list of strings + it will return true if any of the labels match. + """ + if not pr.get("labels"): # if PR has no labels + return False + if (isinstance(labels, str)) and (any(label["name"].casefold() == labels for label in pr["labels"])): + return True + for item in labels: + if any(label["name"].casefold() == item for label in pr["labels"]): + return True + return False + + @staticmethod + async def _is_accepted(pr: dict) -> bool: + """Check if a PR is merged, approved, or labelled hacktoberfest-accepted.""" + # checking for merge status + query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/" + query_url += str(pr["number"]) + jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) + + if "message" in jsonresp.keys(): + logging.error( + f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n" + f"{jsonresp['message']}" + ) + return False + if ("merged" in jsonresp.keys()) and jsonresp["merged"]: + return True + + # checking for the label, using `jsonresp` which has the label information + if HacktoberStats._has_label(jsonresp, "hacktoberfest-accepted"): + return True + + # checking approval + query_url += "/reviews" + jsonresp2 = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) + if isinstance(jsonresp2, dict): + # if API request is unsuccessful it will be a dict with the error in 'message' + logging.error( + f"Error fetching PR reviews for #{pr['number']} in repo {pr['repo_shortname']}:\n" + f"{jsonresp2['message']}" + ) + return False + # if it is successful it will be a list instead of a dict + if len(jsonresp2) == 0: # if PR has no reviews + return False + + # loop through reviews and check for approval + for item in jsonresp2: + if "status" in item.keys(): + if item['status'] == "APPROVED": + return True + return False @staticmethod def _get_shortname(in_url: str) -> str: @@ -301,38 +423,52 @@ class HacktoberStats(commands.Cog): return re.findall(exp, in_url)[0] @staticmethod - def _summarize_prs(prs: List[dict]) -> dict: + async def _categorize_prs(prs: List[dict]) -> tuple: """ - Generate statistics from an input list of PR dictionaries, as output by get_october_prs. + Categorize PRs into 'in_review' and 'accepted' and returns as a tuple. - Return a dictionary containing: - { - "n_prs": int - "top5": [(repo_shortname, ncontributions), ...] - } + PRs created less than 14 days ago are 'in_review', PRs that are not + are 'accepted' (after 14 days review period). + + PRs that are accepted must either be merged, approved, or labelled + 'hacktoberfest-accepted. """ - contributed_repos = [pr["repo_shortname"] for pr in prs] - return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} + now = datetime.now() + oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=None) + in_review = [] + accepted = [] + for pr in prs: + if (pr['created_at'] + timedelta(REVIEW_DAYS)) > now: + in_review.append(pr) + elif (pr['created_at'] <= oct3) or await HacktoberStats._is_accepted(pr): + accepted.append(pr) + + return in_review, accepted @staticmethod - def _build_top5str(stats: List[tuple]) -> str: + def _build_prs_string(prs: List[tuple], user: str) -> str: """ - Build a string from the Top 5 contributions that is compatible with a discord.Embed field. + Builds a discord embed compatible string for a list of PRs. - 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) - ... + Repository name with the link to pull requests authored by 'user' for + each PR. """ 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) + str_list = [] + repo_list = [pr["repo_shortname"] for pr in prs] + prs_list = Counter(repo_list).most_common(5) # get first 5 counted PRs + more = len(prs) - sum(i[1] for i in prs_list) + + for pr in prs_list: + # for example: https://www.github.com/python-discord/bot/pulls/octocat + # will display pull requests authored by octocat. + # pr[1] is the number of PRs to the repo + string = f"{pr[1]} to [{pr[0]}]({base_url}{pr[0]}/pulls/{user})" + str_list.append(string) + if more: + str_list.append(f"...and {more} more") + + return "\n".join(str_list) @staticmethod def _contributionator(n: int) -> str: diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py index c80e0298..0807eca6 100644 --- a/bot/exts/halloween/scarymovie.py +++ b/bot/exts/halloween/scarymovie.py @@ -121,7 +121,8 @@ class ScaryMovie(commands.Cog): if value: embed.add_field(name=name, value=value) - embed.set_footer(text='powered by themoviedb.org') + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") return embed diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py index 0843175a..4df9e0d5 100644 --- a/bot/exts/valentines/movie_generator.py +++ b/bot/exts/valentines/movie_generator.py @@ -48,6 +48,8 @@ class RomanceMovieFinder(commands.Cog): embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") await ctx.send(embed=embed) except KeyError: warning_message = "A KeyError was raised while fetching information on the movie. The API service" \ diff --git a/bot/resources/easter/save_the_planet.json b/bot/resources/easter/save_the_planet.json new file mode 100644 index 00000000..f22261b7 --- /dev/null +++ b/bot/resources/easter/save_the_planet.json @@ -0,0 +1,77 @@ +[ + { + "title": "Choose renewable energy", + "image": {"url": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2019/07/23/851602-renewable-energy-istock-072419.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "Getting energy from oil or fossil fuels isn't a good idea, because there is only so much of it.", + "inline": false + }, + + { + "name": "What you can do", + "value": "Use renewable energy, such as wind, solar, and hydro, because it is healthier and is not a finite resource!", + "inline": false + } + ] + }, + + { + "title": "Save the trees!", + "image": {"url": "https://www.thecollegesolution.com/wp-content/uploads/2014/07/crumpled-paper-1.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "We often waste trees on making paper, and just getting rid of them for no good reason.", + "inline": false + }, + + { + "name": "What you can do", + "value": "Make sure you only use paper when absolutely necessary. When you do, make sure to use recycled paper because making new paper causes pollution. Find ways to plant trees (Hacktober Fest!) to combat losing them.", + "inline": false + } + ] + }, + + { + "title": "Less time in the car!", + "image": {"url": "https://www.careeraddict.com/uploads/article/55294/businessman-riding-bike.jpg"}, + "footer": {"text": "Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "Every mile you drive to work produces about a pound of C0₂. That's crazy! What's crazier is how clean the planet could be if we spent less time in the car!", + "inline": false + }, + + { + "name": "What you can do", + "value": "Instead of using your car, ride your bike if possible! Not only does it save that pound of C0₂, it is also great exercise and is cheaper!", + "inline": false + } + ] + }, + + { + "title":"Paint your roof white!", + "image": {"url": "https://modernize.com/wp-content/uploads/2016/10/Cool-roof.jpg"}, + "footer": {"text":"Help out by sharing this information!"}, + "fields": [ + { + "name": "The problem", + "value": "People with dark roofs often spend 20 to 40% more on their electricity bills because of the extra heat, which means more electricity needs to be made, and a lot of it isn't renewable.", + "inline": false + }, + + { + "name":"What you can do", + "value": "Having a light colored roof will save you money, and also researchers at the Lawrence Berkeley National Laboratory estimated that if 80 percent of roofs in tropical and temperate climate areas were painted white, it could offset the greenhouse gas emissions of 300 million automobiles around the world.", + "inline": false + } + ] + } +] |