From 41e658d7ef8ae5f207c75b4bf89befdcb7245a4f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 23 Sep 2018 17:18:56 +0200 Subject: Add GET support for new Tag API. --- bot/__main__.py | 3 ++ bot/api.py | 30 +++++++++++++++++++ bot/cogs/events.py | 17 ++++++++--- bot/cogs/tags.py | 87 ++++++++++++++++++------------------------------------ 4 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 bot/api.py diff --git a/bot/__main__.py b/bot/__main__.py index 30d1b4c9a..0055e19ba 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,3 +1,4 @@ +import asyncio import logging import socket @@ -5,6 +6,7 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Game from discord.ext.commands import Bot, when_mentioned_or +from bot.api import APIClient from bot.constants import Bot as BotConfig, DEBUG_MODE from bot.utils.service_discovery import wait_for_rmq @@ -27,6 +29,7 @@ bot.http_session = ClientSession( family=socket.AF_INET, ) ) +bot.api_client = APIClient(loop=asyncio.get_event_loop()) log.info("Waiting for RabbitMQ...") has_rmq = wait_for_rmq() diff --git a/bot/api.py b/bot/api.py new file mode 100644 index 000000000..6b9598da2 --- /dev/null +++ b/bot/api.py @@ -0,0 +1,30 @@ +from urllib.parse import quote as quote_url + +import aiohttp + +from .constants import Keys, URLs + + +class APIClient: + def __init__(self, **kwargs): + auth_headers = { + 'Authorization': f"Token {Keys.site_api}" + } + + if 'headers' in kwargs: + kwargs['headers'].update(auth_headers) + else: + kwargs['headers'] = auth_headers + + self.session = aiohttp.ClientSession( + **kwargs, + raise_for_status=True + ) + + @staticmethod + def _url_for(endpoint: str): + return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" + + async def get(self, endpoint: str, *args, **kwargs): + async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: + return await resp.json() diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 0b9b75a00..281e212ff 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -1,5 +1,6 @@ import logging +from aiohttp import ClientResponseError from discord import Colour, Embed, Member, Object from discord.ext.commands import ( BadArgument, Bot, BotMissingPermissions, @@ -134,10 +135,18 @@ class Events: f"Here's what I'm missing: **{e.missing_perms}**" ) elif isinstance(e, CommandInvokeError): - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original + if isinstance(e.original, ClientResponseError): + return await ctx.send("There was some response error but I can't put my finger on what exactly.") + if e.original.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + else: + await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") + + else: + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" + ) + raise e.original raise e async def on_ready(self): diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7499b2b1c..baed44a8c 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -85,7 +85,7 @@ class Tags: def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self.headers = {"X-API-KEY": Keys.site_api} + self.headers = {"Authorization": f"Token {Keys.site_api}"} async def get_tag_data(self, tag_name=None) -> dict: """ @@ -97,12 +97,13 @@ class Tags: if not, returns a list of dicts with all tag data. """ - params = {} if tag_name: - params["tag_name"] = tag_name + url = f'{URLs.site_tags_api}/{tag_name}' + else: + url = URLs.site_tags_api - response = await self.bot.http_session.get(URLs.site_tags_api, headers=self.headers, params=params) + response = await self.bot.http_session.get(url, headers=self.headers) tag_data = await response.json() return tag_data @@ -196,64 +197,32 @@ class Tags: f"Cooldown ends in {time_left:.1f} seconds.") return - tags = [] - - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.get_tag_data(tag_name) - - # If we found something, prepare that data - if tag_data: - embed.colour = Colour.blurple() - - if tag_name: - log.debug(f"{ctx.author} requested the tag '{tag_name}'") - embed.title = tag_name + if tag_name is not None: + tag = await self.bot.api_client.get(f'/bot/tags/{tag_name}') + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + await ctx.send(embed=Embed.from_data(tag['embed'])) - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - else: - embed.title = "**Current tags**" - - if isinstance(tag_data, list): - log.debug(f"{ctx.author} requested a list of all tags") - tags = [f"**»** {tag['tag_name']}" for tag in tag_data] - tags = sorted(tags) - - else: - embed.description = tag_data['tag_content'] - - # If not, prepare an error message. else: - embed.colour = Colour.red() - - if isinstance(tag_data, dict): - log.warning(f"{ctx.author} requested the tag '{tag_name}', but it could not be found.") - embed.description = f"**{tag_name}** is an unknown tag name. Please check the spelling and try again." + tags = await self.bot.api_client.get('/bot/tags') + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) else: - log.warning(f"{ctx.author} requested a list of all tags, but the tags database was empty!") - embed.description = "**There are no tags in the database!**" - - if tag_name: - embed.set_footer(text="To show a list of all tags, use !tags.") - embed.title = "Tag not found." - - # Paginate if this is a list of all tags - if tags: - log.debug(f"Returning a paginated list of all tags.") - return await LinePaginator.paginate( - (lines for lines in tags), - ctx, embed, - footer_text="To show a tag, type !tags .", - empty=False, - max_lines=15 - ) - - return await ctx.send(embed=embed) + embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in tags), + ctx, + embed, + footer_text="To show a tag, type !tags .", + empty=False, + max_lines=15 + ) @tags_group.command(name='set', aliases=('add', 'edit', 's')) @with_role(Roles.admin, Roles.owner, Roles.moderator) -- cgit v1.2.3 From 6f6465ee1dfa5caa66d0fbc113b0c7994c1d3b68 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 13:21:13 +0100 Subject: Add `delete` and `post` API methods. --- bot/api.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/api.py b/bot/api.py index 6b9598da2..20522d154 100644 --- a/bot/api.py +++ b/bot/api.py @@ -28,3 +28,11 @@ class APIClient: async def get(self, endpoint: str, *args, **kwargs): async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() + + async def post(self, endpoint: str, *args. **kwargs): + async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: + return await resp.json() + + async def delete(self, endpoint: str, *args. **kwargs): + async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + return await resp.json() -- cgit v1.2.3 From 8938871b6c522eeba519224fa864d55b5a7d099f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 13:26:10 +0100 Subject: Remove obsolete method. --- bot/cogs/tags.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 85244b835..d9bee6608 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -89,27 +89,6 @@ class Tags: self.tag_cooldowns = {} self.headers = {"Authorization": f"Token {Keys.site_api}"} - async def get_tag_data(self, tag_name=None) -> dict: - """ - Retrieve the tag_data from our API - - :param tag_name: the tag to retrieve - :return: - if tag_name was provided, returns a dict with tag data. - if not, returns a list of dicts with all tag data. - - """ - - if tag_name: - url = f'{URLs.site_tags_api}/{tag_name}' - else: - url = URLs.site_tags_api - - response = await self.bot.http_session.get(url, headers=self.headers) - tag_data = await response.json() - - return tag_data - async def post_tag_data(self, tag_name: str, tag_content: str, image_url: Optional[str]) -> dict: """ Send some tag_data to our API to have it saved in the database. -- cgit v1.2.3 From d205b91f3ead15bd012ca0adf6d9f313d266f1b8 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 13:48:14 +0100 Subject: Add support for adding new tags through the new API. --- bot/cogs/tags.py | 63 +++++++++++++++++++------------------------------------- 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index d9bee6608..578f5e7c8 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -181,7 +181,7 @@ class Tags: return if tag_name is not None: - tag = await self.bot.api_client.get(f'/bot/tags/{tag_name}') + tag = await self.bot.api_client.get(f'bot/tags/{tag_name}') if ctx.channel.id not in TEST_CHANNELS: self.tag_cooldowns[tag_name] = { "time": time.time(), @@ -190,7 +190,7 @@ class Tags: await ctx.send(embed=Embed.from_data(tag['embed'])) else: - tags = await self.bot.api_client.get('/bot/tags') + tags = await self.bot.api_client.get('bot/tags') if not tags: await ctx.send(embed=Embed( description="**There are no tags in the database!**", @@ -207,50 +207,42 @@ class Tags: max_lines=15 ) - @tags_group.command(name='set', aliases=('add', 'edit', 's')) + @tags_group.command(name='set', aliases=('add', 's')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def set_command( self, ctx: Context, tag_name: TagNameConverter, + *, tag_content: TagContentConverter, - image_url: ValidURL = None ): """ - Create a new tag or edit an existing one. + Create a new tag or update an existing one. :param ctx: discord message context :param tag_name: The name of the tag to create or edit. :param tag_content: The content of the tag. - :param image_url: An optional image for the tag. """ - tag_name = tag_name.lower().strip() - tag_content = tag_content.strip() + body = { + 'title': tag_name.lower().strip(), + 'embed': { + 'title': tag_name, + 'description': tag_content + } + } - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.post_tag_data(tag_name, tag_content, image_url) + await self.bot.api_client.post('bot/tags', json=body) - if tag_data.get("success"): - log.debug(f"{ctx.author} successfully added the following tag to our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'") - embed.colour = Colour.blurple() - embed.title = "Tag successfully added" - embed.description = f"**{tag_name}** added to tag database." - else: - log.error("There was an unexpected database error when trying to add the following tag: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was a problem adding the data to the tags database. " - "Please try again. If the problem persists, see the error logs.") + log.debug(f"{ctx.author} successfully added the following tag to our database: \n" + f"tag_name: {tag_name}\n" + f"tag_content: '{tag_content}'\n") - return await ctx.send(embed=embed) + await ctx.send(embed=Embed( + title="Tag successfully added", + description=f"**{tag_name}** added to tag database.", + colour=Colour.blurple() + )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(Roles.admin, Roles.owner) @@ -289,19 +281,6 @@ class Tags: return await ctx.send(embed=embed) - @get_command.error - @set_command.error - @delete_command.error - async def command_error(self, ctx, error): - if isinstance(error, BadArgument): - embed = Embed() - embed.colour = Colour.red() - embed.description = str(error) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - else: - log.error(f"Unhandled tag command error: {error} ({error.original})") - def setup(bot): bot.add_cog(Tags(bot)) -- cgit v1.2.3 From 2fa07bd619040dd7766601b7e4966efb6b2503eb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 13:48:31 +0100 Subject: Fix a typo. --- bot/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/api.py b/bot/api.py index 20522d154..04b684b38 100644 --- a/bot/api.py +++ b/bot/api.py @@ -29,10 +29,10 @@ class APIClient: async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() - async def post(self, endpoint: str, *args. **kwargs): + async def post(self, endpoint: str, *args, **kwargs): async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() - async def delete(self, endpoint: str, *args. **kwargs): + async def delete(self, endpoint: str, *args, **kwargs): async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() -- cgit v1.2.3 From e5f5054d1b398fe45d00e1359b32130d49a5584d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:08:45 +0100 Subject: Remove obsolete error handler. --- bot/cogs/tags.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 578f5e7c8..9507632af 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -89,30 +89,6 @@ class Tags: self.tag_cooldowns = {} self.headers = {"Authorization": f"Token {Keys.site_api}"} - async def post_tag_data(self, tag_name: str, tag_content: str, image_url: Optional[str]) -> dict: - """ - Send some tag_data to our API to have it saved in the database. - - :param tag_name: The name of the tag to create or edit. - :param tag_content: The content of the tag. - :param image_url: The image URL of the tag, can be `None`. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = { - 'tag_name': tag_name, - 'tag_content': tag_content, - 'image_url': image_url - } - - response = await self.bot.http_session.post(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data - async def delete_tag_data(self, tag_name: str) -> dict: """ Delete a tag using our API. -- cgit v1.2.3 From 57a8078fd5a322a03f1d1979574a17736f9222ae Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:14:14 +0100 Subject: Return `None` on `delete` status 204. --- bot/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/api.py b/bot/api.py index 04b684b38..f8757d3d0 100644 --- a/bot/api.py +++ b/bot/api.py @@ -35,4 +35,6 @@ class APIClient: async def delete(self, endpoint: str, *args, **kwargs): async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + if resp.status == 204: + return None return await resp.json() -- cgit v1.2.3 From 3fbf97639ee8325fbc8b6ff1f64c34c73160223f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:14:30 +0100 Subject: Properly display 404s. --- bot/cogs/events.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 281e212ff..9a0b0b106 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -136,8 +136,7 @@ class Events: ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ClientResponseError): - return await ctx.send("There was some response error but I can't put my finger on what exactly.") - if e.original.status == 404: + if e.original.code == 404: await ctx.send("There does not seem to be anything matching your query.") else: await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") -- cgit v1.2.3 From 3ee38308dd1bd7f0648a736692cdc50592ecc1d8 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:14:42 +0100 Subject: Rewrite `delete` to use new tags API. --- bot/cogs/tags.py | 55 ++++++++----------------------------------------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9507632af..3bef8ab0b 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -89,27 +89,6 @@ class Tags: self.tag_cooldowns = {} self.headers = {"Authorization": f"Token {Keys.site_api}"} - async def delete_tag_data(self, tag_name: str) -> dict: - """ - Delete a tag using our API. - - :param tag_name: The name of the tag to delete. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = {} - - if tag_name: - params['tag_name'] = tag_name - - response = await self.bot.http_session.delete(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data - @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter=None): """Show all known tags, a single tag, or run a subcommand.""" @@ -183,7 +162,7 @@ class Tags: max_lines=15 ) - @tags_group.command(name='set', aliases=('add', 's')) + @tags_group.command(name='set', aliases=('add', 'edit', 's')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def set_command( self, @@ -230,32 +209,14 @@ class Tags: :param tag_name: The name of the tag to delete. """ - tag_name = tag_name.lower().strip() - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.delete_tag_data(tag_name) - - if tag_data.get("success") is True: - log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") - embed.colour = Colour.blurple() - embed.title = tag_name - embed.description = f"Tag successfully removed: {tag_name}." - - elif tag_data.get("success") is False: - log.debug(f"{ctx.author} tried to delete a tag called '{tag_name}', but the tag does not exist.") - embed.colour = Colour.red() - embed.title = tag_name - embed.description = "Tag doesn't appear to exist." + await self.bot.api_client.delete(f'bot/tags/{tag_name}') - else: - log.error("There was an unexpected database error when trying to delete the following tag: \n" - f"tag_name: {tag_name}\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was an unexpected error with deleting the data from the tags database. " - "Please try again. If the problem persists, see the error logs.") - - return await ctx.send(embed=embed) + log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") + await ctx.send(embed=Embed( + title=tag_name, + description=f"Tag successfully removed: {tag_name}.", + colour=Colour.blurple() + )) def setup(bot): -- cgit v1.2.3 From c23ad7e4c69016391634967d5d5367b0d8640246 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:34:00 +0100 Subject: Use the new Django API in the `OffTopicNames` cog. --- bot/cogs/off_topic_names.py | 70 ++++++++++++++------------------------------- 1 file changed, 21 insertions(+), 49 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index ac2e1269c..b2a612cb4 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -44,7 +44,7 @@ async def update_names(bot: Bot, headers: dict): Args: bot (Bot): The running bot instance, used for fetching data from the - website via the bot's `http_session`. + website via the bot's `api_client`. """ while True: @@ -53,11 +53,9 @@ async def update_names(bot: Bot, headers: dict): seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds await asyncio.sleep(seconds_to_sleep) - response = await bot.http_session.get( - f'{URLs.site_off_topic_names_api}?random_items=3', - headers=headers + channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( + 'bot/off-topic-channel-names', params={'random_items': 3} ) - channel_0_name, channel_1_name, channel_2_name = await response.json() channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) await channel_0.edit(name=f'ot0-{channel_0_name}') @@ -96,49 +94,24 @@ class OffTopicNames: async def add_command(self, ctx, name: OffTopicName): """Adds a new off-topic name to the rotation.""" - result = await self.bot.http_session.post( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" added the off-topic channel name '{name}" ) - - response = await result.json() - - if result.status == 200: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + await ctx.send(":ok_hand:") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def delete_command(self, ctx, name: OffTopicName): """Removes a off-topic name from the rotation.""" - result = await self.bot.http_session.delete( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" deleted the off-topic channel name '{name}" ) - - response = await result.json() - - if result.status == 200: - if response['deleted'] == 0: - await ctx.send(f":warning: No name matching `{name}` was found in the database.") - else: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" deleted the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + await ctx.send(":ok_hand:") @otname_group.command(name='list', aliases=('l',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -148,18 +121,17 @@ class OffTopicNames: Restricted to Moderator and above to not spoil the surprise. """ - result = await self.bot.http_session.get( - URLs.site_off_topic_names_api, - headers=self.headers - ) - response = await result.json() - lines = sorted(f"• {name}" for name in response) - + result = await self.bot.api_client.get('bot/off-topic-channel-names') + lines = sorted(f"• {name}" for name in result) embed = Embed( - title=f"Known off-topic names (`{len(response)}` total)", + title=f"Known off-topic names (`{len(result)}` total)", colour=Colour.blue() ) - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) def setup(bot: Bot): -- cgit v1.2.3 From 797f8ed27575aeb6234c570f31541a10beb8820e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:35:56 +0100 Subject: Remove unused imports. --- bot/cogs/off_topic_names.py | 2 +- bot/cogs/tags.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index b2a612cb4..fd670b4c6 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from discord import Colour, Embed from discord.ext.commands import BadArgument, Bot, Context, Converter, group -from bot.constants import Channels, Keys, Roles, URLs +from bot.constants import Channels, Keys, Roles from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3bef8ab0b..61b8eee57 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,5 @@ import logging -import random import time -from typing import Optional from discord import Colour, Embed from discord.ext.commands import ( @@ -9,10 +7,7 @@ from discord.ext.commands import ( Context, Converter, group ) -from bot.constants import ( - Channels, Cooldowns, ERROR_REPLIES, Keys, Roles, URLs -) -from bot.converters import ValidURL +from bot.constants import Channels, Cooldowns, Keys, Roles from bot.decorators import with_role from bot.pagination import LinePaginator -- cgit v1.2.3 From 78e3ec87170aa7740572081d09b23c4173fbbea6 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 10 Nov 2018 14:57:07 +0100 Subject: Use the new Django API in the `Doc` cog. --- bot/cogs/doc.py | 152 ++++++++------------------------------------------------ 1 file changed, 22 insertions(+), 130 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2b310f11c..cdc2c0bb4 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -1,11 +1,10 @@ import asyncio import functools import logging -import random import re import textwrap from collections import OrderedDict -from typing import Dict, List, Optional, Tuple +from typing import Optional, Tuple import discord from bs4 import BeautifulSoup @@ -14,7 +13,7 @@ from markdownify import MarkdownConverter from requests import ConnectionError from sphinx.ext import intersphinx -from bot.constants import ERROR_REPLIES, Keys, Roles, URLs +from bot.constants import Keys, Roles from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -179,7 +178,7 @@ class Doc: coros = [ self.update_single( package["package"], package["base_url"], package["inventory_url"], config - ) for package in await self.get_all_packages() + ) for package in await self.bot.api_client.get('bot/documentation-links') ] await asyncio.gather(*coros) @@ -267,95 +266,6 @@ class Doc: description=f"```py\n{signature}```{description}" ) - async def get_all_packages(self) -> List[Dict[str, str]]: - """ - Performs HTTP GET to get all packages from the website. - - :return: - A list of packages, in the following format: - [ - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - }, - ... - ] - `package` specifies the package name, for example 'aiohttp'. - `base_url` specifies the documentation root URL, used to build absolute links. - `inventory_url` specifies the location of the Intersphinx inventory. - """ - - async with self.bot.http_session.get(URLs.site_docs_api, headers=self.headers) as resp: - return await resp.json() - - async def get_package(self, package_name: str) -> Optional[Dict[str, str]]: - """ - Performs HTTP GET to get the specified package from the documentation database. - - :param package_name: The package name for which information should be returned. - :return: - Either a dictionary with information in the following format: - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - } - or `None` if the site didn't returned no results for the given name. - """ - - params = {"package": package_name} - - async with self.bot.http_session.get(URLs.site_docs_api, - headers=self.headers, - params=params) as resp: - package_data = await resp.json() - if not package_data: - return None - return package_data[0] - - async def set_package(self, name: str, base_url: str, inventory_url: str) -> Dict[str, bool]: - """ - Performs HTTP POST to add a new package to the website's documentation database. - - :param name: The name of the package, for example `aiohttp`. - :param base_url: The documentation root URL, used to build absolute links. - :param inventory_url: The absolute URl to the intersphinx inventory of the package. - - :return: The JSON response of the server, which is always: - { - "success": True - } - """ - - package_json = { - 'package': name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - - async with self.bot.http_session.post(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - return await resp.json() - - async def delete_package(self, name: str) -> bool: - """ - Performs HTTP DELETE to delete the specified package from the documentation database. - - :param name: The package to delete. - - :return: `True` if successful, `False` if the package is unknown. - """ - - package_json = {'package': name} - - async with self.bot.http_session.delete(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - changes = await resp.json() - return changes["deleted"] == 1 # Did the package delete successfully? - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx, symbol: commands.clean_content = None): """Lookup documentation for Python symbols.""" @@ -386,7 +296,12 @@ class Doc: ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) else: # Fetching documentation for a symbol (at least for the first time, since @@ -427,7 +342,13 @@ class Doc: https://discordpy.readthedocs.io/en/rewrite/objects.inv """ - await self.set_package(package_name, base_url, inventory_url) + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) + log.info( f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " "added a new documentation package:\n" @@ -455,42 +376,13 @@ class Doc: !docs delete aiohttp """ - success = await self.delete_package(package_name) - if success: + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - else: - await ctx.send( - f"Can't find any package named `{package_name}` in the database. " - "View all known packages by using `docs.get()`." - ) - - @get_command.error - @delete_command.error - @set_command.error - async def general_command_error(self, ctx, error: commands.CommandError): - """ - Handle the `BadArgument` error caused by - the commands when argument validation fails. - - :param ctx: Discord message context of the message creating the error - :param error: The error raised, usually `BadArgument` - """ - - if isinstance(error, commands.BadArgument): - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - description=f"Error: {error}", - colour=discord.Colour.red() - ) - await ctx.send(embed=embed) - else: - log.exception(f"Unhandled error: {error}") + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") def setup(bot): -- cgit v1.2.3 From beacde2f00791aac8988a79a655d5d56d3194783 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 18 Nov 2018 23:52:53 +0100 Subject: Add a simple synchronization cog. --- bot/__main__.py | 3 +- bot/cogs/sync.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 bot/cogs/sync.py diff --git a/bot/__main__.py b/bot/__main__.py index 0018cba9c..666c2d61f 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -59,8 +59,8 @@ if not DEBUG_MODE: bot.load_extension("bot.cogs.verification") # Feature cogs -bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.defcon") +bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.hiphopify") @@ -71,6 +71,7 @@ bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.snekbox") +bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") diff --git a/bot/cogs/sync.py b/bot/cogs/sync.py new file mode 100644 index 000000000..cccaa7d28 --- /dev/null +++ b/bot/cogs/sync.py @@ -0,0 +1,86 @@ +import logging +from collections import namedtuple +from typing import Callable, Iterable + +from discord import Guild, Role +from discord.ext.commands import Bot + + +log = logging.getLogger(__name__) +Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) + + +async def sync_roles(bot: Bot, guild: Guild): + """ + Synchronize roles found on the given `guild` with the ones on the API. + """ + + def convert_role(role: Role): + return { + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + + roles = await bot.api_client.get('bot/roles') + site_roles = { + Role(**role_dict) + for role_dict in roles + } + server_roles = { + Role( + id=role.id, name=role.name, + colour=role.colour.value, permissions=role.permissions.value + ) + for role in guild.roles + } + roles_to_update = server_roles - site_roles + + for role in roles_to_update: + log.info(f"Updating role `{role.name}` on the site.") + await bot.api_client.post( + 'bot/roles', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + ) + + +async def sync_members(bot: Bot, guild: Guild): + """ + Synchronize members found on the given `guild` with the ones on the API. + """ + + current_members = await bot.api_client.get('bot/members') + + +class Sync: + """Captures relevant events and sends them to the site.""" + + # The server to synchronize events on. + # Note that setting this wrongly will result in things getting deleted + # that possibly shouldn't be. + SYNC_SERVER_ID = 267624335836053506 + + # An iterable of callables that are called when the bot is ready. + ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( + sync_roles, + ) + + def __init__(self, bot): + self.bot = bot + + async def on_ready(self): + guild = self.bot.get_guild(self.SYNC_SERVER_ID) + if guild is not None: + for syncer in self.ON_READY_SYNCERS: + await syncer(self.bot, guild) + + +def setup(bot): + bot.add_cog(Sync(bot)) + log.info("Cog loaded: Sync") -- cgit v1.2.3 From 7a986b271cbe592966cf97e6a249216eedf3c6d7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 5 Dec 2018 21:52:59 +0100 Subject: Remove superfluous `self.headers` setting. --- bot/cogs/doc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index cdc2c0bb4..860ec7f62 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -125,7 +125,6 @@ class Doc: self.base_urls = {} self.bot = bot self.inventories = {} - self.headers = {"X-API-KEY": Keys.site_api} async def on_ready(self): await self.refresh_inventory() -- cgit v1.2.3 From c866deefe17bcfe89163c31294f01ea31acf305a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 19:27:17 +0100 Subject: Fix another merge conflict that was leftover. --- bot/__main__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index c598fd921..e280a2479 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -60,11 +60,8 @@ if not DEBUG_MODE: bot.load_extension("bot.cogs.verification") # Feature cogs -<<<<<<< HEAD -======= bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.deployment") ->>>>>>> master bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") -- cgit v1.2.3 From abe2f0db3619f332bc6e0f817dcaffaecf10da0a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 22:30:54 +0100 Subject: Factor out sync cog to package. --- bot/cogs/sync.py | 86 ----------------------------------------------- bot/cogs/sync/__init__.py | 10 ++++++ bot/cogs/sync/cog.py | 29 ++++++++++++++++ bot/cogs/sync/syncers.py | 59 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 86 deletions(-) delete mode 100644 bot/cogs/sync.py create mode 100644 bot/cogs/sync/__init__.py create mode 100644 bot/cogs/sync/cog.py create mode 100644 bot/cogs/sync/syncers.py diff --git a/bot/cogs/sync.py b/bot/cogs/sync.py deleted file mode 100644 index cccaa7d28..000000000 --- a/bot/cogs/sync.py +++ /dev/null @@ -1,86 +0,0 @@ -import logging -from collections import namedtuple -from typing import Callable, Iterable - -from discord import Guild, Role -from discord.ext.commands import Bot - - -log = logging.getLogger(__name__) -Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) - - -async def sync_roles(bot: Bot, guild: Guild): - """ - Synchronize roles found on the given `guild` with the ones on the API. - """ - - def convert_role(role: Role): - return { - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions - } - - roles = await bot.api_client.get('bot/roles') - site_roles = { - Role(**role_dict) - for role_dict in roles - } - server_roles = { - Role( - id=role.id, name=role.name, - colour=role.colour.value, permissions=role.permissions.value - ) - for role in guild.roles - } - roles_to_update = server_roles - site_roles - - for role in roles_to_update: - log.info(f"Updating role `{role.name}` on the site.") - await bot.api_client.post( - 'bot/roles', - json={ - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions - } - ) - - -async def sync_members(bot: Bot, guild: Guild): - """ - Synchronize members found on the given `guild` with the ones on the API. - """ - - current_members = await bot.api_client.get('bot/members') - - -class Sync: - """Captures relevant events and sends them to the site.""" - - # The server to synchronize events on. - # Note that setting this wrongly will result in things getting deleted - # that possibly shouldn't be. - SYNC_SERVER_ID = 267624335836053506 - - # An iterable of callables that are called when the bot is ready. - ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( - sync_roles, - ) - - def __init__(self, bot): - self.bot = bot - - async def on_ready(self): - guild = self.bot.get_guild(self.SYNC_SERVER_ID) - if guild is not None: - for syncer in self.ON_READY_SYNCERS: - await syncer(self.bot, guild) - - -def setup(bot): - bot.add_cog(Sync(bot)) - log.info("Cog loaded: Sync") diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..e4f960620 --- /dev/null +++ b/bot/cogs/sync/__init__.py @@ -0,0 +1,10 @@ +import logging + +from .cog import Sync + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(Sync(bot)) + log.info("Cog loaded: Sync") diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py new file mode 100644 index 000000000..8ef45aa50 --- /dev/null +++ b/bot/cogs/sync/cog.py @@ -0,0 +1,29 @@ +from typing import Callable, Iterable + +from discord import Guild +from discord.ext.commands import Bot + +from . import syncers + + +class Sync: + """Captures relevant events and sends them to the site.""" + + # The server to synchronize events on. + # Note that setting this wrongly will result in things getting deleted + # that possibly shouldn't be. + SYNC_SERVER_ID = 267624335836053506 + + # An iterable of callables that are called when the bot is ready. + ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( + syncers.sync_roles, + ) + + def __init__(self, bot): + self.bot = bot + + async def on_ready(self): + guild = self.bot.get_guild(self.SYNC_SERVER_ID) + if guild is not None: + for syncer in self.ON_READY_SYNCERS: + await syncer(self.bot, guild) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py new file mode 100644 index 000000000..e1e51aea0 --- /dev/null +++ b/bot/cogs/sync/syncers.py @@ -0,0 +1,59 @@ +import logging +from collections import namedtuple + +from discord import Guild +from discord.ext.commands import Bot + +log = logging.getLogger(__name__) +Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) + + +async def sync_roles(bot: Bot, guild: Guild): + """ + Synchronize roles found on the given `guild` with the ones on the API. + """ + + def convert_role(role: Role): + return { + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + + roles = await bot.api_client.get('bot/roles') + site_roles = { + Role(**role_dict) + for role_dict in roles + } + server_roles = { + Role( + id=role.id, name=role.name, + colour=role.colour.value, permissions=role.permissions.value + ) + for role in guild.roles + } + roles_to_update = server_roles - site_roles + + for role in roles_to_update: + log.info(f"Updating role `{role.name}` on the site.") + await bot.api_client.post( + 'bot/roles', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + ) + + +async def sync_members(bot: Bot, guild: Guild): + """ + Synchronize members found on the given `guild` with the ones on the API. + """ + + current_members = await bot.api_client.get('bot/members') + site_members = { + } + -- cgit v1.2.3 From e2de3a30c7bac27ad6b53e2fa93980be2888ea04 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 23:11:45 +0100 Subject: Implement basic member syncing. --- bot/cogs/sync/cog.py | 1 + bot/cogs/sync/syncers.py | 114 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 8ef45aa50..84175745a 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -17,6 +17,7 @@ class Sync: # An iterable of callables that are called when the bot is ready. ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( syncers.sync_roles, + syncers.sync_users ) def __init__(self, bot): diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index e1e51aea0..6d200f370 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,11 +1,36 @@ +import itertools import logging from collections import namedtuple +from typing import Dict, Set, ValuesView from discord import Guild from discord.ext.commands import Bot log = logging.getLogger(__name__) + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) +User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar', 'roles', 'in_guild')) + + +def get_roles_for_update(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role]: + """ + Determine which roles should be updated on the site. + + Arguments: + guild_roles (Set[Role]): + Roles that were found on the guild at startup. + + api_roles (Set[Role]): + Roles that were retrieved from the API at startup. + + Returns: + Set[Role]: + Roles to be sent to the site for an update or insert. + """ + + return guild_roles - api_roles async def sync_roles(bot: Bot, guild: Guild): @@ -13,27 +38,16 @@ async def sync_roles(bot: Bot, guild: Guild): Synchronize roles found on the given `guild` with the ones on the API. """ - def convert_role(role: Role): - return { - 'id': role.id, - 'name': role.name, - 'colour': role.colour, - 'permissions': role.permissions - } - roles = await bot.api_client.get('bot/roles') - site_roles = { - Role(**role_dict) - for role_dict in roles - } - server_roles = { + api_roles = {Role(**role_dict) for role_dict in roles} + guild_roles = { Role( id=role.id, name=role.name, colour=role.colour.value, permissions=role.permissions.value ) for role in guild.roles } - roles_to_update = server_roles - site_roles + roles_to_update = get_roles_for_update(guild_roles, api_roles) for role in roles_to_update: log.info(f"Updating role `{role.name}` on the site.") @@ -48,12 +62,74 @@ async def sync_roles(bot: Bot, guild: Guild): ) -async def sync_members(bot: Bot, guild: Guild): +def get_users_for_update( + guild_users: Dict[int, User], api_users: Dict[int, User] +) -> ValuesView[User]: """ - Synchronize members found on the given `guild` with the ones on the API. + Obtain a set of users to update on the website. """ - current_members = await bot.api_client.get('bot/members') - site_members = { - } + api_user_ids = set(api_users.keys()) + guild_user_ids = set(guild_users.keys()) + left_user_ids = api_user_ids - guild_user_ids + + api_users.update(guild_users) + for left_id in left_user_ids: + if left_id in api_users: + user = api_users[left_id] + log.debug( + "User `%s#%s` (`%d`) left since the last sync, updating `in_guild` field.", + user.name, user.discriminator, user.discriminator + ) + api_users[left_id]._replace(in_guild=False) + return api_users.values() + + +# Taken from `https://docs.python.org/3.7/library/itertools.html#itertools-recipes`. +def chunk_by(iterable, n, fillvalue=None): + "Collect data into fixed-length chunks or blocks" + args = [iter(iterable)] * n + return itertools.zip_longest(*args, fillvalue=fillvalue) + + +async def sync_users(bot: Bot, guild: Guild): + """ + Synchronize users found on the given + `guild` with the ones on the API. + """ + + current_users = await bot.api_client.get('bot/users') + api_users = { + user_dict['id']: User( + roles=set(user_dict.pop('roles')), + **user_dict + ) + for user_dict in current_users + } + guild_users = { + member.id: User( + id=member.id, name=member.name, + discriminator=member.discriminator, avatar=member.avatar, + roles={role.id for role in member.roles}, in_guild=True + ) + for member in guild.members + } + users_to_update = get_users_for_update(guild_users, api_users) + log.info("Updating a total of `%d` users on the site.", len(users_to_update)) + for chunk in chunk_by(users_to_update, n=250): + await bot.api_client.post( + 'bot/users', + json=[ + { + 'avatar': user.avatar, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + for user in chunk + ] + ) + log.info("User update complete.") -- cgit v1.2.3 From 0173a2ea4f52152475c6841896f2ba25419b9e70 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 23:28:44 +0100 Subject: Send users one by one. --- bot/cogs/sync/syncers.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6d200f370..6235a8243 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) -User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar', 'roles', 'in_guild')) +User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) def get_roles_for_update(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role]: @@ -51,7 +51,7 @@ async def sync_roles(bot: Bot, guild: Guild): for role in roles_to_update: log.info(f"Updating role `{role.name}` on the site.") - await bot.api_client.post( + await bot.api_client.put( 'bot/roles', json={ 'id': role.id, @@ -110,26 +110,23 @@ async def sync_users(bot: Bot, guild: Guild): guild_users = { member.id: User( id=member.id, name=member.name, - discriminator=member.discriminator, avatar=member.avatar, + discriminator=member.discriminator, avatar_hash=member.avatar, roles={role.id for role in member.roles}, in_guild=True ) for member in guild.members } users_to_update = get_users_for_update(guild_users, api_users) log.info("Updating a total of `%d` users on the site.", len(users_to_update)) - for chunk in chunk_by(users_to_update, n=250): - await bot.api_client.post( - 'bot/users', - json=[ - { - 'avatar': user.avatar, - 'discriminator': user.discriminator, - 'id': user.id, - 'in_guild': user.in_guild, - 'name': user.name, - 'roles': list(user.roles) - } - for user in chunk - ] + for user in users_to_update: + await bot.api_client.put( + 'bot/users/' + str(user.id), + json={ + 'avatar': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } ) log.info("User update complete.") -- cgit v1.2.3 From 37430e4e69d3a522d6384a1d1a97c005c866705d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 23:28:56 +0100 Subject: Add `PUT` support in API client. --- bot/api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/api.py b/bot/api.py index f8757d3d0..0a2c192ce 100644 --- a/bot/api.py +++ b/bot/api.py @@ -33,6 +33,10 @@ class APIClient: async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() + async def put(self, endpoint: str, *args, **kwargs): + async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: + return await resp.json() + async def delete(self, endpoint: str, *args, **kwargs): async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: if resp.status == 204: -- cgit v1.2.3 From 667105dc059a73838e49a35dc9ee4e0222a0c7c1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 19:22:30 +0100 Subject: Sync updated users since last boot. --- bot/cogs/sync/cog.py | 6 ++++++ bot/cogs/sync/syncers.py | 37 ++++++++++++++++++------------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 84175745a..959e26eeb 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,3 +1,4 @@ +import logging from typing import Callable, Iterable from discord import Guild @@ -5,6 +6,8 @@ from discord.ext.commands import Bot from . import syncers +log = logging.getLogger(__name__) + class Sync: """Captures relevant events and sends them to the site.""" @@ -27,4 +30,7 @@ class Sync: guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: + syncer_name = syncer.__name__[5:] # drop off `sync_` + log.info("Starting `%s` syncer.", syncer_name) await syncer(self.bot, guild) + log.info("`%s` syncer finished.", syncer_name) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 6235a8243..9fff72320 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -69,21 +69,17 @@ def get_users_for_update( Obtain a set of users to update on the website. """ - api_user_ids = set(api_users.keys()) - guild_user_ids = set(guild_users.keys()) - left_user_ids = api_user_ids - guild_user_ids - - api_users.update(guild_users) - for left_id in left_user_ids: - if left_id in api_users: - user = api_users[left_id] - log.debug( - "User `%s#%s` (`%d`) left since the last sync, updating `in_guild` field.", - user.name, user.discriminator, user.discriminator - ) - api_users[left_id]._replace(in_guild=False) - - return api_users.values() + users_to_update = set() + for api_user in api_users.values(): + guild_user = guild_users.get(api_user.id) + if guild_user is not None: + if api_user != guild_user: + users_to_update.add(guild_user) + else: + # User left + api_user._replace(in_guild=False) + users_to_update.add(guild_user) + return users_to_update # Taken from `https://docs.python.org/3.7/library/itertools.html#itertools-recipes`. @@ -102,7 +98,7 @@ async def sync_users(bot: Bot, guild: Guild): current_users = await bot.api_client.get('bot/users') api_users = { user_dict['id']: User( - roles=set(user_dict.pop('roles')), + roles=tuple(sorted(user_dict.pop('roles'))), **user_dict ) for user_dict in current_users @@ -110,18 +106,21 @@ async def sync_users(bot: Bot, guild: Guild): guild_users = { member.id: User( id=member.id, name=member.name, - discriminator=member.discriminator, avatar_hash=member.avatar, - roles={role.id for role in member.roles}, in_guild=True + discriminator=int(member.discriminator), avatar_hash=member.avatar, + roles=tuple(sorted(role.id for role in member.roles)), in_guild=True ) for member in guild.members } users_to_update = get_users_for_update(guild_users, api_users) log.info("Updating a total of `%d` users on the site.", len(users_to_update)) for user in users_to_update: + if user is None: # ?? + continue + await bot.api_client.put( 'bot/users/' + str(user.id), json={ - 'avatar': user.avatar_hash, + 'avatar_hash': user.avatar_hash, 'discriminator': user.discriminator, 'id': user.id, 'in_guild': user.in_guild, -- cgit v1.2.3 From 6c509e8f603931e3dd521306ff7623fd76ccebbf Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 19:23:09 +0100 Subject: Remove useless `chunk_by` function. --- bot/cogs/sync/syncers.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 9fff72320..57df2c519 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -82,13 +82,6 @@ def get_users_for_update( return users_to_update -# Taken from `https://docs.python.org/3.7/library/itertools.html#itertools-recipes`. -def chunk_by(iterable, n, fillvalue=None): - "Collect data into fixed-length chunks or blocks" - args = [iter(iterable)] * n - return itertools.zip_longest(*args, fillvalue=fillvalue) - - async def sync_users(bot: Bot, guild: Guild): """ Synchronize users found on the given -- cgit v1.2.3 From f0078841f4f4c48b04478c5de2cbbf32f0839523 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 19:29:50 +0100 Subject: Update users who joined and left. --- bot/cogs/sync/syncers.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 57df2c519..1ff04e1d8 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -62,14 +62,16 @@ async def sync_roles(bot: Bot, guild: Guild): ) -def get_users_for_update( +def get_users_for_sync( guild_users: Dict[int, User], api_users: Dict[int, User] ) -> ValuesView[User]: """ Obtain a set of users to update on the website. """ + users_to_create = set() users_to_update = set() + for api_user in api_users.values(): guild_user = guild_users.get(api_user.id) if guild_user is not None: @@ -79,7 +81,14 @@ def get_users_for_update( # User left api_user._replace(in_guild=False) users_to_update.add(guild_user) - return users_to_update + + new_user_ids = set(guild_users.keys()) - set(api_users.keys()) + for user_id in new_user_ids: + # User joined + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return users_to_create, users_to_update async def sync_users(bot: Bot, guild: Guild): @@ -104,7 +113,22 @@ async def sync_users(bot: Bot, guild: Guild): ) for member in guild.members } - users_to_update = get_users_for_update(guild_users, api_users) + users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) + log.info("Creating a total of `%d` users on the site.", len(users_to_create)) + for user in users_to_create: + await bot.api_client.post( + 'bot/users', + json={ + 'avatar_hash': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + ) + log.info("User creation complete.") + log.info("Updating a total of `%d` users on the site.", len(users_to_update)) for user in users_to_update: if user is None: # ?? -- cgit v1.2.3 From 04b05d180d5be9014c422851e4728e19b8886a47 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 19:37:48 +0100 Subject: Create or update roles as needed. --- bot/cogs/sync/syncers.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 1ff04e1d8..ac32860fb 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -14,7 +14,7 @@ Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) -def get_roles_for_update(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role]: +def get_roles_for_sync(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role]: """ Determine which roles should be updated on the site. @@ -30,7 +30,13 @@ def get_roles_for_update(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Ro Roles to be sent to the site for an update or insert. """ - return guild_roles - api_roles + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in api_roles} + new_role_ids = guild_role_ids - api_role_ids + + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - api_roles - roles_to_create + return roles_to_create, roles_to_update async def sync_roles(bot: Bot, guild: Guild): @@ -47,12 +53,23 @@ async def sync_roles(bot: Bot, guild: Guild): ) for role in guild.roles } - roles_to_update = get_roles_for_update(guild_roles, api_roles) + roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles) + for role in roles_to_create: + log.info(f"Creating role `{role.name}` on the site.") + await bot.api_client.post( + 'bot/roles', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + ) for role in roles_to_update: log.info(f"Updating role `{role.name}` on the site.") await bot.api_client.put( - 'bot/roles', + 'bot/roles/' + str(role.id), json={ 'id': role.id, 'name': role.name, -- cgit v1.2.3 From 437a3fa654e6c50ef1eec48d4970ed0923ff5d4a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:01:36 +0100 Subject: Respect immutability. --- bot/cogs/sync/syncers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index ac32860fb..2334a0ace 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -96,8 +96,8 @@ def get_users_for_sync( users_to_update.add(guild_user) else: # User left - api_user._replace(in_guild=False) - users_to_update.add(guild_user) + new_api_user = api_user._replace(in_guild=False) + users_to_update.add(new_api_user) new_user_ids = set(guild_users.keys()) - set(api_users.keys()) for user_id in new_user_ids: -- cgit v1.2.3 From 0d7e303b17d76719c77906617c23bbb3674ce451 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:01:48 +0100 Subject: Add role and user differ unit tests. --- tests/__init__.py | 0 tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 64 +++++++++++++++++++++++++++++++++++++++++++ tests/cogs/sync/test_users.py | 61 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/sync/__init__.py create mode 100644 tests/cogs/sync/test_roles.py create mode 100644 tests/cogs/sync/test_users.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..7def815cc --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,64 @@ +from bot.cogs.sync.syncers import get_roles_for_sync, Role + + +def test_get_roles_for_sync_empty_return_for_equal_roles(): + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + + assert get_roles_for_sync(guild_roles, api_roles) == (set(), set()) + + +def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8)} + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + guild_roles + ) + + +def test_get_roles_only_returns_roles_that_require_update(): + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8)}, + ) + + +def test_get_roles_returns_new_roles_in_first_tuple_element(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0)}, + set() + ) + + +def test_get_roles_returns_roles_to_update_and_new_roles(): + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0)}, + {Role(id=41, name='new name', colour=40, permissions=0x16)} + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..6869f89c8 --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,61 @@ +from bot.cogs.sync.syncers import User, get_users_for_sync + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +def test_get_users_for_sync_returns_nothing_for_empty_params(): + assert get_users_for_sync({}, {}) == (set(), set()) + + +def test_get_users_for_sync_returns_nothing_for_equal_users(): + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) + + +def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(name='new fancy name')} + ) + + +def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + set() + ) + + +def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(id=63, in_guild=False)} + ) + + +def test_get_users_for_sync_updates_and_creates_users_as_needed(): + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + {fake_user(in_guild=False)} + ) -- cgit v1.2.3 From 5e603f3529dffb14274421d3784c5304f5faa87b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:07:24 +0100 Subject: Add `pytest` dev dependency. --- Pipfile | 6 +- Pipfile.lock | 377 +++++++++++++++++++++++++++++++++-------------------------- 2 files changed, 215 insertions(+), 168 deletions(-) diff --git a/Pipfile b/Pipfile index 179b317df..aafc9107a 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = {git = "https://github.com/Rapptz/discord.py.git", extras = ["voice"], ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb", editable = true} +discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true} dulwich = "*" aiodns = "*" logmatic-python = "*" @@ -29,6 +29,7 @@ requests = "*" "flake8-string-format" = "*" safety = "*" dodgy = "*" +pytest = "*" [requires] python_version = "3.6" @@ -36,12 +37,9 @@ python_version = "3.6" [scripts] start = "python -m bot" lint = "python -m flake8" - build = "docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile ." push = "docker push pythondiscord/bot:latest" - buildbase = "docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile ." pushbase = "docker push pythondiscord/bot-base:latest" - buildci = "docker build -t pythondiscord/bot-ci:latest -f docker/ci.Dockerfile ." pushci = "docker push pythondiscord/bot-ci:latest" diff --git a/Pipfile.lock b/Pipfile.lock index 506b17065..e273e5276 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "79a3c633f145dbf93ba5b2460d3f49346495328af7302e59be326e9324785cf3" + "sha256": "4f40c03d9fa30eb15e0bc6b962d42d3185e2b2cdf52d9b31969bbd119b9ed61e" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:6438e72963e459552f196a07a081a5f6dc54d42a474292b8497bd4a59554fc85", - "sha256:dc15b451dca6d2b1c504ab353e3f2fe7e7e252fdb1c219261b5412e1cafbc72d" + "sha256:c3eb639f7fc5c96355e7a227380989c9e0f342bb6612e6671ea76d188813ba45", + "sha256:ea26efd262d7c4cd4ac00fb968ede89e82c00ad331b47415e3c2353a4b91cbe0" ], "index": "pypi", - "version": "==4.6.3" + "version": "==4.9.1" }, "aiodns": { "hashes": [ @@ -57,6 +57,7 @@ "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0", "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07" ], + "index": "pypi", "version": "==3.4.4" }, "alabaster": { @@ -89,18 +90,18 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57", - "sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10", - "sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938" + "sha256:1ed70a0e99742653953d68462378a1a8eb65dca5f7c8fa44a05a2a0b3545df67", + "sha256:6a7f5e0efc563cd1ffeefba6d528b97aa0d313c02dd126ba6c455e5fe5bd48eb", + "sha256:e394827904cc4923f443e8dd2e9968343669c8e1ad7a8d62d7541e780884acb8" ], - "version": "==4.6.3" + "version": "==4.7.0" }, "certifi": { "hashes": [ - "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", - "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" ], - "version": "==2018.10.15" + "version": "==2018.11.29" }, "cffi": { "hashes": [ @@ -188,10 +189,10 @@ }, "idna": { "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], - "version": "==2.7" + "version": "==2.8" }, "idna-ssl": { "hashes": [ @@ -231,39 +232,39 @@ }, "lxml": { "hashes": [ - "sha256:02bc220d61f46e9b9d5a53c361ef95e9f5e1d27171cd461dddb17677ae2289a5", - "sha256:22f253b542a342755f6cfc047fe4d3a296515cf9b542bc6e261af45a80b8caf6", - "sha256:2f31145c7ff665b330919bfa44aacd3a0211a76ca7e7b441039d2a0b0451e415", - "sha256:36720698c29e7a9626a0dc802ef8885f8f0239bfd1689628ecd459a061f2807f", - "sha256:438a1b0203545521f6616132bfe0f4bca86f8a401364008b30e2b26ec408ce85", - "sha256:4815892904c336bbaf73dafd54f45f69f4021c22b5bad7332176bbf4fb830568", - "sha256:5be031b0f15ad63910d8e5038b489d95a79929513b3634ad4babf77100602588", - "sha256:5c93ae37c3c588e829b037fdfbd64a6e40c901d3f93f7beed6d724c44829a3ad", - "sha256:60842230678674cdac4a1cf0f707ef12d75b9a4fc4a565add4f710b5fcf185d5", - "sha256:62939a8bb6758d1bf923aa1c13f0bcfa9bf5b2fc0f5fa917a6e25db5fe0cfa4e", - "sha256:75830c06a62fe7b8fe3bbb5f269f0b308f19f3949ac81cfd40062f47c1455faf", - "sha256:81992565b74332c7c1aff6a913a3e906771aa81c9d0c68c68113cffcae45bc53", - "sha256:8c892fb0ee52c594d9a7751c7d7356056a9682674b92cc1c4dc968ff0f30c52f", - "sha256:9d862e3cf4fc1f2837dedce9c42269c8c76d027e49820a548ac89fdcee1e361f", - "sha256:a623965c086a6e91bb703d4da62dabe59fe88888e82c4117d544e11fd74835d6", - "sha256:a7783ab7f6a508b0510490cef9f857b763d796ba7476d9703f89722928d1e113", - "sha256:aab09fbe8abfa3b9ce62aaf45aca2d28726b1b9ee44871dbe644050a2fff4940", - "sha256:abf181934ac3ef193832fb973fd7f6149b5c531903c2ec0f1220941d73eee601", - "sha256:ae07fa0c115733fce1e9da96a3ac3fa24801742ca17e917e0c79d63a01eeb843", - "sha256:b9c78242219f674ab645ec571c9a95d70f381319a23911941cd2358a8e0521cf", - "sha256:bccb267678b870d9782c3b44d0cefe3ba0e329f9af8c946d32bf3778e7a4f271", - "sha256:c4df4d27f4c93b2cef74579f00b1d3a31a929c7d8023f870c4b476f03a274db4", - "sha256:caf0e50b546bb60dfa99bb18dfa6748458a83131ecdceaf5c071d74907e7e78a", - "sha256:d3266bd3ac59ac4edcd5fa75165dee80b94a3e5c91049df5f7c057ccf097551c", - "sha256:db0d213987bcd4e6d41710fb4532b22315b0d8fb439ff901782234456556aed1", - "sha256:dbbd5cf7690a40a9f0a9325ab480d0fccf46d16b378eefc08e195d84299bfae1", - "sha256:e16e07a0ec3a75b5ee61f2b1003c35696738f937dc8148fbda9fe2147ccb6e61", - "sha256:e175a006725c7faadbe69e791877d09936c0ef2cf49d01b60a6c1efcb0e8be6f", - "sha256:edd9c13a97f6550f9da2236126bb51c092b3b1ce6187f2bd966533ad794bbb5e", - "sha256:fa39ea60d527fbdd94215b5e5552f1c6a912624521093f1384a491a8ad89ad8b" + "sha256:16cf8bac33ec17049617186d63006ba49da7c5be417042877a49f0ef6d7a195d", + "sha256:18f2d8f14cc61e66e8a45f740d15b6fc683c096f733db1f8d0ee15bcac9843de", + "sha256:260868f69d14a64dd1de9cf92e133d2f71514d288de4906f109bdf48ca9b756a", + "sha256:29b8acd8ecdf772266dbac491f203c71664b0b07ad4309ba2c3bb131306332fc", + "sha256:2b05e5e06f8e8c63595472dc887d0d6e0250af754a35ba690f6a6abf2ef85691", + "sha256:30d6ec05fb607a5b7345549f642c7c7a5b747b634f6d5e935596b910f243f96f", + "sha256:3bf683f0237449ebc1851098f664410e3c99ba3faa8c9cc82c6acfe857df1767", + "sha256:3ce5488121eb15513c4b239dadd67f9e7959511bd766aac6be0c35e80274f298", + "sha256:48be0c375350a5519bb9474b42a9c0e7ab709fb45f11bfcd33de876791137896", + "sha256:49bc343ca3b30cd860845433bb9f62448a54ff87b632175108bacbc5dc63e49e", + "sha256:4cc7531e86a43ea66601763c5914c3d3adb297f32e4284957609b90d41825fca", + "sha256:4e9822fad564d82035f0b6d701a890444560210f8a8648b8f15850f8fe883cd9", + "sha256:51a9a441aefc8c93512bad5efe867d2ff086e7249ce0fc3b47c310644b352936", + "sha256:5bbed9efc8aeb69929140f71a30e655bf496b45b766861513960e1b11168d475", + "sha256:60a5323b2bc893ca1059d283d6695a172d51cc95a70c25b3e587e1aad5459c38", + "sha256:7035d9361f3ceec9ccc1dd3482094d1174580e7e1bf6870b77ea758f7cad15d2", + "sha256:76d62cc048bda0ebf476689ad3eb8e65e6827e43a7521be3b163071020667b8c", + "sha256:78163b578e6d1836012febaa1865e095ccc7fc826964dd69a2dbfe401618a1f7", + "sha256:83b58b2b5904d50de03a47e2f56d24e9da4cf7e3b0d66fb4510b18fca0faf910", + "sha256:a07447e46fffa5bb4d7a0af0a6505c8517e9bd197cfd2aec79e499b6e86cde49", + "sha256:a17d808b3edca4aaf6b295b5a388c844a0b7f79aca2d79eec5acc1461db739e3", + "sha256:a378fd61022cf4d3b492134c3bc48204ac2ff19e0813b23e07c3dd95ae8df0bc", + "sha256:aa7d096a44ae3d475c5ed763e24cf302d32462e78b61bba73ce1ad0efb8f522a", + "sha256:ade8785c93a985956ba6499d5ea6d0a362e24b4a9ba07dd18920fd67cccf63ea", + "sha256:cc039668f91d8af8c4094cfb5a67c7ae733967fdc84c0507fe271db81480d367", + "sha256:d89f1ffe98744c4b5c11f00fb843a4e72f68a6279b5e38168167f1b3c0fdd84c", + "sha256:e691b6ef6e27437860016bd6c32e481bdc2ed3af03289707a38b9ca422105f40", + "sha256:e750da6ac3ca624ae3303df448664012f9b6f9dfbc5d50048ea8a12ce2f8bc29", + "sha256:eca305b200549906ea25648463aeb1b3b220b716415183eaa99c998a846936d9", + "sha256:f52fe795e08858192eea167290033b5ff24f50f51781cb78d989e8d63cfe73d1" ], "index": "pypi", - "version": "==4.2.5" + "version": "==4.2.6" }, "markdownify": { "hashes": [ @@ -307,37 +308,37 @@ }, "multidict": { "hashes": [ - "sha256:013eb6591ab95173fd3deb7667d80951abac80100335b3e97b5fa778c1bb4b91", - "sha256:0bffbbbb48db35f57dfb4733e943ac8178efb31aab5601cb7b303ee228ce96af", - "sha256:1a34aab1dfba492407c757532f665ba3282ec4a40b0d2f678bda828ef422ebb7", - "sha256:1b4b46a33f459a2951b0fd26c2d80639810631eb99b3d846d298b02d28a3e31d", - "sha256:1d616d80c37a388891bf760d64bc50cac7c61dbb7d7013f2373aa4b44936e9f0", - "sha256:225aefa7befbe05bd0116ef87e8cd76cbf4ac39457a66faf7fb5f3c2d7bea19a", - "sha256:2c9b28985ef7c830d5c7ea344d068bcdee22f8b6c251369dea98c3a814713d44", - "sha256:39e0600f8dd72acb011d09960da560ba3451b1eca8de5557c15705afc9d35f0e", - "sha256:3c642c40ea1ca074397698446893a45cd6059d5d071fc3ba3915c430c125320f", - "sha256:42357c90b488fac38852bcd7b31dcd36b1e2325413960304c28b8d98e6ff5fd4", - "sha256:6ac668f27dbdf8a69c31252f501e128a69a60b43a44e43d712fb58ce3e5dfcca", - "sha256:713683da2e3f1dd81a920c995df5dda51f1fff2b3995f5864c3ee782fcdcb96c", - "sha256:73b6e7853b6d3bc0eac795044e700467631dff37a5a33d3230122b03076ac2f9", - "sha256:77534c1b9f4a5d0962392cad3f668d1a04036b807618e3357eb2c50d8b05f7f7", - "sha256:77b579ef57e27457064bb6bb4c8e5ede866af071af60fe3576226136048c6dfa", - "sha256:82cf28f18c935d66c15a6f82fda766a4138d21e78532a1946b8ec603019ba0b8", - "sha256:937e8f12f9edc0d2e351c09fc3e7335a65eefb75406339d488ee46ef241f75d8", - "sha256:985dbf59e92f475573a04598f9a00f92b4fdb64fc41f1df2ea6f33b689319537", - "sha256:9c4fab7599ba8c0dbf829272c48c519625c2b7f5630b49925802f1af3a77f1f4", - "sha256:9e8772be8455b49a85ad6dbf6ce433da7856ba481d6db36f53507ae540823b15", - "sha256:a06d6d88ce3be4b54deabd078810e3c077a8b2e20f0ce541c979b5dd49337031", - "sha256:a1da0cdc3bc45315d313af976dab900888dbb477d812997ee0e6e4ea43d325e5", - "sha256:a6652466a4800e9fde04bf0252e914fff5f05e2a40ee1453db898149624dfe04", - "sha256:a7f23523ea6a01f77e0c6da8aae37ab7943e35630a8d2eda7e49502f36b51b46", - "sha256:a87429da49f4c9fb37a6a171fa38b59a99efdeabffb34b4255a7a849ffd74a20", - "sha256:c26bb81d0d19619367a96593a097baec2d5a7b3a0cfd1e3a9470277505a465c2", - "sha256:d4f4545edb4987f00fde44241cef436bf6471aaac7d21c6bbd497cca6049f613", - "sha256:daabc2766a2b76b3bec2086954c48d5f215f75a335eaee1e89c8357922a3c4d5", - "sha256:f08c1dcac70b558183b3b755b92f1135a76fd1caa04009b89ddea57a815599aa" - ], - "version": "==4.5.1" + "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", + "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3", + "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef", + "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b", + "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73", + "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc", + "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3", + "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd", + "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351", + "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941", + "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d", + "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1", + "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b", + "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a", + "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3", + "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7", + "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0", + "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0", + "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014", + "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5", + "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036", + "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d", + "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a", + "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce", + "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1", + "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a", + "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9", + "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7", + "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b" + ], + "version": "==4.5.2" }, "packaging": { "hashes": [ @@ -348,65 +349,67 @@ }, "pillow": { "hashes": [ - "sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360", - "sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7", - "sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e", - "sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93", - "sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c", - "sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8", - "sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b", - "sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0", - "sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd", - "sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb", - "sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581", - "sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64", - "sha256:9577888ecc0ad7d06c3746afaba339c94d62b59da16f7a5d1cff9e491f23dace", - "sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956", - "sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60", - "sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16", - "sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37", - "sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae", - "sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a", - "sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073", - "sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8", - "sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb", - "sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409", - "sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f", - "sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2", - "sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4", - "sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74", - "sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e", - "sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1", - "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888" + "sha256:0cd42fe2d99ec6ce23aaf00947a7b7956ad2ed4b1695fd37545c3b8eae06d95a", + "sha256:137bed8972089d65da63fb79b4949b0f2b99e9a58f1b494e82be43ba8b0f4226", + "sha256:14eb2b2e4f2a14f5c89fd0edf55c5af0bf1a40fdf3838d81867f26f131cd557d", + "sha256:1fc43ce8c4fa3754222cd6831d599ad17ca2fc9868d2fb52f4e5362dfbfaf379", + "sha256:26dfeee23a86dad6277a63d18f61f53b957cb2cd3506dbbd74b88ba2fa65b3b1", + "sha256:2e0e582942e025cc58f669499a8e0bffde5bcc8d42b65729f294c1dac54e4672", + "sha256:3bb8dd3ce101dd8b0b37eaae924a5bb93abb6ffdd034bf68a066a808e11768ab", + "sha256:3f07da3874f0b085421f1d4f979785131aa9d497501d8610d82f7378b33858f8", + "sha256:429b2b5ae5f57f8fd9ec2e012c1e7b342ff10f1a8977dc291976b9a3b4c096e1", + "sha256:4a000fdd89d77b6b675de27e1ab91c6fba517c08f19ee83e6716b78930634e04", + "sha256:4ccbe7cce6156391a3ecf447c79a7d4a1a0ecd3de79bdec9ca5e4f7242a306d1", + "sha256:4d08034196db41acb7392e4fccfc0448e7a87192c41d3011ad4093eac2c31ffd", + "sha256:6b202b1cb524bc76ed52a7eb0314f4b0a0497c7cceb9a93539b5a25800e1f2b6", + "sha256:8563b56fa7c34f1606848c2143ea67d27cf225b9726a1b041c3d27cf85e46edc", + "sha256:86d7421e8803d7bae2e594765c378a867b629d46b32fbfe5ed9fd95b30989feb", + "sha256:8d4bddedcb4ab99131d9705a75720efc48b3d006122dae1a4cc329496ac47c9a", + "sha256:a4929c6de9590635c34533609402c9da12b22bfc2feb8c0c4f38c39bab48a9ad", + "sha256:b0736e21798448cee3e663c0df7a6dfa83d805b3f3a45e67f7457a2f019e5fca", + "sha256:b669acba91d47395de84c9ca52a7ad393b487e5ae2e20b9b2790b22a57d479fa", + "sha256:bba993443921f2d077195b425a3283357f52b07807d53704610c1249d20b183a", + "sha256:bdf706a93d00547c9443b2654ae424fd54d5dece4bc4333e7035740aeb7a7cea", + "sha256:c5aa93e55175b9cde95279ccd03c93d218976b376480222d37be41d2c9c54510", + "sha256:cc11fd997d8ad71bb0412e983b711e49639c2ddba9b9dce04d4bdab575fe5f84", + "sha256:d584f1c33995c3dc16a35e30ef43e0881fa0d085f0fef29cebf154ffb5643363", + "sha256:d88f54bdefb7ddccb68efdd710d689aa6a09b875cc3e44b7e81ef54e0751e3a7", + "sha256:de0d323072be72fa4d74f4e013cd594e3f8ee03b2e0eac5876a3249fa076ef7b", + "sha256:f139c963c6679d236b2c45369524338eabd36a853fe23abd39ba246ab0a75aec", + "sha256:f41c0bf667c4c1c30b873eaa8d6bb894f6d721b3e38e9c993bddd1263c02fb1f", + "sha256:fbd0ea468b4ec04270533bf5206f1cd57746fcf226520bb133318fa276de2644", + "sha256:fe2d2850521c467c915ff0a6e27dc64c3c04c2f66612e0072672bd1bd4854b61" ], "index": "pypi", - "version": "==5.3.0" + "version": "==5.4.0" }, "pycares": { "hashes": [ - "sha256:0e81c971236bb0767354f1456e67ab6ae305f248565ce77cd413a311f9572bf5", - "sha256:11c0ff3ccdb5a838cbd59a4e59df35d31355a80a61393bca786ca3b44569ba10", - "sha256:170d62bd300999227e64da4fa85459728cc96e62e44780bbc86a915fdae01f78", - "sha256:36f4c03df57c41a87eb3d642201684eb5a8bc194f4bafaa9f60ee6dc0aef8e40", - "sha256:371ce688776da984c4105c8ca760cc60944b9b49ccf8335c71dc7669335e6173", - "sha256:3a2234516f7db495083d8bba0ccdaabae587e62cfcd1b8154d5d0b09d3a48dfc", - "sha256:3f288586592c697109b2b06e3988b7e17d9765887b5fc367010ee8500cbddc86", - "sha256:40134cee03c8bbfbc644d4c0bc81796e12dd012a5257fb146c5a5417812ee5f7", - "sha256:722f5d2c5f78d47b13b0112f6daff43ce4e08e8152319524d14f1f917cc5125e", - "sha256:7b18fab0ed534a898552df91bc804bd62bb3a2646c11e054baca14d23663e1d6", - "sha256:8a39d03bd99ea191f86b990ef67ecce878d6bf6518c5cde9173fb34fb36beb5e", - "sha256:8ea263de8bf1a30b0d87150b4aa0e3203cf93bc1723ea3e7408a7d25e1299217", - "sha256:943e2dc67ff45ab4c81d628c959837d01561d7e185080ab7a276b8ca67573fb5", - "sha256:9d56a54c93e64b30c0d31f394d9890f175edec029cd846221728f99263cdee82", - "sha256:b95b339c11d824f0bb789d31b91c8534916fcbdce248cccce216fa2630bb8a90", - "sha256:bbfd9aba1e172cd2ab7b7142d49b28cf44d6451c4a66a870aff1dc3cb84849c7", - "sha256:d8637bcc2f901aa61ec1d754abc862f9f145cb0346a0249360df4c159377018e", - "sha256:e2446577eeea79d2179c9469d9d4ce3ab8a07d7985465c3cb91e7d74abc329b6", - "sha256:e72fa163f37ae3b09f143cc6690a36f012d13e905d142e1beed4ec0e593ff657", - "sha256:f32b7c63094749fbc0c1106c9a785666ec8afd49ecfe7002a30bb7c42e62b47c", - "sha256:f50be4dd53f009cfb4b98c3c6b240e18ff9b17e3f1c320bd594bb83eddabfcb2" + "sha256:080ae0f1b1b754be60b6ef31b9ab2915364c210eb1cb4d8e089357c89d7b9819", + "sha256:0eccb76dff0155ddf793a589c6270e1bdbf6975b2824d18d1d23db2075d7fc96", + "sha256:223a03d69e864a18d7bb2e0108bca5ba069ef91e5b048b953ed90ea9f50eb77f", + "sha256:289e49f98adfd7a2ae3656df26e1d62cf49a06bbc03ced63f243c22cd8919adf", + "sha256:292ac442a1d4ff27d41be748ec19f0c4ff47efebfb715064ba336564ea0f2071", + "sha256:34771095123da0e54597fe3c5585a28d3799945257e51b378a20778bf33573b6", + "sha256:34c8865f2d047be4c301ce90a916c7748be597e271c5c7932e8b9a6de85840f4", + "sha256:36af260b215f86ebfe4a5e4aea82fd6036168a5710cbf8aad77019ab52156dda", + "sha256:5e8e2a461717da40482b5fecf1119116234922d29660b3c3e01cbc5ba2cbf4bd", + "sha256:61e77bd75542c56dff49434fedbafb25604997bc57dc0ebf791a5732503cb1bb", + "sha256:691740c332f38a9035b4c6d1f0e6c8af239466ef2373a894d4393f0ea65c815d", + "sha256:6bc0e0fdcb4cdc4ca06aa0b07e6e3560d62b2af79ef0ea4589835fcd2059012b", + "sha256:96db5c93e2fe2e39f519efb7bb9d86aef56f5813fa0b032e47aba329fa925d57", + "sha256:af701b22c91b3e36f65ee9f4b1bc2fe4800c8ed486eb6ef203624acbe53d026d", + "sha256:b25bd21bba9c43d44320b719118c2ce35e4a78031f61d906caeb01316d49dafb", + "sha256:c42f68319f8ea2322ed81c31a86c4e60547e6e90f3ebef479a7a7540bddbf268", + "sha256:cc9a8d35af12bc5f484f3496f9cb3ab5bedfa4dcf3dfff953099453d88b659a7", + "sha256:dfee9d198ba6d6f29aa5bf510bfb2c28a60c3f308116f114c9fd311980d3e870", + "sha256:e1dd02e110a7a97582097ebba6713d9da28583b538c08e8a14bc82169c5d3e10", + "sha256:e48c586c80a139c6c7fb0298b944d1c40752cf839bc8584cc793e42a8971ba6c", + "sha256:f509762dec1a70eac32b86c098f37ac9c5d3d4a8a9098983328377c9e71543b2", + "sha256:f8e0d61733843844f9019c911d5676818d99c4cd2c54b91de58384c7d962862b", + "sha256:fe20280fed496deba60e0f6437b7672bdc83bf45e243bb546af47c60c85bcfbc" ], - "version": "==2.3.0" + "version": "==2.4.0" }, "pycparser": { "hashes": [ @@ -416,10 +419,10 @@ }, "pygments": { "hashes": [ - "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", - "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" + "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", + "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" ], - "version": "==2.2.0" + "version": "==2.3.1" }, "pynacl": { "hashes": [ @@ -505,11 +508,11 @@ }, "requests": { "hashes": [ - "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", - "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], "index": "pypi", - "version": "==2.20.1" + "version": "==2.21.0" }, "shortuuid": { "hashes": [ @@ -519,10 +522,10 @@ }, "six": { "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" ], - "version": "==1.11.0" + "version": "==1.12.0" }, "snowballstemmer": { "hashes": [ @@ -531,13 +534,20 @@ ], "version": "==1.2.1" }, + "soupsieve": { + "hashes": [ + "sha256:057e08f362a255b457a5781675211556799ed3bb8807506eaac3809390bc304b", + "sha256:f7d99b41637be2f249dfcc06ae93c13fcbbdfa7bb68b15308cdd0734e58146f1" + ], + "version": "==1.6.1" + }, "sphinx": { "hashes": [ - "sha256:120732cbddb1b2364471c3d9f8bfd4b0c5b550862f99a65736c77f970b142aea", - "sha256:b348790776490894e0424101af9c8413f2a86831524bd55c5f379d3e3e12ca64" + "sha256:429e3172466df289f0f742471d7e30ba3ee11f3b5aecd9a840480d03f14bcfe5", + "sha256:c4cb17ba44acffae3d3209646b6baec1e215cad3065e852c68cc569d4df1b9f8" ], "index": "pypi", - "version": "==1.8.2" + "version": "==1.8.3" }, "sphinxcontrib-websupport": { "hashes": [ @@ -581,20 +591,29 @@ }, "yarl": { "hashes": [ - "sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9", - "sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee", - "sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308", - "sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357", - "sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78", - "sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8", - "sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1", - "sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4", - "sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7" - ], - "version": "==1.2.6" + "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9", + "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f", + "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb", + "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320", + "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842", + "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0", + "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829", + "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310", + "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4", + "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8", + "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1" + ], + "version": "==1.3.0" } }, "develop": { + "atomicwrites": { + "hashes": [ + "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", + "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" + ], + "version": "==1.2.1" + }, "attrs": { "hashes": [ "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", @@ -604,10 +623,10 @@ }, "certifi": { "hashes": [ - "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", - "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" ], - "version": "==2018.10.15" + "version": "==2018.11.29" }, "chardet": { "hashes": [ @@ -686,10 +705,10 @@ }, "idna": { "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], - "version": "==2.7" + "version": "==2.8" }, "mccabe": { "hashes": [ @@ -698,6 +717,14 @@ ], "version": "==0.6.1" }, + "more-itertools": { + "hashes": [ + "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", + "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", + "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9" + ], + "version": "==5.0.0" + }, "packaging": { "hashes": [ "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", @@ -705,6 +732,20 @@ ], "version": "==18.0" }, + "pluggy": { + "hashes": [ + "sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", + "sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f" + ], + "version": "==0.8.0" + }, + "py": { + "hashes": [ + "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", + "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" + ], + "version": "==1.7.0" + }, "pycodestyle": { "hashes": [ "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", @@ -726,6 +767,14 @@ ], "version": "==2.3.0" }, + "pytest": { + "hashes": [ + "sha256:f689bf2fc18c4585403348dd56f47d87780bf217c53ed9ae7a3e2d7faa45f8e9", + "sha256:f812ea39a0153566be53d88f8de94839db1e8a05352ed8a49525d7d7f37861e9" + ], + "index": "pypi", + "version": "==4.0.2" + }, "pyyaml": { "hashes": [ "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", @@ -745,11 +794,11 @@ }, "requests": { "hashes": [ - "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", - "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], "index": "pypi", - "version": "==2.20.1" + "version": "==2.21.0" }, "safety": { "hashes": [ @@ -761,10 +810,10 @@ }, "six": { "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" ], - "version": "==1.11.0" + "version": "==1.12.0" }, "urllib3": { "hashes": [ -- cgit v1.2.3 From 0958a874f6bc851ad671d0fefac176c78939b964 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:07:33 +0100 Subject: Add testing step to CI. --- azure-pipelines.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 6a63cfe21..0f968e618 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -36,6 +36,9 @@ jobs: - script: python -m flake8 displayName: 'Run linter' + - script: python -m pytest tests + displayName: Run tests + - job: build displayName: 'Build Containers' dependsOn: 'test' -- cgit v1.2.3 From 8a37697033a2a18283d3c5b7f060e05a191deb35 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:11:18 +0100 Subject: Remove obsolete `user is None` check. --- bot/cogs/sync/syncers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 2334a0ace..92c866357 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -148,9 +148,6 @@ async def sync_users(bot: Bot, guild: Guild): log.info("Updating a total of `%d` users on the site.", len(users_to_update)) for user in users_to_update: - if user is None: # ?? - continue - await bot.api_client.put( 'bot/users/' + str(user.id), json={ -- cgit v1.2.3 From 759766f2c20aee3c707c0de02b0c6c1a336ff2b5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:20:53 +0100 Subject: Add documentation strings and comments. --- bot/cogs/sync/syncers.py | 81 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 92c866357..66e041f49 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,7 +1,7 @@ import itertools import logging from collections import namedtuple -from typing import Dict, Set, ValuesView +from typing import Dict, Set, Tuple from discord import Guild from discord.ext.commands import Bot @@ -14,9 +14,11 @@ Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) -def get_roles_for_sync(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role]: +def get_roles_for_sync( + guild_roles: Set[Role], api_roles: Set[Role] +) -> Tuple[Set[Role], Set[Role]]: """ - Determine which roles should be updated on the site. + Determine which roles should be created or updated on the site. Arguments: guild_roles (Set[Role]): @@ -26,14 +28,21 @@ def get_roles_for_sync(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role Roles that were retrieved from the API at startup. Returns: - Set[Role]: - Roles to be sent to the site for an update or insert. + Tuple[Set[Role], Set[Role]]: + A tuple with two elements. The first element represents + roles to be created on the site, meaning that they were + present on the cached guild but not on the API. The second + element represents roles to be updated, meaning they were + present on both the cached guild and the API but non-ID + fields have changed inbetween. """ guild_role_ids = {role.id for role in guild_roles} api_role_ids = {role.id for role in api_roles} new_role_ids = guild_role_ids - api_role_ids + # New roles are those which are on the cached guild but not on the + # API guild, going by the role ID. We need to send them in for creation. roles_to_create = {role for role in guild_roles if role.id in new_role_ids} roles_to_update = guild_roles - api_roles - roles_to_create return roles_to_create, roles_to_update @@ -42,9 +51,21 @@ def get_roles_for_sync(guild_roles: Set[Role], api_roles: Set[Role]) -> Set[Role async def sync_roles(bot: Bot, guild: Guild): """ Synchronize roles found on the given `guild` with the ones on the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. """ roles = await bot.api_client.get('bot/roles') + + # Pack API roles and guild roles into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. api_roles = {Role(**role_dict) for role_dict in roles} guild_roles = { Role( @@ -54,6 +75,7 @@ async def sync_roles(bot: Bot, guild: Guild): for role in guild.roles } roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles) + for role in roles_to_create: log.info(f"Creating role `{role.name}` on the site.") await bot.api_client.post( @@ -81,9 +103,28 @@ async def sync_roles(bot: Bot, guild: Guild): def get_users_for_sync( guild_users: Dict[int, User], api_users: Dict[int, User] -) -> ValuesView[User]: +) -> Tuple[Set[User], Set[User]]: """ - Obtain a set of users to update on the website. + Determine which users should be created or updated on the website. + + Arguments: + guild_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the + guild cached on the running bot instance. + + api_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the API's + current inventory of all users. + + Returns: + Tuple[Set[User], Set[User]]: + Two user sets as a tuple. The first element represents users + to be created on the website, these are users that are present + in the cached guild data but not in the API at all, going by + their ID. The second element represents users to update. It is + populated by users which are present on both the API and the + guild, but where the attribute of a user on the API is not + equal to the attribute of the user on the guild. """ users_to_create = set() @@ -94,14 +135,20 @@ def get_users_for_sync( if guild_user is not None: if api_user != guild_user: users_to_update.add(guild_user) - else: - # User left + + elif api_user.in_guild: + # The user is known on the API but not the guild, and the + # API currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. new_api_user = api_user._replace(in_guild=False) users_to_update.add(new_api_user) new_user_ids = set(guild_users.keys()) - set(api_users.keys()) for user_id in new_user_ids: - # User joined + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. new_user = guild_users[user_id] users_to_create.add(new_user) @@ -112,9 +159,21 @@ async def sync_users(bot: Bot, guild: Guild): """ Synchronize users found on the given `guild` with the ones on the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. """ current_users = await bot.api_client.get('bot/users') + + # Pack API users and guild users into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. api_users = { user_dict['id']: User( roles=tuple(sorted(user_dict.pop('roles'))), @@ -130,7 +189,9 @@ async def sync_users(bot: Bot, guild: Guild): ) for member in guild.members } + users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) + log.info("Creating a total of `%d` users on the site.", len(users_to_create)) for user in users_to_create: await bot.api_client.post( -- cgit v1.2.3 From 1e8ac5e9bff2a6190bdde88fb4c171d07fdda1ba Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:21:04 +0100 Subject: Add test case for deduplication of `in_guild` field update. --- tests/cogs/sync/test_users.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py index 6869f89c8..ecf1d3926 100644 --- a/tests/cogs/sync/test_users.py +++ b/tests/cogs/sync/test_users.py @@ -59,3 +59,10 @@ def test_get_users_for_sync_updates_and_creates_users_as_needed(): {fake_user(id=63)}, {fake_user(in_guild=False)} ) + + +def test_get_users_for_sync_does_not_duplicate_update_users(): + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) -- cgit v1.2.3 From c16a97d51f4dd47f66e45b308022972419fff891 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 3 Jan 2019 20:42:39 +0100 Subject: Move diff logging to cog, add manual sync commands. --- bot/cogs/sync/cog.py | 41 +++++++++++++++++++++++++++++++++++++++-- bot/cogs/sync/syncers.py | 24 ++++++++++++++---------- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 959e26eeb..70acfaab9 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -2,6 +2,7 @@ import logging from typing import Callable, Iterable from discord import Guild +from discord.ext import commands from discord.ext.commands import Bot from . import syncers @@ -32,5 +33,41 @@ class Sync: for syncer in self.ON_READY_SYNCERS: syncer_name = syncer.__name__[5:] # drop off `sync_` log.info("Starting `%s` syncer.", syncer_name) - await syncer(self.bot, guild) - log.info("`%s` syncer finished.", syncer_name) + total_created, total_updated = await syncer(self.bot, guild) + log.info( + "`%s` syncer finished, created `%d`, updated `%d`.", + syncer_name, total_created, total_updated + ) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx): + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx): + """Manually synchronize the guild's roles with the roles on the site.""" + + initial_response = await ctx.send("📊 Synchronizing roles.") + total_created, total_updated = await syncers.sync_roles(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 Role synchronization complete, created **{total_created}** " + f"and updated **{total_created}** roles." + ) + ) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx): + """Manually synchronize the guild's users with the users on the site.""" + + initial_response = await ctx.send("📊 Synchronizing users.") + total_created, total_updated = await syncers.sync_users(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 User synchronization complete, created **{total_created}** " + f"and updated **{total_created}** users." + ) + ) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 66e041f49..3037d2e31 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,13 +1,9 @@ -import itertools -import logging from collections import namedtuple from typing import Dict, Set, Tuple from discord import Guild from discord.ext.commands import Bot -log = logging.getLogger(__name__) - # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) @@ -59,6 +55,11 @@ async def sync_roles(bot: Bot, guild: Guild): guild (discord.Guild): The guild instance from the bot's cache to synchronize roles with. + + Returns: + Tuple[int, int]: + A tuple with two integers representing how many roles were created + (element `0`) and how many roles were updated (element `1`). """ roles = await bot.api_client.get('bot/roles') @@ -77,7 +78,6 @@ async def sync_roles(bot: Bot, guild: Guild): roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles) for role in roles_to_create: - log.info(f"Creating role `{role.name}` on the site.") await bot.api_client.post( 'bot/roles', json={ @@ -89,7 +89,6 @@ async def sync_roles(bot: Bot, guild: Guild): ) for role in roles_to_update: - log.info(f"Updating role `{role.name}` on the site.") await bot.api_client.put( 'bot/roles/' + str(role.id), json={ @@ -100,6 +99,8 @@ async def sync_roles(bot: Bot, guild: Guild): } ) + return (len(roles_to_create), len(roles_to_update)) + def get_users_for_sync( guild_users: Dict[int, User], api_users: Dict[int, User] @@ -167,6 +168,11 @@ async def sync_users(bot: Bot, guild: Guild): guild (discord.Guild): The guild instance from the bot's cache to synchronize roles with. + + Returns: + Tuple[int, int]: + A tuple with two integers representing how many users were created + (element `0`) and how many users were updated (element `1`). """ current_users = await bot.api_client.get('bot/users') @@ -192,7 +198,6 @@ async def sync_users(bot: Bot, guild: Guild): users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) - log.info("Creating a total of `%d` users on the site.", len(users_to_create)) for user in users_to_create: await bot.api_client.post( 'bot/users', @@ -205,9 +210,7 @@ async def sync_users(bot: Bot, guild: Guild): 'roles': list(user.roles) } ) - log.info("User creation complete.") - log.info("Updating a total of `%d` users on the site.", len(users_to_update)) for user in users_to_update: await bot.api_client.put( 'bot/users/' + str(user.id), @@ -220,4 +223,5 @@ async def sync_users(bot: Bot, guild: Guild): 'roles': list(user.roles) } ) - log.info("User update complete.") + + return (len(users_to_create), len(users_to_update)) -- cgit v1.2.3 From b90a226e2cae16f81223e2e2f6b4db27547e8d92 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:11:34 +0100 Subject: Handle member updates in sync cog. --- bot/cogs/sync/cog.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 70acfaab9..1390eb273 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@ import logging from typing import Callable, Iterable -from discord import Guild +from discord import Guild, Member from discord.ext import commands from discord.ext.commands import Bot @@ -39,6 +39,25 @@ class Sync: syncer_name, total_created, total_updated ) + async def on_member_update(self, before: Member, after: Member): + if ( + before.name != after.name + or before.avatar != after.avatar + or before.discriminator != after.discriminator + or before.roles != after.roles + ): + await self.bot.api_client.put( + 'bot/users/' + str(after.id), + json={ + 'avatar_hash': after.avatar, + 'discriminator': int(after.discriminator), + 'id': after.id, + 'in_guild': True, + 'name': after.name, + 'roles': sorted(role.id for role in after.roles) + } + ) + @commands.group(name='sync') @commands.has_permissions(administrator=True) async def sync_group(self, ctx): -- cgit v1.2.3 From 3c9af95e149071273bdd61731ae4aed7f136f529 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:14:36 +0100 Subject: Handle role updates in sync cog. --- bot/cogs/sync/cog.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 1390eb273..be4345df0 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,7 +1,7 @@ import logging from typing import Callable, Iterable -from discord import Guild, Member +from discord import Guild, Member, Role from discord.ext import commands from discord.ext.commands import Bot @@ -39,6 +39,22 @@ class Sync: syncer_name, total_created, total_updated ) + async def on_guild_role_update(self, before: Role, after: Role): + if ( + before.name + or before.colour != after.colour + or before.permissions != after.permissions + ): + await self.bot.api_client.put( + 'bot/roles/' + str(after.id), + json={ + 'colour': after.colour, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions + } + ) + async def on_member_update(self, before: Member, after: Member): if ( before.name != after.name -- cgit v1.2.3 From 6d5acc0c0608304ef88a237c373f0fb40c47dafd Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:17:19 +0100 Subject: Handle role creation in sync cog. --- bot/cogs/sync/cog.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index be4345df0..6cffe6339 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -39,6 +39,17 @@ class Sync: syncer_name, total_created, total_updated ) + async def on_guild_role_create(self, role: Role): + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions + } + ) + async def on_guild_role_update(self, before: Role, after: Role): if ( before.name -- cgit v1.2.3 From d5ad25c6606d313f976078e3c864287ecd009929 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:17:34 +0100 Subject: Handle role deletion in sync cog. --- bot/cogs/sync/cog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 6cffe6339..9442b7a33 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -50,6 +50,15 @@ class Sync: } ) + async def on_guild_role_delete(self, role: Role): + log.warning( + ( + "Attempted to delete role `%s` (`%d`), but role deletion " + "is currently not implementeed." + ), + role.name, role.id + ) + async def on_guild_role_update(self, before: Role, after: Role): if ( before.name -- cgit v1.2.3 From 48e404bc60ae21a33b044de3c556841aab920c36 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:24:05 +0100 Subject: Send permission / colour value as required. --- bot/cogs/sync/cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 9442b7a33..0a63a4c8b 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -43,10 +43,10 @@ class Sync: await self.bot.api_client.post( 'bot/roles', json={ - 'colour': role.colour, + 'colour': role.colour.value, 'id': role.id, 'name': role.name, - 'permissions': role.permissions + 'permissions': role.permissions.value } ) @@ -61,17 +61,17 @@ class Sync: async def on_guild_role_update(self, before: Role, after: Role): if ( - before.name + before.name != after.name or before.colour != after.colour or before.permissions != after.permissions ): await self.bot.api_client.put( 'bot/roles/' + str(after.id), json={ - 'colour': after.colour, + 'colour': after.colour.value, 'id': after.id, 'name': after.name, - 'permissions': after.permissions + 'permissions': after.permissions.value } ) -- cgit v1.2.3 From a24a6aa24b11f4483c55154e284d4b0801e293cc Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:50:08 +0100 Subject: Handle member join in sync cog. --- bot/cogs/sync/cog.py | 60 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 0a63a4c8b..2d1616cc8 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,6 +1,7 @@ import logging from typing import Callable, Iterable +import aiohttp from discord import Guild, Member, Role from discord.ext import commands from discord.ext.commands import Bot @@ -75,6 +76,34 @@ class Sync: } ) + async def on_member_join(self, member: Member): + packed = { + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put('bot/users/' + str(member.id), json=packed) + + except aiohttp.client_exceptions.ClientResponseError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.status != 404: + raise + + got_error = True # gorgeous + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + async def on_member_update(self, before: Member, after: Member): if ( before.name != after.name @@ -82,17 +111,26 @@ class Sync: or before.discriminator != after.discriminator or before.roles != after.roles ): - await self.bot.api_client.put( - 'bot/users/' + str(after.id), - json={ - 'avatar_hash': after.avatar, - 'discriminator': int(after.discriminator), - 'id': after.id, - 'in_guild': True, - 'name': after.name, - 'roles': sorted(role.id for role in after.roles) - } - ) + try: + await self.bot.api_client.put( + 'bot/users/' + str(after.id), + json={ + 'avatar_hash': after.avatar, + 'discriminator': int(after.discriminator), + 'id': after.id, + 'in_guild': True, + 'name': after.name, + 'roles': sorted(role.id for role in after.roles) + } + ) + except aiohttp.client_exceptions.ClientResponseError as e: + if e.status != 404: + raise + + log.warning( + "Unable to update user, got 404. " + "Assuming race condition from join event." + ) @commands.group(name='sync') @commands.has_permissions(administrator=True) -- cgit v1.2.3 From affc4ed16b8a99a87ea573fdf8b4d14b9ebb7db6 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 20:56:18 +0100 Subject: Handle member leave in sync cog. --- bot/cogs/sync/cog.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 2d1616cc8..4556bae83 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -104,6 +104,19 @@ class Sync: # If we got `404`, the user is new. Create them. await self.bot.api_client.post('bot/users', json=packed) + async def on_member_leave(self, member: Member): + await self.bot.api_client.put( + 'bot/users/' + str(member.id), + json={ + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + ) + async def on_member_update(self, before: Member, after: Member): if ( before.name != after.name -- cgit v1.2.3 From c6fa3f0bf30fc6ac6a7dd1db966183d787f29b9a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 4 Jan 2019 21:08:29 +0100 Subject: Use guild ID from configuration file. --- bot/cogs/sync/cog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 4556bae83..ab591ebf8 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -6,7 +6,8 @@ from discord import Guild, Member, Role from discord.ext import commands from discord.ext.commands import Bot -from . import syncers +from bot import constants +from bot.cogs.sync import syncers log = logging.getLogger(__name__) @@ -17,7 +18,7 @@ class Sync: # The server to synchronize events on. # Note that setting this wrongly will result in things getting deleted # that possibly shouldn't be. - SYNC_SERVER_ID = 267624335836053506 + SYNC_SERVER_ID = constants.Guild.id # An iterable of callables that are called when the bot is ready. ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( @@ -98,7 +99,7 @@ class Sync: if e.status != 404: raise - got_error = True # gorgeous + got_error = True # yikes if got_error: # If we got `404`, the user is new. Create them. -- cgit v1.2.3 From 7cd9b233a4f103af781822cb6af329d6018656a5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 17:16:49 +0100 Subject: Move by-user infraction search to Django API backend. --- bot/cogs/moderation.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index ac08d3dd4..45f35cbe4 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -927,25 +927,14 @@ class Moderation(Scheduler): Search for infractions by member. """ - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user.id - ), - params={"hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions for user {user} ({user.id}).") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) embed = Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) @with_role(*MODERATION_ROLES) @@ -983,11 +972,10 @@ class Moderation(Scheduler): await ctx.send(f":warning: No infractions could be found for that query.") return - lines = [] - for infraction in infractions: - lines.append( - self._infraction_to_string(infraction) - ) + lines = [ + self._infraction_to_string(infraction) + for infraction in infractions + ] await LinePaginator.paginate( lines, @@ -1092,12 +1080,12 @@ class Moderation(Scheduler): ) def _infraction_to_string(self, infraction_object): - actor_id = int(infraction_object["actor"]["user_id"]) + actor_id = infraction_object["actor"] guild: Guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) - active = infraction_object["active"] is True - user_id = int(infraction_object["user"]["user_id"]) - hidden = infraction_object.get("hidden", False) is True + active = infraction_object["active"] + user_id = infraction_object["user"] + hidden = infraction_object["hidden"] lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From 336be0df936513707941566564f6d5e22670a843 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 17:22:20 +0100 Subject: Move by-reason infraction search to Django API backend. --- bot/cogs/moderation.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 45f35cbe4..1ea19d2df 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -944,23 +944,13 @@ class Moderation(Scheduler): Search for infractions by their reason. Use Re2 for matching. """ - try: - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"search": reason, "hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions matching reason `{reason}`.") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + infraction_list = await self.bot.api_client.get( + 'bot/infractions', params={'search': reason} + ) embed = Embed( title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) # endregion -- cgit v1.2.3 From 2a2b6cee2e70daf9b60a18cd04bc383407196e9d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 17:23:36 +0100 Subject: Remove unused import, use `tuple`. --- bot/cogs/moderation.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 1ea19d2df..7cc2c9b86 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -3,7 +3,6 @@ import logging import textwrap from typing import Union -from aiohttp import ClientError from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User ) @@ -962,10 +961,10 @@ class Moderation(Scheduler): await ctx.send(f":warning: No infractions could be found for that query.") return - lines = [ + lines = tuple( self._infraction_to_string(infraction) for infraction in infractions - ] + ) await LinePaginator.paginate( lines, -- cgit v1.2.3 From 809348d633172c3c2cfd785cd1d6aa0331e48e74 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 17:32:13 +0100 Subject: Move note and warning creation to Django API backend. --- bot/cogs/moderation.py | 9 +++++++-- bot/utils/moderation.py | 24 +++++++++--------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 7cc2c9b86..d725755cd 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -87,7 +87,9 @@ class Moderation(Scheduler): reason=reason ) - response_object = await post_infraction(ctx, user, type="warning", reason=reason) + response_object = await post_infraction( + ctx, user, type="warning", reason=reason + ) if response_object is None: return @@ -386,7 +388,10 @@ class Moderation(Scheduler): :param reason: The reason for the warning. """ - response_object = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + response_object = await post_infraction( + ctx, user, type="warning", reason=reason, hidden=True + ) + if response_object is None: return diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 724b455bc..459fe6eb3 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -13,33 +13,27 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False + ctx: Context, user: Union[Member, Object, User], + type: str, reason: str, duration: str = None, hidden: bool = False ): payload = { - "type": type, + "actor": ctx.message.author.id, + "hidden": hidden, "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": hidden + "type": type, + "user": user.id } if duration: payload['duration'] = duration try: - response = await ctx.bot.http_session.post( - URLs.site_infractions, - headers=HEADERS, - json=payload + response = await ctx.bot.api_client.post( + 'bot/infractions', json=payload ) except ClientError: log.exception("There was an error adding an infraction.") await ctx.send(":x: There was an error adding the infraction.") return - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") - return - - return response_object + return response -- cgit v1.2.3 From 6e640122cca34bd8a060f75f44a878a00f2b30c5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 20:18:51 +0100 Subject: Move all moderation commands to the Django API. --- Pipfile | 3 +- Pipfile.lock | 100 ++++++++------ bot/api.py | 4 + bot/cogs/moderation.py | 349 +++++++++++++++++++++++++++--------------------- bot/converters.py | 21 +++ bot/utils/moderation.py | 7 +- bot/utils/time.py | 2 +- 7 files changed, 291 insertions(+), 195 deletions(-) diff --git a/Pipfile b/Pipfile index aafc9107a..703057af5 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,7 @@ aio-pika = "*" python-dateutil = "*" deepdiff = "*" requests = "*" +dateparser = "*" [dev-packages] "flake8" = ">=3.6" @@ -32,7 +33,7 @@ dodgy = "*" pytest = "*" [requires] -python_version = "3.6" +python_version = "3.7" [scripts] start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index e273e5276..6c565c85f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4f40c03d9fa30eb15e0bc6b962d42d3185e2b2cdf52d9b31969bbd119b9ed61e" + "sha256": "52bb2561c3036c40f44d3c5da359a5089e7a543cb05d9c8525553207697c98a1" }, "pipfile-spec": 6, "requires": { @@ -147,6 +147,14 @@ ], "version": "==3.0.4" }, + "dateparser": { + "hashes": [ + "sha256:940828183c937bcec530753211b70f673c0a9aab831e43273489b310538dff86", + "sha256:b452ef8b36cd78ae86a50721794bc674aa3994e19b570f7ba92810f4e0a2ae03" + ], + "index": "pypi", + "version": "==0.7.0" + }, "deepdiff": { "hashes": [ "sha256:152b29dd9cd97cc78403121fb394925ec47377d4a410751e56547c3930ba2b39", @@ -232,39 +240,35 @@ }, "lxml": { "hashes": [ - "sha256:16cf8bac33ec17049617186d63006ba49da7c5be417042877a49f0ef6d7a195d", - "sha256:18f2d8f14cc61e66e8a45f740d15b6fc683c096f733db1f8d0ee15bcac9843de", - "sha256:260868f69d14a64dd1de9cf92e133d2f71514d288de4906f109bdf48ca9b756a", - "sha256:29b8acd8ecdf772266dbac491f203c71664b0b07ad4309ba2c3bb131306332fc", - "sha256:2b05e5e06f8e8c63595472dc887d0d6e0250af754a35ba690f6a6abf2ef85691", - "sha256:30d6ec05fb607a5b7345549f642c7c7a5b747b634f6d5e935596b910f243f96f", - "sha256:3bf683f0237449ebc1851098f664410e3c99ba3faa8c9cc82c6acfe857df1767", - "sha256:3ce5488121eb15513c4b239dadd67f9e7959511bd766aac6be0c35e80274f298", - "sha256:48be0c375350a5519bb9474b42a9c0e7ab709fb45f11bfcd33de876791137896", - "sha256:49bc343ca3b30cd860845433bb9f62448a54ff87b632175108bacbc5dc63e49e", - "sha256:4cc7531e86a43ea66601763c5914c3d3adb297f32e4284957609b90d41825fca", - "sha256:4e9822fad564d82035f0b6d701a890444560210f8a8648b8f15850f8fe883cd9", - "sha256:51a9a441aefc8c93512bad5efe867d2ff086e7249ce0fc3b47c310644b352936", - "sha256:5bbed9efc8aeb69929140f71a30e655bf496b45b766861513960e1b11168d475", - "sha256:60a5323b2bc893ca1059d283d6695a172d51cc95a70c25b3e587e1aad5459c38", - "sha256:7035d9361f3ceec9ccc1dd3482094d1174580e7e1bf6870b77ea758f7cad15d2", - "sha256:76d62cc048bda0ebf476689ad3eb8e65e6827e43a7521be3b163071020667b8c", - "sha256:78163b578e6d1836012febaa1865e095ccc7fc826964dd69a2dbfe401618a1f7", - "sha256:83b58b2b5904d50de03a47e2f56d24e9da4cf7e3b0d66fb4510b18fca0faf910", - "sha256:a07447e46fffa5bb4d7a0af0a6505c8517e9bd197cfd2aec79e499b6e86cde49", - "sha256:a17d808b3edca4aaf6b295b5a388c844a0b7f79aca2d79eec5acc1461db739e3", - "sha256:a378fd61022cf4d3b492134c3bc48204ac2ff19e0813b23e07c3dd95ae8df0bc", - "sha256:aa7d096a44ae3d475c5ed763e24cf302d32462e78b61bba73ce1ad0efb8f522a", - "sha256:ade8785c93a985956ba6499d5ea6d0a362e24b4a9ba07dd18920fd67cccf63ea", - "sha256:cc039668f91d8af8c4094cfb5a67c7ae733967fdc84c0507fe271db81480d367", - "sha256:d89f1ffe98744c4b5c11f00fb843a4e72f68a6279b5e38168167f1b3c0fdd84c", - "sha256:e691b6ef6e27437860016bd6c32e481bdc2ed3af03289707a38b9ca422105f40", - "sha256:e750da6ac3ca624ae3303df448664012f9b6f9dfbc5d50048ea8a12ce2f8bc29", - "sha256:eca305b200549906ea25648463aeb1b3b220b716415183eaa99c998a846936d9", - "sha256:f52fe795e08858192eea167290033b5ff24f50f51781cb78d989e8d63cfe73d1" + "sha256:0dd6589fa75d369ba06d2b5f38dae107f76ea127f212f6a7bee134f6df2d1d21", + "sha256:1afbac344aa68c29e81ab56c1a9411c3663157b5aee5065b7fa030b398d4f7e0", + "sha256:1baad9d073692421ad5dbbd81430aba6c7f5fdc347f03537ae046ddf2c9b2297", + "sha256:1d8736421a2358becd3edf20260e41a06a0bf08a560480d3a5734a6bcbacf591", + "sha256:1e1d9bddc5afaddf0de76246d3f2152f961697ad7439c559f179002682c45801", + "sha256:1f179dc8b2643715f020f4d119d5529b02cd794c1c8f305868b73b8674d2a03f", + "sha256:241fb7bdf97cb1df1edfa8f0bcdfd80525d4023dac4523a241907c8b2f44e541", + "sha256:2f9765ee5acd3dbdcdc0d0c79309e01f7c16bc8d39b49250bf88de7b46daaf58", + "sha256:312e1e1b1c3ce0c67e0b8105317323e12807955e8186872affb667dbd67971f6", + "sha256:3273db1a8055ca70257fd3691c6d2c216544e1a70b673543e15cc077d8e9c730", + "sha256:34dfaa8c02891f9a246b17a732ca3e99c5e42802416628e740a5d1cb2f50ff49", + "sha256:3aa3f5288af349a0f3a96448ebf2e57e17332d99f4f30b02093b7948bd9f94cc", + "sha256:51102e160b9d83c1cc435162d90b8e3c8c93b28d18d87b60c56522d332d26879", + "sha256:56115fc2e2a4140e8994eb9585119a1ae9223b506826089a3ba753a62bd194a6", + "sha256:69d83de14dbe8fe51dccfd36f88bf0b40f5debeac763edf9f8325180190eba6e", + "sha256:99fdce94aeaa3ccbdfcb1e23b34273605c5853aa92ec23d84c84765178662c6c", + "sha256:a7c0cd5b8a20f3093ee4a67374ccb3b8a126743b15a4d759e2a1bf098faac2b2", + "sha256:abe12886554634ed95416a46701a917784cb2b4c77bfacac6916681d49bbf83d", + "sha256:b4f67b5183bd5f9bafaeb76ad119e977ba570d2b0e61202f534ac9b5c33b4485", + "sha256:bdd7c1658475cc1b867b36d5c4ed4bc316be8d3368abe03d348ba906a1f83b0e", + "sha256:c6f24149a19f611a415a51b9bc5f17b6c2f698e0d6b41ffb3fa9f24d35d05d73", + "sha256:d1e111b3ab98613115a208c1017f266478b0ab224a67bc8eac670fa0bad7d488", + "sha256:d6520aa965773bbab6cb7a791d5895b00d02cf9adc93ac2bf4edb9ac1a6addc5", + "sha256:dd185cde2ccad7b649593b0cda72021bc8a91667417001dbaf24cd746ecb7c11", + "sha256:de2e5b0828a9d285f909b5d2e9d43f1cf6cf21fe65bc7660bdaa1780c7b58298", + "sha256:f726444b8e909c4f41b4fde416e1071cf28fa84634bfb4befdf400933b6463af" ], "index": "pypi", - "version": "==4.2.6" + "version": "==4.3.0" }, "markdownify": { "hashes": [ @@ -506,6 +510,20 @@ "index": "pypi", "version": "==3.13" }, + "regex": { + "hashes": [ + "sha256:15b4a185ae9782133f398f8ab7c29612a6e5f34ea9411e4cd36e91e78c347ebe", + "sha256:3852b76f0b6d7bd98d328d548716c151b79017f2b81347360f26e5db10fb6503", + "sha256:79a6a60ed1ee3b12eb0e828c01d75e3b743af6616d69add6c2fde1d425a4ba3f", + "sha256:a2938c290b3be2c7cadafa21de3051f2ed23bfaf88728a1fe5dc552cbfdb0326", + "sha256:aff7414712c9e6d260609da9c9af3aacebfbc307a4abe3376c7736e2a6c8563f", + "sha256:d03782f0b0fa34f8f1dbdc94e27cf193b83c6105307a8c10563938c6d85180d9", + "sha256:db79ac3d81e655dc12d38a865dd6d1b569a28fab4c53749051cd599a6eb7614f", + "sha256:e803b3646c3f9c47f1f3dc870173c5d79c0fd2fd8e40bf917b97c7b56701baff", + "sha256:e9660ccca360b6bd79606aab3672562ebb14bce6af6c501107364668543f4bef" + ], + "version": "==2018.11.22" + }, "requests": { "hashes": [ "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", @@ -536,10 +554,10 @@ }, "soupsieve": { "hashes": [ - "sha256:057e08f362a255b457a5781675211556799ed3bb8807506eaac3809390bc304b", - "sha256:f7d99b41637be2f249dfcc06ae93c13fcbbdfa7bb68b15308cdd0734e58146f1" + "sha256:638535780f7b966411123d56eb3b89cd1d2e42d707270c6d7d053c7720a238f3", + "sha256:cb61b59c55f9f6e91928a03fe4b500ac1fcef6f8e68082a630db098ab33e2126" ], - "version": "==1.6.1" + "version": "==1.6.2" }, "sphinx": { "hashes": [ @@ -556,6 +574,12 @@ ], "version": "==1.1.0" }, + "tzlocal": { + "hashes": [ + "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e" + ], + "version": "==1.5.1" + }, "urllib3": { "hashes": [ "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", @@ -769,11 +793,11 @@ }, "pytest": { "hashes": [ - "sha256:f689bf2fc18c4585403348dd56f47d87780bf217c53ed9ae7a3e2d7faa45f8e9", - "sha256:f812ea39a0153566be53d88f8de94839db1e8a05352ed8a49525d7d7f37861e9" + "sha256:3e65a22eb0d4f1bdbc1eacccf4a3198bf8d4049dea5112d70a0c61b00e748d02", + "sha256:5924060b374f62608a078494b909d341720a050b5224ff87e17e12377486a71d" ], "index": "pypi", - "version": "==4.0.2" + "version": "==4.1.0" }, "pyyaml": { "hashes": [ diff --git a/bot/api.py b/bot/api.py index 0a2c192ce..2e1a239ba 100644 --- a/bot/api.py +++ b/bot/api.py @@ -29,6 +29,10 @@ class APIClient: async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() + async def patch(self, endpoint: str, *args, **kwargs): + async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: + return await resp.json() + async def post(self, endpoint: str, *args, **kwargs): async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: return await resp.json() diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index d725755cd..51f4a3d79 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,6 +1,7 @@ import asyncio import logging import textwrap +from datetime import datetime from typing import Union from discord import ( @@ -13,12 +14,12 @@ from discord.ext.commands import ( from bot import constants from bot.cogs.modlog import ModLog from bot.constants import Colours, Event, Icons, Keys, Roles, URLs -from bot.converters import InfractionSearchQuery +from bot.converters import ExpirationDate, InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils.moderation import post_infraction from bot.utils.scheduling import Scheduler, create_task -from bot.utils.time import parse_rfc1123, wait_until +from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -59,16 +60,13 @@ class Moderation(Scheduler): async def on_ready(self): # Schedule expiration for previous infractions - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"dangling": "true"}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', params={'active': 'true'} ) - infraction_list = await response.json() loop = asyncio.get_event_loop() - for infraction_object in infraction_list: - if infraction_object["expires_at"] is not None: - self.schedule_task(loop, infraction_object["id"], infraction_object) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(loop, infraction["id"], infraction) # region: Permanent infractions @@ -172,10 +170,23 @@ class Moderation(Scheduler): :param reason: The reason for the ban. """ + active_bans = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } + ) + if active_bans: + return await ctx.send( + ":x: According to my records, this user is already banned. " + f"See infraction **#{active_bans[0]['id']}**." + ) + notified = await self.notify_infraction( user=user, infr_type="Ban", - duration="Permanent", reason=reason ) @@ -220,11 +231,23 @@ class Moderation(Scheduler): :param reason: The reason for the mute. """ + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': str(user.id) + } + ) + if active_mutes: + return await ctx.send( + ":x: According to my records, this user is already muted. " + f"See infraction **#{active_mutes[0]['id']}**." + ) + notified = await self.notify_infraction( - user=user, - infr_type="Mute", - duration="Permanent", - reason=reason + user=user, infr_type="Mute", + expires_at="Permanent", reason=reason ) response_object = await post_infraction(ctx, user, type="mute", reason=reason) @@ -264,7 +287,10 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(name="tempmute") - async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): + async def tempmute( + self, ctx: Context, user: Member, expiration: ExpirationDate, + *, reason: str = None + ): """ Create a temporary mute infraction in the database for a user. :param user: Accepts user mention, ID, etc. @@ -272,25 +298,42 @@ class Moderation(Scheduler): :param reason: The reason for the temporary mute. """ + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': str(user.id) + } + ) + if active_mutes: + return await ctx.send( + ":x: According to my records, this user is already muted. " + f"See infraction **#{active_mutes[0]['id']}**." + ) + notified = await self.notify_infraction( - user=user, - infr_type="Mute", - duration=duration, - reason=reason + user=user, infr_type="Mute", + expires_at=expiration, reason=reason ) - response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration) - if response_object is None: - return + infraction = await post_infraction( + ctx, user, + type="mute", reason=reason, + expires_at=expiration + ) self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" @@ -313,30 +356,47 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """) ) @with_role(*MODERATION_ROLES) @command(name="tempban") - async def tempban(self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None): + async def tempban( + self, ctx: Context, user: Union[User, proxy_user], expiry: ExpirationDate, + *, reason: str = None + ): """ Create a temporary ban infraction in the database for a user. :param user: Accepts user mention, ID, etc. - :param duration: The duration for the temporary ban infraction + :param expiry: The duration for the temporary ban infraction :param reason: The reason for the temporary ban. """ + active_bans = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } + ) + if active_bans: + return await ctx.send( + ":x: According to my records, this user is already banned. " + f"See infraction **#{active_bans[0]['id']}**." + ) + notified = await self.notify_infraction( - user=user, - infr_type="Ban", - duration=duration, - reason=reason + user=user, infr_type="Ban", + expires_at=expiry, reason=reason ) - response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration) - if response_object is None: + infraction = await post_infraction( + ctx, user, type="ban", + reason=reason, expires_at=expiry + ) + if infraction is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -344,11 +404,14 @@ class Moderation(Scheduler): guild: Guild = ctx.guild await guild.ban(user, reason=reason, delete_message_days=0) - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" @@ -371,7 +434,6 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """) ) @@ -635,27 +697,26 @@ class Moderation(Scheduler): try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="mute" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': user.id + } ) - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") - return + if len(response) > 1: + log.warning("Found more than one active mute infraction for user `%d`", user.id) - infraction_object = response_object["infraction"] - if infraction_object is None: + if not response: # no active infraction await ctx.send(f":x: There is no active mute infraction for user {user.mention}.") return - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["id"]) + infraction = response[0] + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) notified = await self.notify_pardon( user=user, @@ -679,7 +740,7 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} - Intended expiry: {infraction_object['expires_at']} + Intended expiry: {infraction['expires_at']} """) ) except Exception: @@ -697,27 +758,29 @@ class Moderation(Scheduler): try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="ban" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } ) - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error removing the infraction: {response_object['error_message']}") - return + if len(response) > 1: + log.warning( + "More than one active ban infraction found for user `%d`.", + user.id + ) - infraction_object = response_object["infraction"] - if infraction_object is None: + if not response: # no active infraction await ctx.send(f":x: There is no active ban infraction for user {user.mention}.") return - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["id"]) + infraction = response[0] + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) await ctx.send(f":ok_hand: Un-banned {user.mention}.") @@ -730,7 +793,7 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} - Intended expiry: {infraction_object['expires_at']} + Intended expiry: {infraction['expires_at']} """) ) except Exception: @@ -757,60 +820,64 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="duration") - async def edit_duration(self, ctx: Context, infraction_id: str, duration: str): + async def edit_duration( + self, ctx: Context, + infraction_id: int, expires_at: Union[ExpirationDate, str] + ): """ Sets the duration of the given infraction, relative to the time of updating. - :param infraction_id: the id (UUID) of the infraction - :param duration: the new duration of the infraction, relative to the time of updating. Use "permanent" to mark - the infraction as permanent. + :param infraction_id: the id of the infraction + :param expires_at: the new expiration date of the infraction. + Use "permanent" to mark the infraction as permanent. """ - try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + if isinstance(expires_at, str) and expires_at != 'permanent': + raise BadArgument( + "If `expires_at` is given as a non-datetime, " + "it must be `permanent`." ) + if expires_at == 'permanent': + expires_at = None - previous_object = await previous.json() + try: + previous_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) + ) - if duration == "permanent": - duration = None # check the current active infraction - response = await self.bot.http_session.patch( - URLs.site_infractions, + infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), json={ - "id": infraction_id, - "duration": duration - }, - headers=self.headers + 'expires_at': ( + expires_at.isoformat() + if expires_at is not None + else None + ) + } ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") - return - infraction_object = response_object["infraction"] # Re-schedule - self.cancel_task(infraction_id) + self.cancel_task(infraction['id']) loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(loop, infraction['id'], infraction) - if duration is None: + if expires_at is None: await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") else: - await ctx.send(f":ok_hand: Updated infraction: set to expire on {infraction_object['expires_at']}.") + human_expiry = ( + datetime + .fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) + await ctx.send(f":ok_hand: Updated infraction: set to expire on {human_expiry}.") except Exception: log.exception("There was an error updating an infraction.") await ctx.send(":x: There was an error updating the infraction.") return - prev_infraction = previous_object["infraction"] - # Get information about the infraction's user - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction["user"] user = ctx.guild.get_member(user_id) if user: @@ -821,7 +888,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(infraction_object["actor"]["user_id"]) + actor_id = infraction["actor"] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -833,54 +900,38 @@ class Moderation(Scheduler): Member: {member_text} Actor: {actor} Edited by: {ctx.message.author} - Previous expiry: {prev_infraction['expires_at']} - New expiry: {infraction_object['expires_at']} + Previous expiry: {previous_infraction['expires_at']} + New expiry: {infraction['expires_at']} """) ) @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str): + async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str): """ Sets the reason of the given infraction. - :param infraction_id: the id (UUID) of the infraction + :param infraction_id: the id of the infraction :param reason: The new reason of the infraction """ try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + old_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) ) - previous_object = await previous.json() - - response = await self.bot.http_session.patch( - URLs.site_infractions, - json={ - "id": infraction_id, - "reason": reason - }, - headers=self.headers + updated_infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), + json={'reason': reason} ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - await ctx.send(f":x: There was an error updating the infraction: {response_object['error_message']}") - return - await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") + except Exception: log.exception("There was an error updating an infraction.") await ctx.send(":x: There was an error updating the infraction.") return - new_infraction = response_object["infraction"] - prev_infraction = previous_object["infraction"] - # Get information about the infraction's user - user_id = int(new_infraction["user"]["user_id"]) + user_id = updated_infraction['user'] user = ctx.guild.get_member(user_id) if user: @@ -891,7 +942,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(new_infraction["actor"]["user_id"]) + actor_id = updated_infraction['actor'] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -903,8 +954,8 @@ class Moderation(Scheduler): Member: {user_text} Actor: {actor} Edited by: {ctx.message.author} - Previous reason: {prev_infraction['reason']} - New reason: {new_infraction['reason']} + Previous reason: {old_infraction['reason']} + New reason: {updated_infraction['reason']} """) ) @@ -1023,7 +1074,7 @@ class Moderation(Scheduler): infraction_id = infraction_object["id"] # transform expiration to delay in seconds - expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) + expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") @@ -1032,7 +1083,7 @@ class Moderation(Scheduler): self.cancel_task(infraction_object["id"]) # Notify the user that they've been unmuted. - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] guild = self.bot.get_guild(constants.Guild.id) await self.notify_pardon( user=guild.get_member(user_id), @@ -1043,13 +1094,13 @@ class Moderation(Scheduler): async def _deactivate_infraction(self, infraction_object): """ - A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or - un-schedule an expiration task. + A co-routine which marks an infraction as inactive on the website. + This co-routine does not cancel or un-schedule an expiration task. :param infraction_object: the infraction in question """ guild: Guild = self.bot.get_guild(constants.Guild.id) - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] infraction_type = infraction_object["type"] if infraction_type == "mute": @@ -1064,13 +1115,9 @@ class Moderation(Scheduler): user: Object = Object(user_id) await guild.unban(user) - await self.bot.http_session.patch( - URLs.site_infractions, - headers=self.headers, - json={ - "id": infraction_object["id"], - "active": False - } + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_object['id']), + json={"active": False} ) def _infraction_to_string(self, infraction_object): @@ -1098,7 +1145,8 @@ class Moderation(Scheduler): return lines.strip() async def notify_infraction( - self, user: Union[User, Member], infr_type: str, duration: str = None, reason: str = None + self, user: Union[User, Member], infr_type: str, + expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided." ): """ Notify a user of their fresh infraction :) @@ -1109,16 +1157,13 @@ class Moderation(Scheduler): :param reason: The reason for the infraction. """ - if duration is None: - duration = "N/A" - - if reason is None: - reason = "No reason provided." + if isinstance(expires_at, datetime): + expires_at = expires_at.strftime('%c') embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type} - **Duration:** {duration} + **Expires:** {expires_at} **Reason:** {reason} """), colour=Colour(Colours.soft_red) diff --git a/bot/converters.py b/bot/converters.py index 069e841f9..1100b502c 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,8 +1,10 @@ import logging import random import socket +from datetime import datetime from ssl import CertificateError +import dateparser import discord from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector from discord.ext.commands import BadArgument, Context, Converter @@ -254,3 +256,22 @@ class TagContentConverter(Converter): raise BadArgument("Tag contents should not be empty, or filled with whitespace.") return tag_content + + +class ExpirationDate(Converter): + DATEPARSER_SETTINGS = { + 'PREFER_DATES_FROM': 'future', + 'TIMEZONE': 'UTC', + 'TO_TIMEZONE': 'UTC' + } + + async def convert(self, ctx, expiration_string: str): + expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS) + if expiry is None: + raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`") + + now = datetime.utcnow() + if expiry < now: + expiry = now + (now - expiry) + + return expiry diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 459fe6eb3..2611ee993 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import Union from aiohttp import ClientError @@ -14,7 +15,7 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( ctx: Context, user: Union[Member, Object, User], - type: str, reason: str, duration: str = None, hidden: bool = False + type: str, reason: str, expires_at: datetime = None, hidden: bool = False ): payload = { @@ -24,8 +25,8 @@ async def post_infraction( "type": type, "user": user.id } - if duration: - payload['duration'] = duration + if expires_at: + payload['expires_at'] = expires_at.isoformat() try: response = await ctx.bot.api_client.post( diff --git a/bot/utils/time.py b/bot/utils/time.py index 8e5d4e1bd..a330c9cd8 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -106,7 +106,7 @@ async def wait_until(time: datetime.datetime): :param time: A datetime.datetime object to wait until. """ - delay = time - datetime.datetime.now(tz=datetime.timezone.utc) + delay = time - datetime.datetime.utcnow() delay_seconds = delay.total_seconds() if delay_seconds > 1.0: -- cgit v1.2.3 From f40c09ca70bcaf0d4f846f09db1826bbca729460 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 6 Jan 2019 20:20:08 +0100 Subject: Remove dead code from old API client. --- bot/cogs/moderation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 51f4a3d79..9db5d9d62 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -13,7 +13,7 @@ from discord.ext.commands import ( from bot import constants from bot.cogs.modlog import ModLog -from bot.constants import Colours, Event, Icons, Keys, Roles, URLs +from bot.constants import Colours, Event, Icons, Roles from bot.converters import ExpirationDate, InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator @@ -50,7 +50,6 @@ class Moderation(Scheduler): def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._muted_role = Object(constants.Roles.muted) super().__init__() -- cgit v1.2.3 From 3042e76d5357b72e5c21d1404b919988e6466ec3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Jan 2019 13:53:05 +0100 Subject: Support obtaining individual rules via `!site rules` command. --- bot/cogs/site.py | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 442e80cd2..dee58ea76 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,3 +1,4 @@ +import gettext import logging from discord import Colour, Embed @@ -93,17 +94,42 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="rules") - async def site_rules(self, ctx: Context): + async def site_rules(self, ctx: Context, *selection: int): """Info about the server's rules.""" url = f"{URLs.site_schema}{URLs.site}/about/rules" - - embed = Embed(title="Rules") + full_rules = await self.bot.api_client.get( + 'rules', params={'link_format': 'md'} + ) + if selection: + invalid_indices = tuple( + pick + for pick in selection + if pick < 0 or pick >= len(full_rules) + ) + + if invalid_indices: + return await ctx.send( + embed=Embed( + title='Invalid rule indices', + description=', '.join(map(str, invalid_indices)), + colour=Colour.red() + ) + ) + title = ( + gettext.ngettext("Rule", 'Rules', len(selection)) + + " " + ", ".join(map(str, selection)) + ) + else: + title = "Full rules" + selection = range(len(full_rules)) + + embed = Embed(title=title) embed.set_footer(text=url) embed.colour = Colour.blurple() - embed.description = ( - f"The rules and guidelines that apply to this community can be found on our [rules page]({url}). " - "We expect all members of the community to have read and understood these." + embed.description = '\n'.join( + f"**{pick}**: {full_rules[pick]}" + for pick in selection ) await ctx.send(embed=embed) -- cgit v1.2.3 From 445e44731b632f05a74e652029d3d98ef083ced0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Jan 2019 14:31:25 +0100 Subject: Use a single `Dockerfile` instead of two. --- Dockerfile | 32 ++++++++++++++++++++++++++++++++ docker/base.Dockerfile | 17 ----------------- docker/bot.Dockerfile | 17 ----------------- 3 files changed, 32 insertions(+), 34 deletions(-) create mode 100644 Dockerfile delete mode 100644 docker/base.Dockerfile delete mode 100644 docker/bot.Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..983a226e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.7-alpine3.7 + +RUN apk add --update tini +RUN apk add --update build-base +RUN apk add --update libffi-dev +RUN apk add --update zlib +RUN apk add --update jpeg-dev +RUN apk add --update libxml2 libxml2-dev libxslt-dev +RUN apk add --update zlib-dev +RUN apk add --update freetype-dev +RUN apk add --update git + +ENV LIBRARY_PATH=/lib:/usr/lib +ENV PIPENV_VENV_IN_PROJECT=1 +ENV PIPENV_IGNORE_VIRTUALENVS=1 +ENV PIPENV_NOSPIN=1 +ENV PIPENV_HIDE_EMOJIS=1 +ENV PIPENV_VENV_IN_PROJECT=1 +ENV PIPENV_IGNORE_VIRTUALENVS=1 +ENV PIPENV_NOSPIN=1 +ENV PIPENV_HIDE_EMOJIS=1 + +RUN pip install -U pipenv + +RUN mkdir -p /bot +COPY . /bot +WORKDIR /bot + +RUN pipenv install --deploy --system + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["pipenv", "run", "start"] diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile deleted file mode 100644 index e46db756a..000000000 --- a/docker/base.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.6-alpine3.7 - -RUN apk add --update tini -RUN apk add --update build-base -RUN apk add --update libffi-dev -RUN apk add --update zlib -RUN apk add --update jpeg-dev -RUN apk add --update libxml2 libxml2-dev libxslt-dev -RUN apk add --update zlib-dev -RUN apk add --update freetype-dev -RUN apk add --update git - -ENV LIBRARY_PATH=/lib:/usr/lib -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile deleted file mode 100644 index 5a07a612b..000000000 --- a/docker/bot.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM pythondiscord/bot-base:latest - -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 - -RUN pip install -U pipenv - -RUN mkdir -p /bot -COPY . /bot -WORKDIR /bot - -RUN pipenv install --deploy - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pipenv", "run", "start"] -- cgit v1.2.3 From e6ed4e076b0361c98ccb0eb4deb9b617dd730e7f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Jan 2019 14:31:30 +0100 Subject: Update lockfile. --- Pipfile.lock | 99 ++++++++++++++++++++++++++++-------------------------------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 6c565c85f..6261537d1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "52bb2561c3036c40f44d3c5da359a5089e7a543cb05d9c8525553207697c98a1" + "sha256": "54aabf14cead3c3cee38ede8393ac0637ef0a2acd083cc4f5d2b18d498aa874f" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.7" }, "sources": [ { @@ -90,11 +90,11 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:1ed70a0e99742653953d68462378a1a8eb65dca5f7c8fa44a05a2a0b3545df67", - "sha256:6a7f5e0efc563cd1ffeefba6d528b97aa0d313c02dd126ba6c455e5fe5bd48eb", - "sha256:e394827904cc4923f443e8dd2e9968343669c8e1ad7a8d62d7541e780884acb8" + "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858", + "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348", + "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718" ], - "version": "==4.7.0" + "version": "==4.7.1" }, "certifi": { "hashes": [ @@ -202,13 +202,6 @@ ], "version": "==2.8" }, - "idna-ssl": { - "hashes": [ - "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c" - ], - "markers": "python_version < '3.7'", - "version": "==1.1.0" - }, "imagesize": { "hashes": [ "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", @@ -353,39 +346,39 @@ }, "pillow": { "hashes": [ - "sha256:0cd42fe2d99ec6ce23aaf00947a7b7956ad2ed4b1695fd37545c3b8eae06d95a", - "sha256:137bed8972089d65da63fb79b4949b0f2b99e9a58f1b494e82be43ba8b0f4226", - "sha256:14eb2b2e4f2a14f5c89fd0edf55c5af0bf1a40fdf3838d81867f26f131cd557d", - "sha256:1fc43ce8c4fa3754222cd6831d599ad17ca2fc9868d2fb52f4e5362dfbfaf379", - "sha256:26dfeee23a86dad6277a63d18f61f53b957cb2cd3506dbbd74b88ba2fa65b3b1", - "sha256:2e0e582942e025cc58f669499a8e0bffde5bcc8d42b65729f294c1dac54e4672", - "sha256:3bb8dd3ce101dd8b0b37eaae924a5bb93abb6ffdd034bf68a066a808e11768ab", - "sha256:3f07da3874f0b085421f1d4f979785131aa9d497501d8610d82f7378b33858f8", - "sha256:429b2b5ae5f57f8fd9ec2e012c1e7b342ff10f1a8977dc291976b9a3b4c096e1", - "sha256:4a000fdd89d77b6b675de27e1ab91c6fba517c08f19ee83e6716b78930634e04", - "sha256:4ccbe7cce6156391a3ecf447c79a7d4a1a0ecd3de79bdec9ca5e4f7242a306d1", - "sha256:4d08034196db41acb7392e4fccfc0448e7a87192c41d3011ad4093eac2c31ffd", - "sha256:6b202b1cb524bc76ed52a7eb0314f4b0a0497c7cceb9a93539b5a25800e1f2b6", - "sha256:8563b56fa7c34f1606848c2143ea67d27cf225b9726a1b041c3d27cf85e46edc", - "sha256:86d7421e8803d7bae2e594765c378a867b629d46b32fbfe5ed9fd95b30989feb", - "sha256:8d4bddedcb4ab99131d9705a75720efc48b3d006122dae1a4cc329496ac47c9a", - "sha256:a4929c6de9590635c34533609402c9da12b22bfc2feb8c0c4f38c39bab48a9ad", - "sha256:b0736e21798448cee3e663c0df7a6dfa83d805b3f3a45e67f7457a2f019e5fca", - "sha256:b669acba91d47395de84c9ca52a7ad393b487e5ae2e20b9b2790b22a57d479fa", - "sha256:bba993443921f2d077195b425a3283357f52b07807d53704610c1249d20b183a", - "sha256:bdf706a93d00547c9443b2654ae424fd54d5dece4bc4333e7035740aeb7a7cea", - "sha256:c5aa93e55175b9cde95279ccd03c93d218976b376480222d37be41d2c9c54510", - "sha256:cc11fd997d8ad71bb0412e983b711e49639c2ddba9b9dce04d4bdab575fe5f84", - "sha256:d584f1c33995c3dc16a35e30ef43e0881fa0d085f0fef29cebf154ffb5643363", - "sha256:d88f54bdefb7ddccb68efdd710d689aa6a09b875cc3e44b7e81ef54e0751e3a7", - "sha256:de0d323072be72fa4d74f4e013cd594e3f8ee03b2e0eac5876a3249fa076ef7b", - "sha256:f139c963c6679d236b2c45369524338eabd36a853fe23abd39ba246ab0a75aec", - "sha256:f41c0bf667c4c1c30b873eaa8d6bb894f6d721b3e38e9c993bddd1263c02fb1f", - "sha256:fbd0ea468b4ec04270533bf5206f1cd57746fcf226520bb133318fa276de2644", - "sha256:fe2d2850521c467c915ff0a6e27dc64c3c04c2f66612e0072672bd1bd4854b61" + "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e", + "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7", + "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a", + "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3", + "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1", + "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1", + "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7", + "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1", + "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3", + "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055", + "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf", + "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f", + "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f", + "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239", + "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe", + "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c", + "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697", + "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494", + "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356", + "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6", + "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000", + "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f", + "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c", + "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca", + "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8", + "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3", + "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad", + "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9", + "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc", + "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e" ], "index": "pypi", - "version": "==5.4.0" + "version": "==5.4.1" }, "pycares": { "hashes": [ @@ -488,10 +481,10 @@ }, "pytz": { "hashes": [ - "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", - "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" + "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", + "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" ], - "version": "==2018.7" + "version": "==2018.9" }, "pyyaml": { "hashes": [ @@ -554,10 +547,10 @@ }, "soupsieve": { "hashes": [ - "sha256:638535780f7b966411123d56eb3b89cd1d2e42d707270c6d7d053c7720a238f3", - "sha256:cb61b59c55f9f6e91928a03fe4b500ac1fcef6f8e68082a630db098ab33e2126" + "sha256:1d6ca207e67765d5297a59d1b5a18344a84587674d8c002cea72081c01a7f638", + "sha256:dff67354bff219f169ee634173c0148fcb0f7b23304ffddcfa2bb2f07accf30a" ], - "version": "==1.6.2" + "version": "==1.7" }, "sphinx": { "hashes": [ @@ -758,10 +751,10 @@ }, "pluggy": { "hashes": [ - "sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095", - "sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f" + "sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616", + "sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a" ], - "version": "==0.8.0" + "version": "==0.8.1" }, "py": { "hashes": [ -- cgit v1.2.3 From 389adacca74c86cab1abd2a0e6863ccc10dc102d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Jan 2019 14:47:45 +0100 Subject: Minimize stages required to build image. --- Dockerfile | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 983a226e4..e08e37a76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,33 @@ FROM python:3.7-alpine3.7 -RUN apk add --update tini -RUN apk add --update build-base -RUN apk add --update libffi-dev -RUN apk add --update zlib -RUN apk add --update jpeg-dev -RUN apk add --update libxml2 libxml2-dev libxslt-dev -RUN apk add --update zlib-dev -RUN apk add --update freetype-dev -RUN apk add --update git +RUN apk add --update --no-cache \ + build-base \ + freetype-dev \ + git \ + jpeg-dev \ + libffi-dev \ + libxml2 \ + libxml2-dev \ + libxslt-dev \ + tini \ + zlib \ + zlib-dev -ENV LIBRARY_PATH=/lib:/usr/lib -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 +ENV \ + LIBRARY_PATH=/lib:/usr/lib \ + PIPENV_HIDE_EMOJIS=1 \ + PIPENV_HIDE_EMOJIS=1 \ + PIPENV_IGNORE_VIRTUALENVS=1 \ + PIPENV_IGNORE_VIRTUALENVS=1 \ + PIPENV_NOSPIN=1 \ + PIPENV_NOSPIN=1 \ + PIPENV_VENV_IN_PROJECT=1 \ + PIPENV_VENV_IN_PROJECT=1 RUN pip install -U pipenv -RUN mkdir -p /bot -COPY . /bot WORKDIR /bot +COPY . . RUN pipenv install --deploy --system -- cgit v1.2.3 From 3949b46fda7926200499f61f984574beb799d1ce Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 13 Jan 2019 11:14:41 +0100 Subject: Drop `--update` flag since `--no-cache` implies it. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e08e37a76..864b4e557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.7-alpine3.7 -RUN apk add --update --no-cache \ +RUN apk add --no-cache \ build-base \ freetype-dev \ git \ -- cgit v1.2.3 From e9efae44711be6377c6678c2d2522265fe7fcb7d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 13 Jan 2019 11:35:24 +0100 Subject: Build and push Django branch image on Azure. --- azure-pipelines.yml | 2 -- scripts/deploy-azure.sh | 29 +++++------------------------ 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0f968e618..a6a633918 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -54,7 +54,5 @@ jobs: - task: ShellScript@2 displayName: 'Build and deploy containers' - inputs: scriptPath: scripts/deploy-azure.sh - args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)' diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 6b3dea508..af69ab46b 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -2,30 +2,11 @@ cd .. -# Build and deploy on master branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then - changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) - - if [ $changed_lines != '0' ]; then - echo "base.Dockerfile was changed" - - echo "Building bot base" - docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . - - echo "Pushing image to Docker Hub" - docker push pythondiscord/bot-base:latest - else - echo "base.Dockerfile was not changed, not building" - fi - +# Build and deploy on django branch, only if not a pull request +if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then echo "Building image" - docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . + docker build -t pythondiscord/bot:django . echo "Pushing image" - docker push pythondiscord/bot:latest - - echo "Deploying container" - curl -H "token: $1" $2 -else - echo "Skipping deploy" -fi \ No newline at end of file + docker push pythondiscord/bot:django +fi -- cgit v1.2.3 From 539451aba55201b25637d0f452010cd878e71292 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Jan 2019 09:28:54 +0100 Subject: Move superstarify cog to Django API. --- bot/cogs/superstarify.py | 285 -------------------------------------- bot/cogs/superstarify/__init__.py | 281 +++++++++++++++++++++++++++++++++++++ bot/cogs/superstarify/stars.py | 86 ++++++++++++ 3 files changed, 367 insertions(+), 285 deletions(-) delete mode 100644 bot/cogs/superstarify.py create mode 100644 bot/cogs/superstarify/__init__.py create mode 100644 bot/cogs/superstarify/stars.py diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py deleted file mode 100644 index 84467bd8c..000000000 --- a/bot/cogs/superstarify.py +++ /dev/null @@ -1,285 +0,0 @@ -import logging -import random - -from discord import Colour, Embed, Member -from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command - -from bot.cogs.moderation import Moderation -from bot.cogs.modlog import ModLog -from bot.constants import ( - Icons, Keys, - NEGATIVE_REPLIES, POSITIVE_REPLIES, - Roles, URLs -) -from bot.decorators import with_role - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" - - -class Superstarify: - """ - A set of commands to moderate terrible nicknames. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} - - @property - def moderation(self) -> Moderation: - return self.bot.get_cog("Moderation") - - @property - def modlog(self) -> ModLog: - return self.bot.get_cog("ModLog") - - async def on_member_update(self, before: Member, after: Member): - """ - This event will trigger when someone changes their name. - At this point we will look up the user in our database and check - whether they are allowed to change their names, or if they are in - superstar-prison. If they are not allowed, we will change it back. - """ - - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.debug( - f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in superstar-prison..." - ) - - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(before.id)} - ) - - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - if after.display_name == response.get("forced_nick"): - return # Nick change was triggered by this event. Ignore. - - log.debug( - f"{after.display_name} is currently in superstar-prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit(nick=response.get("forced_nick")) - try: - await after.send( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so. " - "You will be allowed to change your nickname again at the following time:\n\n" - f"**{response.get('end_timestamp')}**." - ) - except Forbidden: - log.warning( - "The user tried to change their nickname while in superstar-prison. " - "This led to the bot trying to DM the user to let them know they cannot do that, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - async def on_member_join(self, member: Member): - """ - This event will trigger when someone (re)joins the server. - At this point we will look up the user in our database and check - whether they are in superstar-prison. If so, we will change their name - back to the forced nickname. - """ - - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(member.id)} - ) - - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - forced_nick = response.get("forced_nick") - end_timestamp = response.get("end_timestamp") - log.debug( - f"{member.name} rejoined but is currently in superstar-prison. " - f"Changing the nick back to {forced_nick}." - ) - - await member.edit(nick=forced_nick) - try: - await member.send( - "You have left and rejoined the **Python Discord** server, effectively resetting " - f"your nickname from **{forced_nick}** to **{member.name}**, " - "but as you are currently in superstar-prison, you do not have permission to do so. " - "Therefore your nickname was automatically changed back. You will be allowed to " - "change your nickname again at the following time:\n\n" - f"**{end_timestamp}**." - ) - except Forbidden: - log.warning( - "The user left and rejoined the server while in superstar-prison. " - "This led to the bot trying to DM the user to let them know their name was restored, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified member potentially tried to escape the prison.\n" - f"Restored enforced nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_timestamp}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Superstar member rejoined server", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - @command(name='superstarify', aliases=('force_nick', 'star')) - @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): - """ - This command will force a random superstar name (like Taylor Swift) to be the user's - nickname for a specified duration. If a forced_nick is provided, it will use that instead. - - :param ctx: Discord message context - :param ta: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. - """ - - log.debug( - f"Attempting to superstarify {member.display_name} for {duration}. " - f"forced_nick is set to {forced_nick}." - ) - - embed = Embed() - embed.colour = Colour.blurple() - - params = { - "user_id": str(member.id), - "duration": duration - } - - if forced_nick: - params["forced_nick"] = forced_nick - - response = await self.bot.http_session.post( - URLs.site_superstarify_api, - headers=self.headers, - json=params - ) - - response = await response.json() - - if "error_message" in response: - log.warning( - "Encountered the following error when trying to superstarify the user:\n" - f"{response.get('error_message')}" - ) - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - return await ctx.send(embed=embed) - - else: - forced_nick = response.get('forced_nick') - end_time = response.get("end_timestamp") - image_url = response.get("image_url") - old_nick = member.display_name - - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{old_nick}**, was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{end_time}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - embed.set_image(url=image_url) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified by **{ctx.author.name}**\n" - f"Old nickname: `{old_nick}`\n" - f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_time}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Member Achieved Superstardom", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - await self.moderation.notify_infraction( - user=member, - infr_type="Superstarify", - duration=duration, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Change the nick and return the embed - log.debug("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - await ctx.send(embed=embed) - - @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) - @with_role(Roles.admin, Roles.owner, Roles.moderator) - async def unsuperstarify(self, ctx: Context, member: Member): - """ - This command will remove the entry from our database, allowing the user - to once again change their nickname. - - :param ctx: Discord message context - :param member: The member to unsuperstarify - """ - - log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") - - embed = Embed() - embed.colour = Colour.blurple() - - response = await self.bot.http_session.delete( - URLs.site_superstarify_api, - headers=self.headers, - json={"user_id": str(member.id)} - ) - - response = await response.json() - embed.description = "User has been released from superstar-prison." - embed.title = random.choice(POSITIVE_REPLIES) - - if "error_message" in response: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - log.warning( - f"Error encountered when trying to unsuperstarify {member.display_name}:\n" - f"{response}" - ) - - else: - await self.moderation.notify_pardon( - user=member, - title="You are no longer superstarified.", - content="You may now change your nickname on the server." - ) - - log.debug(f"{member.display_name} was successfully released from superstar-prison.") - await ctx.send(embed=embed) - - -def setup(bot): - bot.add_cog(Superstarify(bot)) - log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py new file mode 100644 index 000000000..7f5b0d487 --- /dev/null +++ b/bot/cogs/superstarify/__init__.py @@ -0,0 +1,281 @@ +import logging +import random +from datetime import datetime + +from discord import Colour, Embed, Member +from discord.errors import Forbidden +from discord.ext.commands import Bot, Context, command + +from bot.cogs.moderation import Moderation +from bot.cogs.modlog import ModLog +from bot.constants import ( + Icons, Keys, + NEGATIVE_REPLIES, POSITIVE_REPLIES, + Roles, URLs +) +from bot.converters import ExpirationDate +from bot.decorators import with_role +from bot.cogs.superstarify.stars import get_nick +from bot.utils.moderation import post_infraction + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" + + +class Superstarify: + """ + A set of commands to moderate terrible nicknames. + """ + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def moderation(self) -> Moderation: + return self.bot.get_cog("Moderation") + + @property + def modlog(self) -> ModLog: + return self.bot.get_cog("ModLog") + + async def on_member_update(self, before: Member, after: Member): + """ + This event will trigger when someone changes their name. + At this point we will look up the user in our database and check + whether they are allowed to change their names, or if they are in + superstar-prison. If they are not allowed, we will change it back. + """ + + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before.display_name} is trying to change their nickname to {after.display_name}. " + "Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(before.id) + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} is currently in superstar-prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) + + try: + await after.send( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so. " + "You will be allowed to change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user tried to change their nickname while in superstar-prison. " + "This led to the bot trying to DM the user to let them know they cannot do that, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + async def on_member_join(self, member: Member): + """ + This event will trigger when someone (re)joins the server. + At this point we will look up the user in our database and check + whether they are in superstar-prison. If so, we will change their name + back to the forced nickname. + """ + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstarify', + 'user__id': before.id + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], member.id) + await member.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(response['expires_at'][:-1]) + .strftime('%c') + ) + + try: + await member.send( + "You have left and rejoined the **Python Discord** server, effectively resetting " + f"your nickname from **{forced_nick}** to **{member.name}**, " + "but as you are currently in superstar-prison, you do not have permission to do so. " + "Therefore your nickname was automatically changed back. You will be allowed to " + "change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user left and rejoined the server while in superstar-prison. " + "This led to the bot trying to DM the user to let them know their name was restored, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified member potentially tried to escape the prison.\n" + f"Restored enforced nickname: `{forced_nick}`\n" + f"Superstardom ends: **{end_timestamp_human}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Superstar member rejoined server", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + @command(name='superstarify', aliases=('force_nick', 'star')) + @with_role(Roles.admin, Roles.owner, Roles.moderator) + async def superstarify( + self, ctx: Context, member: Member, + expiration: ExpirationDate, reason: str = None + ): + """ + This command will force a random superstar name (like Taylor Swift) to be the user's + nickname for a specified duration. An optional reason can be provided. + If no reason is given, the original name will be shown in a generated reason. + """ + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if active_superstarifies: + return await ctx.send( + ":x: According to my records, this user is already superstarified. " + f"See infraction **#{active_superstarifies[0]['id']}**." + ) + + + infraction = await post_infraction( + ctx, member, + type='superstar', reason=reason or ('old nick: ' + member.display_name), + expires_at=expiration + ) + forced_nick = get_nick(infraction['id'], member.id) + + embed = Embed() + embed.title = "Congratulations!" + embed.description = ( + f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until \n**{expiration}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified by **{ctx.author.name}**\n" + f"Old nickname: `{member.display_name}`\n" + f"New nickname: `{forced_nick}`\n" + f"Superstardom ends: **{expiration}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Member Achieved Superstardom", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + await self.moderation.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiration, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Change the nick and return the embed + log.trace("Changing the users nickname and sending the embed.") + await member.edit(nick=forced_nick) + await ctx.send(embed=embed) + + @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) + @with_role(Roles.admin, Roles.owner, Roles.moderator) + async def unsuperstarify(self, ctx: Context, member: Member): + """ + This command will remove the entry from our database, allowing the user + to once again change their nickname. + + :param ctx: Discord message context + :param member: The member to unsuperstarify + """ + + log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") + + embed = Embed() + embed.colour = Colour.blurple() + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if not active_superstarifies: + return await ctx.send( + ":x: There is no active superstarify infraction for this user." + ) + + + [infraction] = active_superstarifies + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction['id']), + json={'active': False} + ) + + embed = Embed() + embed.description = "User has been released from superstar-prison." + embed.title = random.choice(POSITIVE_REPLIES) + + await self.moderation.notify_pardon( + user=member, + title="You are no longer superstarified.", + content="You may now change your nickname on the server." + ) + log.trace(f"{member.display_name} was successfully released from superstar-prison.") + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py new file mode 100644 index 000000000..9b49d7175 --- /dev/null +++ b/bot/cogs/superstarify/stars.py @@ -0,0 +1,86 @@ +import random + + +STAR_NAMES = ( + "Adele", + "Aerosmith", + "Aretha Franklin", + "Ayumi Hamasaki", + "B'z", + "Barbra Streisand", + "Barry Manilow", + "Barry White", + "Beyonce", + "Billy Joel", + "Bob Dylan", + "Bob Marley", + "Bob Seger", + "Bon Jovi", + "Britney Spears", + "Bruce Springsteen", + "Bruno Mars", + "Bryan Adams", + "Celine Dion", + "Cher", + "Christina Aguilera", + "David Bowie", + "Donna Summer", + "Drake", + "Ed Sheeran", + "Elton John", + "Elvis Presley", + "Eminem", + "Enya", + "Flo Rida", + "Frank Sinatra", + "Garth Brooks", + "George Michael", + "George Strait", + "James Taylor", + "Janet Jackson", + "Jay-Z", + "Johnny Cash", + "Johnny Hallyday", + "Julio Iglesias", + "Justin Bieber", + "Justin Timberlake", + "Kanye West", + "Katy Perry", + "Kenny G", + "Kenny Rogers", + "Lady Gaga", + "Lil Wayne", + "Linda Ronstadt", + "Lionel Richie", + "Madonna", + "Mariah Carey", + "Meat Loaf", + "Michael Jackson", + "Neil Diamond", + "Nicki Minaj", + "Olivia Newton-John", + "Paul McCartney", + "Phil Collins", + "Pink", + "Prince", + "Reba McEntire", + "Rihanna", + "Robbie Williams", + "Rod Stewart", + "Santana", + "Shania Twain", + "Stevie Wonder", + "Taylor Swift", + "Tim McGraw", + "Tina Turner", + "Tom Petty", + "Tupac Shakur", + "Usher", + "Van Halen", + "Whitney Houston", +) + + +def get_nick(infraction_id, member_id): + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) -- cgit v1.2.3 From 9dc57626d31b11e4355c1596d80d144f57e5056b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Jan 2019 09:42:41 +0100 Subject: Move infractions cog to Django. --- bot/cogs/information.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 7a244cdbe..476820feb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -150,14 +150,14 @@ class Information: ) # Infractions - api_response = await self.bot.http_session.get( - url=URLs.site_infractions_user.format(user_id=user.id), - params={"hidden": hidden}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': hidden, + 'user__id': str(user.id) + } ) - infractions = await api_response.json() - infr_total = 0 infr_active = 0 -- cgit v1.2.3 From e202f50c8bbdc122cc41f6076b5222df79160bdc Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Jan 2019 10:50:56 +0100 Subject: Move deleted message log uploading to the Django API. --- bot/cogs/antispam.py | 2 +- bot/cogs/clean.py | 2 +- bot/cogs/modlog.py | 64 ++++++++++++++++------------------------------------ 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d5b72718c..c6d53c7ed 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -121,7 +121,7 @@ class AntiSpam: # For multiple messages, use the logs API if len(messages) > 1: - url = await self.mod_log.upload_log(messages) + url = await self.mod_log.upload_log(messages, msg.guild.me.id) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: mod_alert_message += "Message:\n" diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 8a9b01d07..7621c4ef7 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -165,7 +165,7 @@ class Clean: # Reverse the list to restore chronological order if messages: messages = list(reversed(messages)) - log_url = await self.mod_log.upload_log(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) else: # Can't build an embed, nothing to clean! embed = Embed( diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 905f114c1..911b5da03 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -1,6 +1,6 @@ import asyncio -import datetime import logging +from datetime import datetime from typing import List, Optional, Union from aiohttp import ClientResponseError @@ -39,13 +39,12 @@ class ModLog: def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._ignored = {event: [] for event in Event} self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: List[Message]) -> Optional[str]: + async def upload_log(self, messages: List[Message], actor_id: int) -> Optional[str]: """ Uploads the log data to the database via an API endpoint for uploading logs. @@ -55,48 +54,25 @@ class ModLog: Returns a URL that can be used to view the log. """ - log_data = [] - - for message in messages: - author = f"{message.author.name}#{message.author.discriminator}" - - # message.author may return either a User or a Member. Users don't have roles. - if type(message.author) is User: - role_id = Roles.developer - else: - role_id = message.author.top_role.id - - content = message.content - embeds = [embed.to_dict() for embed in message.embeds] - attachments = ["" for _ in message.attachments] - - log_data.append({ - "content": content, - "author": author, - "user_id": str(message.author.id), - "role_id": str(role_id), - "timestamp": message.created_at.strftime("%D %H:%M"), - "attachments": attachments, - "embeds": embeds, - }) - - response = await self.bot.http_session.post( - URLs.site_logs_api, - headers=self.headers, - json={"log_data": log_data} + response = await self.bot.api_client.post( + 'bot/deleted-messages', + json={ + 'actor': actor_id, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'id': message.id, + 'author': message.author.id, + 'channel_id': message.channel.id, + 'content': message.content, + 'embeds': [embed.to_dict() for embed in message.embeds] + } + for message in messages + ] + } ) - try: - data = await response.json() - log_id = data["log_id"] - except (KeyError, ClientResponseError): - log.debug( - "API returned an unexpected result:\n" - f"{response.text}" - ) - return - - return f"{URLs.site_logs_view}/{log_id}" + return f"{URLs.site_logs_view}/{response['id']}" def ignore(self, event: Event, *items: int): for item in items: @@ -114,7 +90,7 @@ class ModLog: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - embed.timestamp = datetime.datetime.utcnow() + embed.timestamp = datetime.utcnow() if thumbnail is not None: embed.set_thumbnail(url=thumbnail) -- cgit v1.2.3 From 4d35f8f7137edb97e1124fa9087bd86399398047 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Jan 2019 12:48:20 +0100 Subject: Remove moved `events` cog. --- bot/__main__.py | 1 - bot/cogs/events.py | 285 ----------------------------------------------------- 2 files changed, 286 deletions(-) delete mode 100644 bot/cogs/events.py diff --git a/bot/__main__.py b/bot/__main__.py index e280a2479..8687cc62c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -42,7 +42,6 @@ else: # Internal/debug bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") -bot.load_extension("bot.cogs.events") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.modlog") diff --git a/bot/cogs/events.py b/bot/cogs/events.py deleted file mode 100644 index 160791fb0..000000000 --- a/bot/cogs/events.py +++ /dev/null @@ -1,285 +0,0 @@ -import logging - -from aiohttp import ClientResponseError -from discord import Colour, Embed, Member, Object -from discord.ext.commands import ( - BadArgument, Bot, BotMissingPermissions, - CommandError, CommandInvokeError, CommandNotFound, - Context, NoPrivateMessage, UserInputError -) - -from bot.cogs.modlog import ModLog -from bot.constants import ( - Channels, Colours, DEBUG_MODE, - Guild, Icons, Keys, - Roles, URLs -) -from bot.utils import chunks - -log = logging.getLogger(__name__) - -RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) - - -class Events: - """No commands, just event handlers.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - return self.bot.get_cog("ModLog") - - async def send_updated_users(self, *users, replace_all=False): - users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) - - for chunk in chunks(users, 1000): - response = None - - try: - if replace_all: - response = await self.bot.http_session.post( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - else: - response = await self.bot.http_session.put( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - - await response.json() # We do this to ensure we got a proper response from the site - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - break # Stop right now, thank you very much - - result = {} - - if replace_all: - response = None - - try: - response = await self.bot.http_session.post( - url=URLs.site_user_complete_api, - headers={"X-API-Key": Keys.site_api} - ) - - result = await response.json() - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - - return result - - async def send_delete_users(self, *users): - try: - response = await self.bot.http_session.delete( - url=URLs.site_user_api, - json=list(users), - headers={"X-API-Key": Keys.site_api} - ) - - return await response.json() - except Exception: - log.exception(f"Failed to delete {len(users)} users") - return {} - - async def get_user(self, user_id): - response = await self.bot.http_session.get( - url=URLs.site_user_api, - params={"user_id": user_id}, - headers={"X-API-Key": Keys.site_api} - ) - - resp = await response.json() - return resp["data"] - - async def on_command_error(self, ctx: Context, e: CommandError): - command = ctx.command - parent = None - - if command is not None: - parent = command.parent - - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) - - if hasattr(command, "on_error"): - log.debug(f"Command {command} has a local error handler, ignoring.") - return - - if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - # Return to not raise the exception - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) - elif isinstance(e, BadArgument): - await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) - elif isinstance(e, UserInputError): - await ctx.invoke(*help_command) - elif isinstance(e, NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): - await ctx.send( - f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" - f"Here's what I'm missing: **{e.missing_perms}**" - ) - elif isinstance(e, CommandInvokeError): - if isinstance(e.original, ClientResponseError): - if e.original.code == 404: - await ctx.send("There does not seem to be anything matching your query.") - else: - await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") - - else: - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original - raise e - - async def on_ready(self): - users = [] - - for member in self.bot.get_guild(Guild.id).members: # type: Member - roles = [str(r.id) for r in member.roles] # type: List[int] - - users.append({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": roles, - "username": member.name, - "discriminator": member.discriminator - }) - - if users: - log.info(f"{len(users)} user roles to be updated") - - done = await self.send_updated_users(*users, replace_all=True) - - if any(done.values()): - embed = Embed( - title="Users updated" - ) - - for key, value in done.items(): - if value: - if key == "deleted_oauth": - key = "Deleted (OAuth)" - elif key == "deleted_jam_profiles": - key = "Deleted (Jammer Profiles)" - elif key == "deleted_responses": - key = "Deleted (Jam Form Responses)" - elif key == "jam_bans": - key = "Ex-Jammer Bans" - else: - key = key.title() - - embed.add_field( - name=key, value=str(value) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send( - embed=embed - ) - - async def on_member_update(self, before: Member, after: Member): - if ( - before.roles == after.roles - and before.name == after.name - and before.discriminator == after.discriminator - and before.avatar == after.avatar): - return - - before_role_names = [role.name for role in before.roles] # type: List[str] - after_role_names = [role.name for role in after.roles] # type: List[str] - role_ids = [str(r.id) for r in after.roles] # type: List[str] - - log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}") - - changes = await self.send_updated_users({ - "avatar": after.avatar_url_as(format="png"), - "user_id": str(after.id), - "roles": role_ids, - "username": after.name, - "discriminator": after.discriminator - }) - - log.debug(f"User {after.id} updated; changes: {changes}") - - async def on_member_join(self, member: Member): - role_ids = [str(r.id) for r in member.roles] # type: List[str] - new_roles = [] - - try: - user_objs = await self.get_user(str(member.id)) - except Exception as e: - log.exception("Failed to persist roles") - - await self.mod_log.send_log_message( - Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", - f"```py\n{e}\n```", - member.avatar_url_as(static_format="png") - ) - else: - if user_objs: - old_roles = user_objs[0].get("roles", []) - - for role in RESTORE_ROLES: - if role in old_roles: - new_roles.append(Object(int(role))) - - for role in new_roles: - if str(role) not in role_ids: - role_ids.append(str(role.id)) - - changes = await self.send_updated_users({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": role_ids, - "username": member.name, - "discriminator": member.discriminator - }) - - log.debug(f"User {member.id} joined; changes: {changes}") - - if new_roles: - await member.add_roles( - *new_roles, - reason="Roles restored" - ) - - await self.mod_log.send_log_message( - Icons.crown_blurple, Colour.blurple(), "Roles restored", - f"Restored {len(new_roles)} roles", - member.avatar_url_as(static_format="png") - ) - - async def on_member_remove(self, member: Member): - changes = await self.send_delete_users({ - "user_id": str(member.id) - }) - - log.debug(f"User {member.id} left; changes: {changes}") - - -def setup(bot): - bot.add_cog(Events(bot)) - log.info("Cog loaded: Events") -- cgit v1.2.3 From 747162db2f1244b987f43126d35f98268ff50ce6 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 20 Jan 2019 13:35:34 +0100 Subject: Move the `BigBrother` cog to the Django API. --- bot/cogs/bigbrother.py | 205 ++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 115 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 29b13f038..126a108fc 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -21,17 +21,13 @@ URL_RE = re.compile(r"(https?://[^\s]+)") class BigBrother: """User monitoring to assist with moderation.""" - HEADERS = {'X-API-Key': Keys.site_api} - def __init__(self, bot: Bot): self.bot = bot - self.watched_users = {} # { user_id: log_channel_id } + self.watched_users = set() # { user_id } self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } self.last_log = [None, None, 0] # [user_id, channel_id, message_count] self.consuming = False - self.bot.loop.create_task(self.get_watched_users()) - def update_cache(self, api_response: List[dict]): """ Updates the internal cache of watched users from the given `api_response`. @@ -42,47 +38,46 @@ class BigBrother: """ for entry in api_response: - user_id = int(entry['user_id']) - channel_id = int(entry['channel_id']) - channel = self.bot.get_channel(channel_id) - - if channel is not None: - self.watched_users[user_id] = channel - else: - log.error( - f"Site specified to relay messages by `{user_id}` in `{channel_id}`, " - "but the given channel could not be found. Ignoring." - ) - - async def get_watched_users(self): + user_id = entry['user'] + self.watched_users.add(user_id) + + async def on_ready(self): """Retrieves watched users from the API.""" - await self.bot.wait_until_ready() - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - data = await response.json() + self.channel = self.bot.get_channel(Channels.big_brother_logs) + if self.channel is None: + log.error("Cannot find Big Brother channel. Cannot watch users.") + else: + data = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'watch' + } + ) self.update_cache(data) async def on_member_ban(self, guild: Guild, user: Union[User, Member]): if guild.id == GuildConfig.id and user.id in self.watched_users: - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - channel = self.watched_users[user.id] + [active_watch] = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'watch', + 'user__id': str(user.id) + } + ) + await self.bot.api_client.put( + 'bot/infractions/' + str(active_watch['id']), + json={'active': False} + ) + self.watched_users.remove(user.id) + del self.channel_queues[user.id] + await channel.send( + f"{Emojis.bb_message}:hammer: {user} got banned, so " + f"`BigBrother` will no longer relay their messages." + ) - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - del self.watched_users[user.id] - del self.channel_queues[user.id] - if response.status == 204: - await channel.send( - f"{Emojis.bb_message}:hammer: {user} got banned, so " - f"`BigBrother` will no longer relay their messages to {channel}" - ) - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await channel.send( - f"{Emojis.bb_message}:x: {user} got banned, but trying to remove them from" - f"BigBrother's user dictionary on the API returned an error: {reason}" - ) async def on_message(self, msg: Message): """Queues up messages sent by watched users.""" @@ -106,15 +101,14 @@ class BigBrother: channel_queues = self.channel_queues.copy() self.channel_queues.clear() for user_id, queues in channel_queues.items(): - for _, queue in queues.items(): - channel = self.watched_users[user_id] + for queue in queues.values(): while queue: msg = queue.popleft() log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") self.last_log[2] += 1 # Increment message count. - await self.send_header(msg, channel) - await self.log_message(msg, channel) + await self.send_header(msg) + await self.log_message(msg) if self.channel_queues: log.trace("Queue not empty; continue consumption.") @@ -123,7 +117,7 @@ class BigBrother: log.trace("Done consuming messages.") self.consuming = False - async def send_header(self, message: Message, destination: TextChannel): + async def send_header(self, message: Message): """ Sends a log message header to the given channel. @@ -131,7 +125,6 @@ class BigBrother: limit for a single header has been exceeded. :param message: the first message in the queue - :param destination: the channel in which to send the header """ last_user, last_channel, msg_count = self.last_log @@ -143,10 +136,9 @@ class BigBrother: embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url) - await destination.send(embed=embed) + await self.channel.send(embed=embed) - @staticmethod - async def log_message(message: Message, destination: TextChannel): + async def log_message(self, message: Message): """ Logs a watched user's message in the given channel. @@ -165,7 +157,7 @@ class BigBrother: if url not in media_urls: content = content.replace(url, f"`{url}`") - await destination.send(content) + await self.channel.send(content) await messages.send_attachments(message, destination) @@ -186,10 +178,7 @@ class BigBrother: """ if from_cache: - lines = tuple( - f"• <@{user_id}> in <#{self.watched_users[user_id].id}>" - for user_id in self.watched_users - ) + lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) await LinePaginator.paginate( lines or ("There's nothing here yet.",), ctx, @@ -198,21 +187,25 @@ class BigBrother: ) else: - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - if response.status == 200: - data = await response.json() - self.update_cache(data) - lines = tuple(f"• <@{entry['user_id']}> in <#{entry['channel_id']}>" for entry in data) - - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title="Watched users", color=Color.blue()), - empty=False - ) - - else: - await ctx.send(f":x: got non-200 response from the API") + active_watches = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'watch' + } + ) + self.update_cache(active_watches) + lines = tuple( + f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" + for entry in active_watches + ) + + await LinePaginator.paginate( + lines or ("There's nothing here yet.",), + ctx, + Embed(title="Watched users", color=Color.blue()), + empty=False + ) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -224,59 +217,41 @@ class BigBrother: note (aka: shadow warning) """ - channel_id = Channels.big_brother_logs - - post_data = { - 'user_id': str(user.id), - 'channel_id': str(channel_id) - } - - async with self.bot.http_session.post( - URLs.site_bigbrother_api, - headers=self.HEADERS, - json=post_data - ) as response: - if response.status == 204: - await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>") - - channel = self.bot.get_channel(channel_id) - if channel is None: - log.error( - f"could not update internal cache, failed to find a channel with ID {channel_id}" - ) - else: - self.watched_users[user.id] = channel - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") - - # Add a note (shadow warning) with the reason for watching - reason = "bb watch: " + reason # Prepend for situational awareness - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + if user.id in self.watched_users: + return await ctx.send(":x: That user is already watched.") + + created_infraction = await post_infraction( + ctx, user, type='watch', reason=reason, hidden=True + ) + self.watched_users.add(user.id) + await ctx.send(f":ok_hand: will now relay messages sent by {user}") + @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def unwatch_command(self, ctx: Context, user: User): """Stop relaying messages by the given `user`.""" - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - if response.status == 204: - await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") - - if user.id in self.watched_users: - del self.watched_users[user.id] - if user.id in self.channel_queues: - del self.channel_queues[user.id] - else: - log.warning(f"user {user.id} was unwatched but was not found in the cache") - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") + active_watches = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'watch', + 'user__id': str(user.id) + } + ) + if active_watches: + [infraction] = active_watches + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction['id']), + json={'active': False} + ) + await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") + self.watched_users.remove(user.id) + if user.id in self.channel_queues: + del self.channel_queues[user.id] + else: + await ctx.send(":x: that user is currently not being watched") def setup(bot: Bot): -- cgit v1.2.3 From 783d70cada443dc832275ca59bfef0ea487d91f3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 22 Jan 2019 23:30:45 +0100 Subject: Migrate reminders cog to Django. --- bot/cogs/reminders.py | 228 ++++++++++++++++---------------------------------- 1 file changed, 73 insertions(+), 155 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index f6ed111dc..78f1cd73b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,8 +1,9 @@ import asyncio -import datetime import logging import random import textwrap +from datetime import datetime +from operator import itemgetter from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta @@ -13,6 +14,7 @@ from bot.constants import ( Channels, Icons, Keys, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles, URLs ) +from bot.converters import ExpirationDate from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta, parse_rfc1123, wait_until @@ -28,24 +30,20 @@ class Reminders(Scheduler): def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-Key": Keys.site_api} super().__init__() async def on_ready(self): # Get all the current reminders for re-scheduling - response = await self.bot.http_session.get( - url=URLs.site_reminders_api, - headers=self.headers + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} ) - response_data = await response.json() - - # Find the current time, timezone-aware. - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() loop = asyncio.get_event_loop() - for reminder in response_data["reminders"]: - remind_at = parse_rfc1123(reminder["remind_at"]) + for reminder in response: + remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) # If the reminder is already overdue ... if remind_at < now: @@ -56,32 +54,16 @@ class Reminders(Scheduler): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, response: dict, on_success: str): + async def _send_confirmation(ctx: Context, on_success: str): """ - Send an embed confirming whether or not a change was made successfully. - - :return: A Boolean value indicating whether it failed (True) or passed (False) + Send an embed confirming the change was made successfully. """ embed = Embed() - - if not response.get("success"): - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message", "An unexpected error occurred.") - - log.warn(f"Unable to create/edit/delete a reminder. Response: {response}") - failed = True - - else: - embed.colour = Colour.green() - embed.title = random.choice(POSITIVE_REPLIES) - embed.description = on_success - - failed = False - + embed.colour = Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success await ctx.send(embed=embed) - return failed async def _scheduled_task(self, reminder: dict): """ @@ -92,7 +74,7 @@ class Reminders(Scheduler): """ reminder_id = reminder["id"] - reminder_datetime = parse_rfc1123(reminder["remind_at"]) + reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1]) # Send the reminder message once the desired duration has passed await wait_until(reminder_datetime) @@ -111,18 +93,7 @@ class Reminders(Scheduler): :param reminder_id: The ID of the reminder. """ - # The API requires a list, so let's give it one :) - json_data = { - "reminders": [ - reminder_id - ] - } - - await self.bot.http_session.delete( - url=URLs.site_reminders_api, - headers=self.headers, - json=json_data - ) + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) # Now we can remove it from the schedule list self.cancel_task(reminder_id) @@ -147,8 +118,8 @@ class Reminders(Scheduler): :param late: How late the reminder is (if at all) """ - channel = self.bot.get_channel(int(reminder["channel_id"])) - user = self.bot.get_user(int(reminder["user_id"])) + channel = self.bot.get_channel(reminder["channel_id"]) + user = self.bot.get_user(reminder["author"]) embed = Embed() embed.colour = Colour.blurple() @@ -173,15 +144,15 @@ class Reminders(Scheduler): await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) - async def remind_group(self, ctx: Context, duration: str, *, content: str): + async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str): """ Commands for managing your reminders. """ - await ctx.invoke(self.new_reminder, duration=duration, content=content) + await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, duration: str, *, content: str): + async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str): """ Set yourself a simple reminder. """ @@ -200,13 +171,13 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Get their current active reminders - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api.format(user_id=ctx.author.id), - headers=self.headers + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'user__id': str(ctx.author.id) + } ) - active_reminders = await response.json() - # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: @@ -217,45 +188,23 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Now we can attempt to actually set the reminder. - try: - response = await self.bot.http_session.post( - url=URLs.site_reminders_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "duration": duration, - "content": content, - "channel_id": str(ctx.channel.id) - } - ) - - response_data = await response.json() - - # AFAIK only happens if the user enters, like, a quintillion weeks - except ClientResponseError: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = ( - "An error occurred while adding your reminder to the database. " - "Did you enter a reasonable duration?" - ) - - log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.") - - return await ctx.send(embed=embed) - - # Confirm to the user whether or not it worked. - failed = await self._send_confirmation( - ctx, response_data, - on_success="Your reminder has been created successfully!" + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'content': content, + 'expiration': expiration.isoformat() + } ) - # If it worked, schedule the reminder. - if not failed: - loop = asyncio.get_event_loop() - reminder = response_data["reminder"] + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, on_success="Your reminder has been created successfully!" + ) - self.schedule_task(loop, reminder["id"], reminder) + loop = asyncio.get_event_loop() + self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context): @@ -264,31 +213,31 @@ class Reminders(Scheduler): """ # Get all the user's reminders from the database. - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api, - params={"user_id": str(ctx.author.id)}, - headers=self.headers + data = await self.bot.api_client.get( + 'bot/reminders', + params={'user__id': str(ctx.author.id)} ) - data = await response.json() - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() # Make a list of tuples so it can be sorted by time. - reminders = [ - (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"] - ] - - reminders.sort(key=lambda rem: rem[1]) + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id']) + for rem in data + ), + key=itemgetter(1) + ) lines = [] - for index, (content, remind_at, friendly_id) in enumerate(reminders): + for content, remind_at, id_ in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = parse_rfc1123(remind_at) + remind_datetime = datetime.fromisoformat(remind_at[:-1]) time = humanize_delta(relativedelta(remind_datetime, now)) text = textwrap.dedent(f""" - **Reminder #{index}:** *expires in {time}* (ID: {friendly_id}) + **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {content} """).strip() @@ -322,84 +271,53 @@ class Reminders(Scheduler): await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str): + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate): """ - Edit one of your reminders' duration. + Edit one of your reminders' expiration. """ # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "duration": duration - } + reminder = await self.bot.http_session.patch( + 'bot/reminders/' + str(id_), + json={'expiration': expiration.isoformat()} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str): + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str): """ Edit one of your reminders' content. """ # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "content": content - } + reminder = await self.bot.http_session.patch( + 'bot/reminders/' + str(id_), + json={'content': content} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove",)) - async def delete_reminder(self, ctx: Context, friendly_id: str): + async def delete_reminder(self, ctx: Context, id_: int): """ Delete one of your active reminders. """ - # Send the request to delete the reminder from the database - response = await self.bot.http_session.delete( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id - } - ) - - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been deleted successfully!" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, on_success="That reminder has been deleted successfully!" ) - if not failed: - self.cancel_reminder(response_data["reminder_id"]) - def setup(bot: Bot): bot.add_cog(Reminders(bot)) -- cgit v1.2.3 From 0f9b0406604692e28ba3c8908795e74477784049 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 27 Jan 2019 12:31:02 +0100 Subject: Initial stab at implementing the nominations API. --- bot/cogs/bigbrother.py | 3 +- bot/cogs/nominations.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 bot/cogs/nominations.py diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index 126a108fc..a6962efea 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -146,7 +146,6 @@ class BigBrother: embeds from being automatically generated. :param message: the message to log - :param destination: the channel in which to log the message """ content = message.clean_content @@ -159,7 +158,7 @@ class BigBrother: await self.channel.send(content) - await messages.send_attachments(message, destination) + await messages.send_attachments(message, self.channel) @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) diff --git a/bot/cogs/nominations.py b/bot/cogs/nominations.py new file mode 100644 index 000000000..89fd0fba5 --- /dev/null +++ b/bot/cogs/nominations.py @@ -0,0 +1,110 @@ +import logging + +from discord import Color, Embed, User +from discord.ext.commands import Context, group + +from bot.cogs.bigbrother import BigBrother, Roles +from bot.constants import Channels +from bot.decorators import with_role +from bot.pagination import LinePaginator + + +log = logging.getLogger(__name__) + + +class Nominations(BigBrother): + """Monitor potential helpers, NSA-style.""" + + async def on_ready(self): + """Retrieve nominees from the API.""" + + self.channel = self.bot.get_channel(Channels.talent_pool) + if self.channel is None: + log.error("Cannot find talent pool channel. Cannot watch nominees.") + else: + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={'active': 'true'} + ) + self.update_cache(nominations) + + async def on_member_ban(self, *_): + pass + + @group(name='nominations', aliases=('n',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def bigbrother_group(self, ctx: Context): + """Nominate helpers, NSA-style.""" + + await ctx.invoke(self.bot.get_command("help"), "nominations") + + @bigbrother_group.command(name='nominated', aliases=('nominees', 'all')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, from_cache: bool = True): + if from_cache: + lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) + + else: + active_nominations = await self.bot.api_client.get( + 'bot/nominations', + params={'active': 'true'} + ) + self.update_cache(active_nominations) + lines = tuple( + f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" + for entry in active_nominations + ) + + await LinePaginator.paginate( + lines or ("There's nothing here yet.",), + ctx, + Embed( + title="Nominated users" + " (cached)" * from_cache, + color=Color.blue() + ), + empty=False + ) + + @bigbrother_group.command(name='nominate', aliases=('n',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: User, *, reason: str): + """Talent pool the given `user`.""" + + await self.bot.api_client.patch( + 'bot/nominations', + json={ + 'active': True, + 'author': ctx.author.id, + 'reason': reason, + 'user': user.id + } + ) + self.watched_users.add(user.id) + await ctx.send(":ok_hand: user added to talent pool") + + @bigbrother_group.command(name='unnominate', aliases=('un',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: User): + """Stop talent pooling the given `user`.""" + + nomination = await self.bot.api_client.get( + 'bot/nominations/' + str(user.id) + ) + + if not nomination['active']: + await ctx.send(":x: the nomination is already inactive") + + else: + await self.bot.api_client.patch( + 'bot/nominations/' + str(user.id), + json={'active': False} + ) + self.watched_users.remove(user.id) + if user.id in self.channel_queues: + del self.channel_queues[user.id] + await ctx.send(f":ok_hand: {user} is no longer part of the talent pool") + + +def setup(bot): + bot.add_cog(Nominations(bot)) + log.info("Cog loaded: Nominations") -- cgit v1.2.3 From 913e2da417b5631944c6dc557961813304ac96d0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 6 Feb 2019 22:11:36 +0100 Subject: Reimplement nominations on Django. --- bot/cogs/nominations.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/bot/cogs/nominations.py b/bot/cogs/nominations.py index 89fd0fba5..93ee0d885 100644 --- a/bot/cogs/nominations.py +++ b/bot/cogs/nominations.py @@ -70,17 +70,27 @@ class Nominations(BigBrother): async def watch_command(self, ctx: Context, user: User, *, reason: str): """Talent pool the given `user`.""" - await self.bot.api_client.patch( - 'bot/nominations', - json={ - 'active': True, - 'author': ctx.author.id, - 'reason': reason, - 'user': user.id - } + active_nominations = await self.bot.api_client.get( + 'bot/nominations/' + str(user.id), ) - self.watched_users.add(user.id) - await ctx.send(":ok_hand: user added to talent pool") + if active_nominations: + active_nominations = await self.bot.api_client.put( + 'bot/nominations/' + str(user.id), + json={'active': True} + ) + await ctx.send(":ok_hand: user's watch was updated") + + else: + active_nominations = await self.bot.api_client.post( + 'bot/nominations/' + str(user.id), + json={ + 'active': True, + 'author': ctx.author.id, + 'reason': reason, + } + ) + self.watched_users.add(user.id) + await ctx.send(":ok_hand: user added to talent pool") @bigbrother_group.command(name='unnominate', aliases=('un',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -95,7 +105,7 @@ class Nominations(BigBrother): await ctx.send(":x: the nomination is already inactive") else: - await self.bot.api_client.patch( + await self.bot.api_client.put( 'bot/nominations/' + str(user.id), json={'active': False} ) -- cgit v1.2.3 From 6f96339080f6f82cb28515e9f1e93e56f59e207a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 6 Feb 2019 22:11:47 +0100 Subject: Use proper snowflake in `on_member_update`. --- bot/cogs/superstarify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 7f5b0d487..e14336b52 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -108,7 +108,7 @@ class Superstarify: params={ 'active': 'true', 'type': 'superstarify', - 'user__id': before.id + 'user__id': member.id } ) -- cgit v1.2.3 From 2469592ca4837c1383b13056525ac63cd32a50ec Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 18 Feb 2019 21:01:27 +0100 Subject: Update defcon cog to use Django API. --- bot/cogs/defcon.py | 59 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c432d377c..8fa80020a 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -41,13 +41,8 @@ class Defcon: async def on_ready(self): try: - response = await self.bot.http_session.get( - URLs.site_settings_api, - headers=self.headers, - params={"keys": "defcon_enabled,defcon_days"} - ) - - data = await response.json() + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") @@ -56,9 +51,9 @@ class Defcon: ) else: - if data["defcon_enabled"]: + if data["enabled"]: self.enabled = True - self.days = timedelta(days=data["defcon_days"]) + self.days = timedelta(days=data["days"]) log.warning(f"DEFCON enabled: {self.days.days} days") else: @@ -117,13 +112,18 @@ class Defcon: self.enabled = True try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": True} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + 'enabled': True, + # TODO: retrieve old days count + 'days': 0 + } + } ) - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( @@ -141,6 +141,7 @@ class Defcon: "restarted.\n\n" f"```py\n{e}\n```" ) + else: await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") @@ -160,13 +161,16 @@ class Defcon: self.enabled = False try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": False} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': 0, + 'enabled': False + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( @@ -216,13 +220,16 @@ class Defcon: self.days = timedelta(days=days) try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_days": days} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': days, + 'enabled': True + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( -- cgit v1.2.3 From 9c6e92626dbd7907ba9acb76b32f24b4131c3493 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 18 Feb 2019 21:14:34 +0100 Subject: Use proper API client for reminders. --- bot/cogs/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 78f1cd73b..b86fecd5c 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -277,7 +277,7 @@ class Reminders(Scheduler): """ # Send the request to update the reminder in the database - reminder = await self.bot.http_session.patch( + reminder = await self.bot.api_client.patch( 'bot/reminders/' + str(id_), json={'expiration': expiration.isoformat()} ) @@ -296,7 +296,7 @@ class Reminders(Scheduler): """ # Send the request to update the reminder in the database - reminder = await self.bot.http_session.patch( + reminder = await self.bot.api_client.patch( 'bot/reminders/' + str(id_), json={'content': content} ) -- cgit v1.2.3 From 13009e91c584cb67302398243389cb58ba672817 Mon Sep 17 00:00:00 2001 From: "azure-pipelines[bot]" Date: Sat, 9 Mar 2019 07:05:18 +0000 Subject: Update pool build to Ubuntu 18.04 --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a6a633918..3453af200 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ jobs: displayName: 'Lint & Test' pool: - vmImage: 'Ubuntu 16.04' + vmImage: 'Ubuntu 18.04' variables: PIPENV_CACHE_DIR: ".cache/pipenv" -- cgit v1.2.3 From c56bcffb830bc765bf532feef04fa31ad76b65d2 Mon Sep 17 00:00:00 2001 From: "azure-pipelines[bot]" Date: Sat, 9 Mar 2019 07:16:57 +0000 Subject: Revert to 16.04 build pools. --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3453af200..7e33459b7 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ jobs: displayName: 'Lint & Test' pool: - vmImage: 'Ubuntu 18.04' + vmImage: ubuntu-16.04 variables: PIPENV_CACHE_DIR: ".cache/pipenv" -- cgit v1.2.3 From 1ff9aa88628d7c25484da1b85ef4d1eeb0049b63 Mon Sep 17 00:00:00 2001 From: Scragly <29337040+scragly@users.noreply.github.com> Date: Sat, 9 Mar 2019 17:52:23 +1000 Subject: Fix the linting issues with Bot so builds will work. --- bot/cogs/bigbrother.py | 14 +++++++------- bot/cogs/defcon.py | 2 +- bot/cogs/doc.py | 2 +- bot/cogs/information.py | 2 +- bot/cogs/modlog.py | 5 +---- bot/cogs/reminders.py | 6 ++---- bot/cogs/superstarify/__init__.py | 16 ++++------------ bot/cogs/tags.py | 7 ++----- bot/utils/moderation.py | 2 +- tests/cogs/sync/test_roles.py | 2 +- tests/cogs/sync/test_users.py | 1 + tox.ini | 2 +- 12 files changed, 23 insertions(+), 38 deletions(-) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py index a6962efea..df7a0b576 100644 --- a/bot/cogs/bigbrother.py +++ b/bot/cogs/bigbrother.py @@ -4,10 +4,12 @@ import re from collections import defaultdict, deque from typing import List, Union -from discord import Color, Embed, Guild, Member, Message, TextChannel, User +from discord import Color, Embed, Guild, Member, Message, User from discord.ext.commands import Bot, Context, group -from bot.constants import BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs +from bot.constants import ( + BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Roles +) from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils import messages @@ -73,12 +75,11 @@ class BigBrother: ) self.watched_users.remove(user.id) del self.channel_queues[user.id] - await channel.send( + await self.channel.send( f"{Emojis.bb_message}:hammer: {user} got banned, so " f"`BigBrother` will no longer relay their messages." ) - async def on_message(self, msg: Message): """Queues up messages sent by watched users.""" @@ -100,7 +101,7 @@ class BigBrother: log.trace("Begin consuming messages.") channel_queues = self.channel_queues.copy() self.channel_queues.clear() - for user_id, queues in channel_queues.items(): + for _, queues in channel_queues.items(): for queue in queues.values(): while queue: msg = queue.popleft() @@ -219,13 +220,12 @@ class BigBrother: if user.id in self.watched_users: return await ctx.send(":x: That user is already watched.") - created_infraction = await post_infraction( + await post_infraction( ctx, user, type='watch', reason=reason, hidden=True ) self.watched_users.add(user.id) await ctx.send(f":ok_hand: will now relay messages sent by {user}") - @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def unwatch_command(self, ctx: Context, user: User): diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 8fa80020a..29979de83 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -5,7 +5,7 @@ from discord import Colour, Embed, Member from discord.ext.commands import Bot, Context, group from bot.cogs.modlog import ModLog -from bot.constants import Channels, Emojis, Icons, Keys, Roles, URLs +from bot.constants import Channels, Emojis, Icons, Keys, Roles from bot.decorators import with_role log = logging.getLogger(__name__) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 860ec7f62..d427acc3a 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -13,7 +13,7 @@ from markdownify import MarkdownConverter from requests import ConnectionError from sphinx.ext import intersphinx -from bot.constants import Keys, Roles +from bot.constants import Roles from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 476820feb..92b2444a3 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,7 +4,7 @@ import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel from discord.ext.commands import Bot, Context, command -from bot.constants import Emojis, Keys, Roles, URLs +from bot.constants import Emojis, Keys, Roles from bot.decorators import with_role from bot.utils.time import time_since diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 911b5da03..66e80e778 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from typing import List, Optional, Union -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import ( @@ -16,9 +15,7 @@ from discord.abc import GuildChannel from discord.ext.commands import Bot from bot.constants import ( - Channels, Colours, Emojis, - Event, Guild as GuildConstant, Icons, - Keys, Roles, URLs + Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs ) from bot.utils.time import humanize_delta diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b86fecd5c..fa1be307c 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -5,19 +5,17 @@ import textwrap from datetime import datetime from operator import itemgetter -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta from discord import Colour, Embed from discord.ext.commands import Bot, Context, group from bot.constants import ( - Channels, Icons, Keys, NEGATIVE_REPLIES, - POSITIVE_REPLIES, Roles, URLs + Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles ) from bot.converters import ExpirationDate from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_rfc1123, wait_until +from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index e14336b52..efa02cb43 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -8,14 +8,10 @@ from discord.ext.commands import Bot, Context, command from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog -from bot.constants import ( - Icons, Keys, - NEGATIVE_REPLIES, POSITIVE_REPLIES, - Roles, URLs -) +from bot.cogs.superstarify.stars import get_nick +from bot.constants import Icons, POSITIVE_REPLIES, Roles from bot.converters import ExpirationDate from bot.decorators import with_role -from bot.cogs.superstarify.stars import get_nick from bot.utils.moderation import post_infraction log = logging.getLogger(__name__) @@ -117,8 +113,7 @@ class Superstarify: forced_nick = get_nick(infraction['id'], member.id) await member.edit(nick=forced_nick) end_timestamp_human = ( - datetime.fromisoformat(response['expires_at'][:-1]) - .strftime('%c') + datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c') ) try: @@ -157,8 +152,7 @@ class Superstarify: @command(name='superstarify', aliases=('force_nick', 'star')) @with_role(Roles.admin, Roles.owner, Roles.moderator) async def superstarify( - self, ctx: Context, member: Member, - expiration: ExpirationDate, reason: str = None + self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None ): """ This command will force a random superstar name (like Taylor Swift) to be the user's @@ -180,7 +174,6 @@ class Superstarify: f"See infraction **#{active_superstarifies[0]['id']}**." ) - infraction = await post_infraction( ctx, member, type='superstar', reason=reason or ('old nick: ' + member.display_name), @@ -256,7 +249,6 @@ class Superstarify: ":x: There is no active superstarify infraction for this user." ) - [infraction] = active_superstarifies await self.bot.api_client.patch( 'bot/infractions/' + str(infraction['id']), diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5a0198db8..bb4d6ba71 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,13 +2,10 @@ import logging import time from discord import Colour, Embed -from discord.ext.commands import ( - BadArgument, Bot, - Context, group -) +from discord.ext.commands import Bot, Context, group from bot.constants import Channels, Cooldowns, Keys, Roles -from bot.converters import TagContentConverter, TagNameConverter, ValidURL +from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 2611ee993..fcdf3c4d5 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -6,7 +6,7 @@ from aiohttp import ClientError from discord import Member, Object, User from discord.ext.commands import Context -from bot.constants import Keys, URLs +from bot.constants import Keys log = logging.getLogger(__name__) diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py index 7def815cc..18682f39f 100644 --- a/tests/cogs/sync/test_roles.py +++ b/tests/cogs/sync/test_roles.py @@ -1,4 +1,4 @@ -from bot.cogs.sync.syncers import get_roles_for_sync, Role +from bot.cogs.sync.syncers import Role, get_roles_for_sync def test_get_roles_for_sync_empty_return_for_equal_roles(): diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py index ecf1d3926..a863ae35b 100644 --- a/tests/cogs/sync/test_users.py +++ b/tests/cogs/sync/test_users.py @@ -1,5 +1,6 @@ from bot.cogs.sync.syncers import User, get_users_for_sync + def fake_user(**kwargs): kwargs.setdefault('id', 43) kwargs.setdefault('name', 'bob the test man') diff --git a/tox.ini b/tox.ini index c6fa513f4..c84827570 100644 --- a/tox.ini +++ b/tox.ini @@ -2,5 +2,5 @@ max-line-length=120 application_import_names=bot exclude=.cache,.venv -ignore=B311,W503,E226,S311 +ignore=B311,W503,E226,S311,T000 import-order-style=pycharm -- cgit v1.2.3 From f0c615f9bdc7c679337441f1630e28804e79671c Mon Sep 17 00:00:00 2001 From: Scragly <29337040+scragly@users.noreply.github.com> Date: Sat, 9 Mar 2019 21:35:36 +1000 Subject: Remove pytest stage for pipeline. --- azure-pipelines.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 7e33459b7..be27a5ba8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -7,7 +7,7 @@ variables: jobs: - job: test - displayName: 'Lint & Test' + displayName: 'Lint' pool: vmImage: ubuntu-16.04 @@ -36,9 +36,6 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: python -m pytest tests - displayName: Run tests - - job: build displayName: 'Build Containers' dependsOn: 'test' -- cgit v1.2.3 From 2f486ac6c7afbf88b12a27cd586be6ab34ef54ac Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 10 Mar 2019 13:09:02 +0100 Subject: Run tests with bogus token. --- azure-pipelines.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index be27a5ba8..81e3701e0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -7,7 +7,7 @@ variables: jobs: - job: test - displayName: 'Lint' + displayName: 'Lint & Test' pool: vmImage: ubuntu-16.04 @@ -36,6 +36,9 @@ jobs: - script: python -m flake8 displayName: 'Run linter' + - script: BOT_TOKEN=foobar python -m pytest tests + displayName: Run tests + - job: build displayName: 'Build Containers' dependsOn: 'test' -- cgit v1.2.3 From fb50926916f9734774b74d228926e4b54253eed7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 27 Apr 2019 15:26:18 +0200 Subject: Removing dulwich and devalerts for bulk deletion --- Pipfile | 1 - Pipfile.lock | 660 ++++++++++++++++++++++++++++----------------------- bot/cogs/antispam.py | 3 +- bot/cogs/bot.py | 6 - bot/cogs/modlog.py | 31 --- bot/constants.py | 1 - config-default.yml | 1 - 7 files changed, 361 insertions(+), 342 deletions(-) diff --git a/Pipfile b/Pipfile index 703057af5..0524f10bf 100644 --- a/Pipfile +++ b/Pipfile @@ -5,7 +5,6 @@ name = "pypi" [packages] discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true} -dulwich = "*" aiodns = "*" logmatic-python = "*" aiohttp = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 6261537d1..a3d489e31 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "54aabf14cead3c3cee38ede8393ac0637ef0a2acd083cc4f5d2b18d498aa874f" + "sha256": "927aabf1a7c4b9e097c3521012e20e030c4f493298fbc489713b30eaff48e732" }, "pipfile-spec": 6, "requires": { @@ -18,19 +18,19 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c3eb639f7fc5c96355e7a227380989c9e0f342bb6612e6671ea76d188813ba45", - "sha256:ea26efd262d7c4cd4ac00fb968ede89e82c00ad331b47415e3c2353a4b91cbe0" + "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a", + "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b" ], "index": "pypi", - "version": "==4.9.1" + "version": "==5.5.2" }, "aiodns": { "hashes": [ - "sha256:99d0652f2c02f73bfa646bf44af82705260a523014576647d7959e664830b26b", - "sha256:d8677adc679ce8d0ef706c14d9c3d2f27a0e0cc11d59730cdbaf218ad52dd9ea" + "sha256:815fdef4607474295d68da46978a54481dd1e7be153c7d60f9e72773cd38d77d", + "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de" ], "index": "pypi", - "version": "==1.1.1" + "version": "==2.0.0" }, "aiohttp": { "hashes": [ @@ -60,6 +60,13 @@ "index": "pypi", "version": "==3.4.4" }, + "aiormq": { + "hashes": [ + "sha256:2e18576a90dfdaa91f705bd226506d9589353350f09b7121179c0bf5350a79a8", + "sha256:be3e74b6f4a490ea1f3d393c186e98e8214cdde26f7073812b23fc002fff7383" + ], + "version": "==2.5.1" + }, "alabaster": { "hashes": [ "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", @@ -76,10 +83,10 @@ }, "attrs": { "hashes": [ - "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", - "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" ], - "version": "==18.2.0" + "version": "==19.1.0" }, "babel": { "hashes": [ @@ -98,47 +105,43 @@ }, "certifi": { "hashes": [ - "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", - "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", + "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" ], - "version": "==2018.11.29" + "version": "==2019.3.9" }, "cffi": { "hashes": [ - "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", - "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", - "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", - "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", - "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", - "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", - "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", - "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", - "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", - "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", - "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", - "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", - "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", - "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", - "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", - "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", - "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", - "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", - "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", - "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", - "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", - "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", - "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", - "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", - "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", - "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", - "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", - "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", - "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", - "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", - "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", - "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" - ], - "version": "==1.11.5" + "sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774", + "sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d", + "sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90", + "sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b", + "sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63", + "sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45", + "sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25", + "sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3", + "sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b", + "sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647", + "sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016", + "sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4", + "sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb", + "sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753", + "sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7", + "sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9", + "sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f", + "sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8", + "sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f", + "sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc", + "sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42", + "sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3", + "sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909", + "sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45", + "sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d", + "sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512", + "sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff", + "sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201" + ], + "version": "==1.12.3" }, "chardet": { "hashes": [ @@ -147,22 +150,29 @@ ], "version": "==3.0.4" }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "markers": "sys_platform == 'win32'", + "version": "==0.4.1" + }, "dateparser": { "hashes": [ - "sha256:940828183c937bcec530753211b70f673c0a9aab831e43273489b310538dff86", - "sha256:b452ef8b36cd78ae86a50721794bc674aa3994e19b570f7ba92810f4e0a2ae03" + "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e", + "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09" ], "index": "pypi", - "version": "==0.7.0" + "version": "==0.7.1" }, "deepdiff": { "hashes": [ - "sha256:152b29dd9cd97cc78403121fb394925ec47377d4a410751e56547c3930ba2b39", - "sha256:b4150052e610b231885c4c0be3eea86e4c029df91550ec51b9fc14dd209a5055", - "sha256:ecad8e16a96ffd27e8f40c9801a6ab16ec6a7e7e6e6859a7710ba4695f22702c" + "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56", + "sha256:856966b80109df002a1ee406ba21cd66e64746167b2ea8f5353d692762326ac9" ], "index": "pypi", - "version": "==3.3.0" + "version": "==4.0.6" }, "discord-py": { "editable": true, @@ -180,13 +190,6 @@ ], "version": "==0.14" }, - "dulwich": { - "hashes": [ - "sha256:5e1e39555f594939a8aff1ca08b3bdf6c7efd4b941c2850760983a0197240974" - ], - "index": "pypi", - "version": "==0.19.9" - }, "fuzzywuzzy": { "hashes": [ "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254", @@ -202,6 +205,13 @@ ], "version": "==2.8" }, + "idna-ssl": { + "hashes": [ + "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c" + ], + "markers": "python_version < '3.7'", + "version": "==1.1.0" + }, "imagesize": { "hashes": [ "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", @@ -211,18 +221,17 @@ }, "jinja2": { "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", + "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" ], - "version": "==2.10" + "version": "==2.10.1" }, "jsonpickle": { "hashes": [ - "sha256:8b6212f1155f43ce67fa945efae6d010ed059f3ca5ed377aa070e5903d45b722", - "sha256:d43ede55b3d9b5524a8e11566ea0b11c9c8109116ef6a509a1b619d2041e7397", - "sha256:ed4adf0d14564c56023862eabfac211cf01211a20c5271896c8ab6f80c68086c" + "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0", + "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1" ], - "version": "==1.0" + "version": "==1.1" }, "logmatic-python": { "hashes": [ @@ -233,35 +242,35 @@ }, "lxml": { "hashes": [ - "sha256:0dd6589fa75d369ba06d2b5f38dae107f76ea127f212f6a7bee134f6df2d1d21", - "sha256:1afbac344aa68c29e81ab56c1a9411c3663157b5aee5065b7fa030b398d4f7e0", - "sha256:1baad9d073692421ad5dbbd81430aba6c7f5fdc347f03537ae046ddf2c9b2297", - "sha256:1d8736421a2358becd3edf20260e41a06a0bf08a560480d3a5734a6bcbacf591", - "sha256:1e1d9bddc5afaddf0de76246d3f2152f961697ad7439c559f179002682c45801", - "sha256:1f179dc8b2643715f020f4d119d5529b02cd794c1c8f305868b73b8674d2a03f", - "sha256:241fb7bdf97cb1df1edfa8f0bcdfd80525d4023dac4523a241907c8b2f44e541", - "sha256:2f9765ee5acd3dbdcdc0d0c79309e01f7c16bc8d39b49250bf88de7b46daaf58", - "sha256:312e1e1b1c3ce0c67e0b8105317323e12807955e8186872affb667dbd67971f6", - "sha256:3273db1a8055ca70257fd3691c6d2c216544e1a70b673543e15cc077d8e9c730", - "sha256:34dfaa8c02891f9a246b17a732ca3e99c5e42802416628e740a5d1cb2f50ff49", - "sha256:3aa3f5288af349a0f3a96448ebf2e57e17332d99f4f30b02093b7948bd9f94cc", - "sha256:51102e160b9d83c1cc435162d90b8e3c8c93b28d18d87b60c56522d332d26879", - "sha256:56115fc2e2a4140e8994eb9585119a1ae9223b506826089a3ba753a62bd194a6", - "sha256:69d83de14dbe8fe51dccfd36f88bf0b40f5debeac763edf9f8325180190eba6e", - "sha256:99fdce94aeaa3ccbdfcb1e23b34273605c5853aa92ec23d84c84765178662c6c", - "sha256:a7c0cd5b8a20f3093ee4a67374ccb3b8a126743b15a4d759e2a1bf098faac2b2", - "sha256:abe12886554634ed95416a46701a917784cb2b4c77bfacac6916681d49bbf83d", - "sha256:b4f67b5183bd5f9bafaeb76ad119e977ba570d2b0e61202f534ac9b5c33b4485", - "sha256:bdd7c1658475cc1b867b36d5c4ed4bc316be8d3368abe03d348ba906a1f83b0e", - "sha256:c6f24149a19f611a415a51b9bc5f17b6c2f698e0d6b41ffb3fa9f24d35d05d73", - "sha256:d1e111b3ab98613115a208c1017f266478b0ab224a67bc8eac670fa0bad7d488", - "sha256:d6520aa965773bbab6cb7a791d5895b00d02cf9adc93ac2bf4edb9ac1a6addc5", - "sha256:dd185cde2ccad7b649593b0cda72021bc8a91667417001dbaf24cd746ecb7c11", - "sha256:de2e5b0828a9d285f909b5d2e9d43f1cf6cf21fe65bc7660bdaa1780c7b58298", - "sha256:f726444b8e909c4f41b4fde416e1071cf28fa84634bfb4befdf400933b6463af" + "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5", + "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f", + "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b", + "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616", + "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b", + "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294", + "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3", + "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90", + "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e", + "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314", + "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f", + "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d", + "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8", + "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3", + "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33", + "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317", + "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63", + "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd", + "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f", + "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a", + "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f", + "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22", + "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d", + "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b", + "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f", + "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86" ], "index": "pypi", - "version": "==4.3.0" + "version": "==4.3.3" }, "markdownify": { "hashes": [ @@ -272,36 +281,36 @@ }, "markupsafe": { "hashes": [ - "sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432", - "sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b", - "sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9", - "sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af", - "sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834", - "sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd", - "sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d", - "sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7", - "sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b", - "sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3", - "sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c", - "sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2", - "sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7", - "sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36", - "sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1", - "sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e", - "sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1", - "sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c", - "sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856", - "sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550", - "sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492", - "sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672", - "sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401", - "sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6", - "sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6", - "sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c", - "sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd", - "sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1" + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" ], - "version": "==1.1.0" + "version": "==1.1.1" }, "multidict": { "hashes": [ @@ -337,76 +346,75 @@ ], "version": "==4.5.2" }, + "ordered-set": { + "hashes": [ + "sha256:a7bfa858748c73b096e43db14eb23e2bc714a503f990c89fac8fab9b0ee79724" + ], + "version": "==3.1.1" + }, "packaging": { "hashes": [ - "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", - "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9" + "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", + "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" ], - "version": "==18.0" + "version": "==19.0" + }, + "pamqp": { + "hashes": [ + "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02", + "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8" + ], + "version": "==2.3.0" }, "pillow": { "hashes": [ - "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e", - "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7", - "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a", - "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3", - "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1", - "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1", - "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7", - "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1", - "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3", - "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055", - "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf", - "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f", - "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f", - "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239", - "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe", - "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c", - "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697", - "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494", - "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356", - "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6", - "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000", - "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f", - "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c", - "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca", - "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8", - "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3", - "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad", - "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9", - "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc", - "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e" + "sha256:15c056bfa284c30a7f265a41ac4cbbc93bdbfc0dfe0613b9cb8a8581b51a9e55", + "sha256:1a4e06ba4f74494ea0c58c24de2bb752818e9d504474ec95b0aa94f6b0a7e479", + "sha256:1c3c707c76be43c9e99cb7e3d5f1bee1c8e5be8b8a2a5eeee665efbf8ddde91a", + "sha256:1fd0b290203e3b0882d9605d807b03c0f47e3440f97824586c173eca0aadd99d", + "sha256:24114e4a6e1870c5a24b1da8f60d0ba77a0b4027907860188ea82bd3508c80eb", + "sha256:258d886a49b6b058cd7abb0ab4b2b85ce78669a857398e83e8b8e28b317b5abb", + "sha256:33c79b6dd6bc7f65079ab9ca5bebffb5f5d1141c689c9c6a7855776d1b09b7e8", + "sha256:367385fc797b2c31564c427430c7a8630db1a00bd040555dfc1d5c52e39fcd72", + "sha256:3c1884ff078fb8bf5f63d7d86921838b82ed4a7d0c027add773c2f38b3168754", + "sha256:44e5240e8f4f8861d748f2a58b3f04daadab5e22bfec896bf5434745f788f33f", + "sha256:46aa988e15f3ea72dddd81afe3839437b755fffddb5e173886f11460be909dce", + "sha256:74d90d499c9c736d52dd6d9b7221af5665b9c04f1767e35f5dd8694324bd4601", + "sha256:809c0a2ce9032cbcd7b5313f71af4bdc5c8c771cb86eb7559afd954cab82ebb5", + "sha256:85d1ef2cdafd5507c4221d201aaf62fc9276f8b0f71bd3933363e62a33abc734", + "sha256:8c3889c7681af77ecfa4431cd42a2885d093ecb811e81fbe5e203abc07e0995b", + "sha256:9218d81b9fca98d2c47d35d688a0cea0c42fd473159dfd5612dcb0483c63e40b", + "sha256:9aa4f3827992288edd37c9df345783a69ef58bd20cc02e64b36e44bcd157bbf1", + "sha256:9d80f44137a70b6f84c750d11019a3419f409c944526a95219bea0ac31f4dd91", + "sha256:b7ebd36128a2fe93991293f997e44be9286503c7530ace6a55b938b20be288d8", + "sha256:c4c78e2c71c257c136cdd43869fd3d5e34fc2162dc22e4a5406b0ebe86958239", + "sha256:c6a842537f887be1fe115d8abb5daa9bc8cc124e455ff995830cc785624a97af", + "sha256:cf0a2e040fdf5a6d95f4c286c6ef1df6b36c218b528c8a9158ec2452a804b9b8", + "sha256:cfd28aad6fc61f7a5d4ee556a997dc6e5555d9381d1390c00ecaf984d57e4232", + "sha256:dca5660e25932771460d4688ccbb515677caaf8595f3f3240ec16c117deff89a", + "sha256:de7aedc85918c2f887886442e50f52c1b93545606317956d65f342bd81cb4fc3", + "sha256:e6c0bbf8e277b74196e3140c35f9a1ae3eafd818f7f2d3a15819c49135d6c062" ], "index": "pypi", - "version": "==5.4.1" + "version": "==6.0.0" }, "pycares": { "hashes": [ - "sha256:080ae0f1b1b754be60b6ef31b9ab2915364c210eb1cb4d8e089357c89d7b9819", - "sha256:0eccb76dff0155ddf793a589c6270e1bdbf6975b2824d18d1d23db2075d7fc96", - "sha256:223a03d69e864a18d7bb2e0108bca5ba069ef91e5b048b953ed90ea9f50eb77f", - "sha256:289e49f98adfd7a2ae3656df26e1d62cf49a06bbc03ced63f243c22cd8919adf", - "sha256:292ac442a1d4ff27d41be748ec19f0c4ff47efebfb715064ba336564ea0f2071", - "sha256:34771095123da0e54597fe3c5585a28d3799945257e51b378a20778bf33573b6", - "sha256:34c8865f2d047be4c301ce90a916c7748be597e271c5c7932e8b9a6de85840f4", - "sha256:36af260b215f86ebfe4a5e4aea82fd6036168a5710cbf8aad77019ab52156dda", - "sha256:5e8e2a461717da40482b5fecf1119116234922d29660b3c3e01cbc5ba2cbf4bd", - "sha256:61e77bd75542c56dff49434fedbafb25604997bc57dc0ebf791a5732503cb1bb", - "sha256:691740c332f38a9035b4c6d1f0e6c8af239466ef2373a894d4393f0ea65c815d", - "sha256:6bc0e0fdcb4cdc4ca06aa0b07e6e3560d62b2af79ef0ea4589835fcd2059012b", - "sha256:96db5c93e2fe2e39f519efb7bb9d86aef56f5813fa0b032e47aba329fa925d57", - "sha256:af701b22c91b3e36f65ee9f4b1bc2fe4800c8ed486eb6ef203624acbe53d026d", - "sha256:b25bd21bba9c43d44320b719118c2ce35e4a78031f61d906caeb01316d49dafb", - "sha256:c42f68319f8ea2322ed81c31a86c4e60547e6e90f3ebef479a7a7540bddbf268", - "sha256:cc9a8d35af12bc5f484f3496f9cb3ab5bedfa4dcf3dfff953099453d88b659a7", - "sha256:dfee9d198ba6d6f29aa5bf510bfb2c28a60c3f308116f114c9fd311980d3e870", - "sha256:e1dd02e110a7a97582097ebba6713d9da28583b538c08e8a14bc82169c5d3e10", - "sha256:e48c586c80a139c6c7fb0298b944d1c40752cf839bc8584cc793e42a8971ba6c", - "sha256:f509762dec1a70eac32b86c098f37ac9c5d3d4a8a9098983328377c9e71543b2", - "sha256:f8e0d61733843844f9019c911d5676818d99c4cd2c54b91de58384c7d962862b", - "sha256:fe20280fed496deba60e0f6437b7672bdc83bf45e243bb546af47c60c85bcfbc" - ], - "version": "==2.4.0" + "sha256:2ca080db265ea238dc45f997f94effb62b979a617569889e265c26a839ed6305", + "sha256:6f79c6afb6ce603009db2042fddc2e348ad093ece9784cbe2daa809499871a23", + "sha256:70918d06eb0603016d37092a5f2c0228509eb4e6c5a3faacb4184f6ab7be7650", + "sha256:755187d28d24a9ea63aa2b4c0638be31d65fbf7f0ce16d41261b9f8cb55a1b99", + "sha256:7baa4b1f2146eb8423ff8303ebde3a20fb444a60db761fba0430d104fe35ddbf", + "sha256:90b27d4df86395f465a171386bc341098d6d47b65944df46518814ae298f6cc6", + "sha256:9e090dd6b2afa65cb51c133883b2bf2240fd0f717b130b0048714b33fb0f47ce", + "sha256:a11b7d63c3718775f6e805d6464cb10943780395ab042c7e5a0a7a9f612735dd", + "sha256:b253f5dcaa0ac7076b79388a3ac80dd8f3bd979108f813baade40d3a9b8bf0bd", + "sha256:c7f4f65e44ba35e35ad3febc844270665bba21cfb0fb7d749434e705b556e087", + "sha256:cdb342e6a254f035bd976d95807a2184038fc088d957a5104dcaab8be602c093", + "sha256:cf08e164f8bfb83b9fe633feb56f2754fae6baefcea663593794fa0518f8f98c", + "sha256:df9bc694cf03673878ea8ce674082c5acd134991d64d6c306d4bd61c0c1df98f" + ], + "version": "==3.0.0" }, "pycparser": { "hashes": [ @@ -459,63 +467,62 @@ }, "pyparsing": { "hashes": [ - "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b", - "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592" + "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", + "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" ], - "version": "==2.3.0" + "version": "==2.4.0" }, "python-dateutil": { "hashes": [ - "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93", - "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02" + "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", + "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" ], "index": "pypi", - "version": "==2.7.5" + "version": "==2.8.0" }, "python-json-logger": { "hashes": [ - "sha256:3e000053837500f9eb28d6228d7cb99fabfc1874d34b40c08289207292abaf2e", - "sha256:cf2caaf34bd2eff394915b6242de4d0245de79971712439380ece6f149748cde" + "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281" ], - "version": "==0.1.10" + "version": "==0.1.11" }, "pytz": { "hashes": [ - "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", - "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" + "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", + "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" ], - "version": "==2018.9" + "version": "==2019.1" }, "pyyaml": { "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" ], "index": "pypi", - "version": "==3.13" + "version": "==5.1" }, "regex": { "hashes": [ - "sha256:15b4a185ae9782133f398f8ab7c29612a6e5f34ea9411e4cd36e91e78c347ebe", - "sha256:3852b76f0b6d7bd98d328d548716c151b79017f2b81347360f26e5db10fb6503", - "sha256:79a6a60ed1ee3b12eb0e828c01d75e3b743af6616d69add6c2fde1d425a4ba3f", - "sha256:a2938c290b3be2c7cadafa21de3051f2ed23bfaf88728a1fe5dc552cbfdb0326", - "sha256:aff7414712c9e6d260609da9c9af3aacebfbc307a4abe3376c7736e2a6c8563f", - "sha256:d03782f0b0fa34f8f1dbdc94e27cf193b83c6105307a8c10563938c6d85180d9", - "sha256:db79ac3d81e655dc12d38a865dd6d1b569a28fab4c53749051cd599a6eb7614f", - "sha256:e803b3646c3f9c47f1f3dc870173c5d79c0fd2fd8e40bf917b97c7b56701baff", - "sha256:e9660ccca360b6bd79606aab3672562ebb14bce6af6c501107364668543f4bef" + "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4", + "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf", + "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175", + "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19", + "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c", + "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9", + "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8", + "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680", + "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585" ], - "version": "==2018.11.22" + "version": "==2019.4.14" }, "requests": { "hashes": [ @@ -525,12 +532,6 @@ "index": "pypi", "version": "==2.21.0" }, - "shortuuid": { - "hashes": [ - "sha256:d08fd398f40f8baf87e15eef8355e92fa541bca4eb8465fefab7ee22f92711b9" - ], - "version": "==0.5.0" - }, "six": { "hashes": [ "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", @@ -547,25 +548,69 @@ }, "soupsieve": { "hashes": [ - "sha256:1d6ca207e67765d5297a59d1b5a18344a84587674d8c002cea72081c01a7f638", - "sha256:dff67354bff219f169ee634173c0148fcb0f7b23304ffddcfa2bb2f07accf30a" + "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece", + "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca" ], - "version": "==1.7" + "version": "==1.9.1" }, "sphinx": { "hashes": [ - "sha256:429e3172466df289f0f742471d7e30ba3ee11f3b5aecd9a840480d03f14bcfe5", - "sha256:c4cb17ba44acffae3d3209646b6baec1e215cad3065e852c68cc569d4df1b9f8" + "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", + "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" ], "index": "pypi", - "version": "==1.8.3" + "version": "==2.0.1" }, - "sphinxcontrib-websupport": { + "sphinxcontrib-applehelp": { "hashes": [ - "sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd", - "sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9" + "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", + "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" ], - "version": "==1.1.0" + "version": "==1.0.1" + }, + "sphinxcontrib-devhelp": { + "hashes": [ + "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", + "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" + ], + "version": "==1.0.1" + }, + "sphinxcontrib-htmlhelp": { + "hashes": [ + "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", + "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + ], + "version": "==1.0.2" + }, + "sphinxcontrib-jsmath": { + "hashes": [ + "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", + "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" + ], + "version": "==1.0.1" + }, + "sphinxcontrib-qthelp": { + "hashes": [ + "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", + "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" + ], + "version": "==1.0.2" + }, + "sphinxcontrib-serializinghtml": { + "hashes": [ + "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", + "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" + ], + "version": "==1.1.3" + }, + "typing": { + "hashes": [ + "sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d", + "sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4", + "sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a" + ], + "markers": "python_version < '3.7'", + "version": "==3.6.6" }, "tzlocal": { "hashes": [ @@ -575,10 +620,10 @@ }, "urllib3": { "hashes": [ - "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", - "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" + "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", + "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" ], - "version": "==1.24.1" + "version": "==1.24.2" }, "websockets": { "hashes": [ @@ -626,24 +671,24 @@ "develop": { "atomicwrites": { "hashes": [ - "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", - "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" ], - "version": "==1.2.1" + "version": "==1.3.0" }, "attrs": { "hashes": [ - "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", - "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" ], - "version": "==18.2.0" + "version": "==19.1.0" }, "certifi": { "hashes": [ - "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", - "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", + "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" ], - "version": "==2018.11.29" + "version": "==2019.3.9" }, "chardet": { "hashes": [ @@ -659,6 +704,14 @@ ], "version": "==7.0" }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "markers": "sys_platform == 'win32'", + "version": "==0.4.1" + }, "dodgy": { "hashes": [ "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" @@ -673,29 +726,36 @@ ], "version": "==0.4.1" }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, "flake8": { "hashes": [ - "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670", - "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2" + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" ], "index": "pypi", - "version": "==3.6.0" + "version": "==3.7.7" }, "flake8-bugbear": { "hashes": [ - "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83", - "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a" + "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb", + "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d" ], "index": "pypi", - "version": "==18.8.0" + "version": "==19.3.0" }, "flake8-import-order": { "hashes": [ - "sha256:9be5ca10d791d458eaa833dd6890ab2db37be80384707b0f76286ddd13c16cbf", - "sha256:feca2fd0a17611b33b7fa84449939196c2c82764e262486d5c3e143ed77d387b" + "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543", + "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92" ], "index": "pypi", - "version": "==0.18" + "version": "==0.18.1" }, "flake8-string-format": { "hashes": [ @@ -707,11 +767,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:5fc28c82bba16abb4f1154dc59a90487f5491fbdb27e658cbee241e8fddc1b91", - "sha256:c05c9f7dadb5748a04b6fa1c47cb6ae5a8170f03cfb1dca8b37aec58c1ee6d15" + "sha256:1c476aabc6e8db26dc75278464a3a392dba0ea80562777c5f13fd5cdf2646154", + "sha256:b3f5b96affd0f57cacb6621ed28286ce67edaca807757b51227043ebf7b136a1" ], "index": "pypi", - "version": "==1.1.0" + "version": "==2.0.0" }, "flake8-todo": { "hashes": [ @@ -736,78 +796,78 @@ }, "more-itertools": { "hashes": [ - "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4", - "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc", - "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9" + "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", + "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" ], - "version": "==5.0.0" + "markers": "python_version > '2.7'", + "version": "==7.0.0" }, "packaging": { "hashes": [ - "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", - "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9" + "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", + "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" ], - "version": "==18.0" + "version": "==19.0" }, "pluggy": { "hashes": [ - "sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616", - "sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a" + "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", + "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" ], - "version": "==0.8.1" + "version": "==0.9.0" }, "py": { "hashes": [ - "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", - "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" ], - "version": "==1.7.0" + "version": "==1.8.0" }, "pycodestyle": { "hashes": [ - "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", - "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" ], - "version": "==2.4.0" + "version": "==2.5.0" }, "pyflakes": { "hashes": [ - "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49", - "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae" + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" ], - "version": "==2.0.0" + "version": "==2.1.1" }, "pyparsing": { "hashes": [ - "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b", - "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592" + "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", + "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" ], - "version": "==2.3.0" + "version": "==2.4.0" }, "pytest": { "hashes": [ - "sha256:3e65a22eb0d4f1bdbc1eacccf4a3198bf8d4049dea5112d70a0c61b00e748d02", - "sha256:5924060b374f62608a078494b909d341720a050b5224ff87e17e12377486a71d" + "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", + "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.4.1" }, "pyyaml": { "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" ], "index": "pypi", - "version": "==3.13" + "version": "==5.1" }, "requests": { "hashes": [ @@ -819,11 +879,11 @@ }, "safety": { "hashes": [ - "sha256:399511524f47230d5867f1eb75548f9feefb7a2711a4985cb5be0e034f87040f", - "sha256:69b970918324865dcd7b92337e07152a0ea1ceecaf92f4d3b38529ee0ca83441" + "sha256:0a3a8a178a9c96242b224f033ee8d1d130c0448b0e6622d12deaf37f6c3b4e59", + "sha256:5059f3ffab3648330548ea9c7403405bbfaf085b11235770825d14c58f24cb78" ], "index": "pypi", - "version": "==1.8.4" + "version": "==1.8.5" }, "six": { "hashes": [ @@ -834,10 +894,10 @@ }, "urllib3": { "hashes": [ - "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", - "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" + "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", + "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" ], - "version": "==1.24.1" + "version": "==1.24.2" } } } diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index c6d53c7ed..1c69d33ae 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -34,8 +34,7 @@ RULE_FUNCTION_MAPPING = { } WHITELISTED_CHANNELS = ( Channels.admins, Channels.announcements, Channels.big_brother_logs, - Channels.devalerts, Channels.devlog, Channels.devtest, - Channels.helpers, Channels.message_log, + Channels.devlog, Channels.devtest, Channels.helpers, Channels.message_log, Channels.mod_alerts, Channels.modlog, Channels.staff_lounge ) WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index b684ad886..84ddb85f4 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -5,7 +5,6 @@ import time from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Context, command, group -from dulwich.repo import Repo from bot.constants import ( Channels, Guild, Roles, URLs @@ -65,12 +64,7 @@ class Bot: url="https://gitlab.com/discord-python/projects/bot" ) - repo = Repo(".") - sha = repo[repo.head()].sha().hexdigest() - embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) - embed.add_field(name="Git SHA", value=str(sha)[:7]) - embed.set_author( name="Python Bot", url="https://gitlab.com/discord-python/projects/bot", diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 66e80e778..35f2c8a01 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -466,37 +466,6 @@ class ModLog: thumbnail=after.avatar_url_as(static_format="png") ) - async def on_raw_bulk_message_delete(self, event: RawBulkMessageDeleteEvent): - if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: - return - - # Could upload the log to the site - maybe we should store all the messages somewhere? - # Currently if messages aren't in the cache, we ain't gonna have 'em. - - ignored_messages = 0 - - for message_id in event.message_ids: - if message_id in self._ignored[Event.message_delete]: - self._ignored[Event.message_delete].remove(message_id) - ignored_messages += 1 - - if ignored_messages >= len(event.message_ids): - return - - channel = self.bot.get_channel(event.channel_id) - - if channel.category: - message = f"{len(event.message_ids)} deleted in {channel.category}/#{channel.name} (`{channel.id}`)" - else: - message = f"{len(event.message_ids)} deleted in #{channel.name} (`{channel.id}`)" - - await self.send_log_message( - Icons.message_bulk_delete, Colour.orange(), - "Bulk message delete", - message, channel_id=Channels.devalerts, - ping_everyone=True - ) - async def on_message_delete(self, message: Message): channel = message.channel author = message.author diff --git a/bot/constants.py b/bot/constants.py index 99ef98da2..d2e3bb315 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -319,7 +319,6 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot: int checkpoint_test: int - devalerts: int devlog: int devtest: int help_0: int diff --git a/config-default.yml b/config-default.yml index 3a1ad8052..f6481cfcd 100644 --- a/config-default.yml +++ b/config-default.yml @@ -91,7 +91,6 @@ guild: big_brother_logs: &BBLOGS 468507907357409333 bot: 267659945086812160 checkpoint_test: 422077681434099723 - devalerts: 460181980097675264 devlog: &DEVLOG 409308876241108992 devtest: &DEVTEST 414574275865870337 help_0: 303906576991780866 -- cgit v1.2.3 From ac30f13e2cb72fc84aaae8c6f88ad2d5f79edd9d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 27 Apr 2019 15:34:37 +0200 Subject: Fixing linting error -_- --- bot/cogs/modlog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 35f2c8a01..a3876bab7 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -7,9 +7,8 @@ from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import ( CategoryChannel, Colour, Embed, File, Guild, - Member, Message, NotFound, RawBulkMessageDeleteEvent, - RawMessageDeleteEvent, RawMessageUpdateEvent, Role, - TextChannel, User, VoiceChannel + Member, Message, NotFound, RawMessageDeleteEvent, + RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel ) from discord.abc import GuildChannel from discord.ext.commands import Bot -- cgit v1.2.3 From b0abdbd83fe6ea5cc09d5bc7f422b406b6e28001 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 8 May 2019 13:19:21 +0100 Subject: Update apt cache in build pipline --- azure-pipelines.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 81e3701e0..19df35c11 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -18,7 +18,9 @@ jobs: PIP_SRC: ".cache/src" steps: - - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev + - script: | + sudo apt-get update + sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev displayName: 'Install base dependencies' - task: UsePythonVersion@0 -- cgit v1.2.3 From 55c978dac3287f1eb0b9ca8a5fa65210bb7a672d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 May 2019 13:46:43 +0200 Subject: Removing special handling (allowance) for self-deprecating uses of the word 'retarded' from the filter. All bad words are now, once again, equal. --- bot/cogs/filtering.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index f5811d9d2..d1a0de89e 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -24,9 +24,6 @@ INVITE_RE = ( URL_RE = r"(https?://[^\s]+)" ZALGO_RE = r"[\u0300-\u036F\u0489]" -RETARDED_RE = r"(re+)tar+(d+|t+)(ed)?" -SELF_DEPRECATION_RE = fr"((i'?m)|(i am)|(it'?s)|(it is)) (.+? )?{RETARDED_RE}" -RETARDED_QUESTIONS_RE = fr"{RETARDED_RE} questions?" class Filtering: @@ -151,18 +148,6 @@ class Filtering: for expression in Filter.word_watchlist: if re.search(fr"\b{expression}\b", text, re.IGNORECASE): - - # Special handling for `retarded` - if expression == RETARDED_RE: - - # stuff like "I'm just retarded" - if re.search(SELF_DEPRECATION_RE, text, re.IGNORECASE): - return False - - # stuff like "sorry for all the retarded questions" - elif re.search(RETARDED_QUESTIONS_RE, text, re.IGNORECASE): - return False - return True return False -- cgit v1.2.3 From 71e80214d04bccf4bddfccdc0f6cfe2a663499c4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 20 May 2019 14:09:03 +0200 Subject: Updates the cog list and cog batch load/unloads to use status_indicator emojis instead of chevrons. --- bot/cogs/cogs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 0a33b3de0..cefe6b530 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -221,13 +221,13 @@ class Cogs: lines.append("\n**Unload failures**") for cog, error in failed_unloads: - lines.append(f"`{cog}` {Emojis.white_chevron} `{error}`") + lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") if failed_loads: lines.append("\n**Load failures**") for cog, error in failed_loads: - lines.append(f"`{cog}` {Emojis.white_chevron} `{error}`") + lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" f"{lines}") @@ -259,7 +259,7 @@ class Cogs: """ Get a list of all cogs, including their loaded status. - A red double-chevron indicates that the cog is unloaded. Green indicates that the cog is currently loaded. + Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. """ embed = Embed() @@ -291,11 +291,11 @@ class Cogs: cog = self.cogs[cog] if loaded: - chevron = Emojis.green_chevron + status = Emojis.status_online else: - chevron = Emojis.red_chevron + status = Emojis.status_offline - lines.append(f"{chevron} {cog}") + lines.append(f"{status} {cog}") log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.") await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) -- cgit v1.2.3 From ba3bf4030945bbdf3ac0ffc4acdf542de4d92759 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 9 Jun 2019 13:46:42 -0700 Subject: Add appeals e-mail to infraction DMs --- bot/cogs/moderation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 9db5d9d62..256d38866 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -30,6 +30,7 @@ INFRACTION_ICONS = { "Ban": Icons.user_ban } RULES_URL = "https://pythondiscord.com/about/rules" +APPEALABLE_INFRACTIONS = ("Ban", "Mute") def proxy_user(user_id: str) -> Object: @@ -1173,6 +1174,9 @@ class Moderation(Scheduler): embed.title = f"Please review our rules over at {RULES_URL}" embed.url = RULES_URL + if infr_type in APPEALABLE_INFRACTIONS: + embed.set_footer(text="To appeal this infraction, send an e-mail to appeals@pythondiscord.com") + return await self.send_private_embed(user, embed) async def notify_pardon( -- cgit v1.2.3 From fe75d99fdcd3ce67a64c6fcff51b5016dff0c5f0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 9 Jun 2019 13:56:17 -0700 Subject: Fix linting error for snakes --- bot/cogs/snakes.py | 2 +- bot/utils/snakes/sal.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py index d74380259..8dee13dca 100644 --- a/bot/cogs/snakes.py +++ b/bot/cogs/snakes.py @@ -13,9 +13,9 @@ from typing import Any, Dict import aiohttp import async_timeout +from PIL import Image, ImageDraw, ImageFont from discord import Colour, Embed, File, Member, Message, Reaction from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, group -from PIL import Image, ImageDraw, ImageFont from bot.constants import ERROR_REPLIES, Keys, URLs from bot.converters import Snake diff --git a/bot/utils/snakes/sal.py b/bot/utils/snakes/sal.py index 8530d8a0f..2528664aa 100644 --- a/bot/utils/snakes/sal.py +++ b/bot/utils/snakes/sal.py @@ -6,9 +6,9 @@ import os import random import aiohttp +from PIL import Image from discord import File, Member, Reaction from discord.ext.commands import Context -from PIL import Image from bot.utils.snakes.sal_board import ( BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE, -- cgit v1.2.3 From 37a604522d081e7dc668e70c9b5080d2d6df18eb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Jun 2019 23:38:50 -0700 Subject: Banish RabbitMQ from this world --- bot/__main__.py | 12 --- bot/cogs/rmq.py | 229 ----------------------------------------- bot/cogs/snekbox.py | 18 +--- bot/constants.py | 9 -- bot/utils/service_discovery.py | 22 ---- config-default.yml | 7 -- 6 files changed, 1 insertion(+), 296 deletions(-) delete mode 100644 bot/cogs/rmq.py delete mode 100644 bot/utils/service_discovery.py diff --git a/bot/__main__.py b/bot/__main__.py index 8687cc62c..8afec2718 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -8,7 +8,6 @@ from discord.ext.commands import Bot, when_mentioned_or from bot.api import APIClient from bot.constants import Bot as BotConfig, DEBUG_MODE -from bot.utils.service_discovery import wait_for_rmq log = logging.getLogger(__name__) @@ -31,14 +30,6 @@ bot.http_session = ClientSession( ) bot.api_client = APIClient(loop=asyncio.get_event_loop()) -log.info("Waiting for RabbitMQ...") -has_rmq = wait_for_rmq() - -if has_rmq: - log.info("RabbitMQ found") -else: - log.warning("Timed out while waiting for RabbitMQ") - # Internal/debug bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") @@ -80,9 +71,6 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.wolfram") -if has_rmq: - bot.load_extension("bot.cogs.rmq") - bot.run(BotConfig.token) bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/bot/cogs/rmq.py b/bot/cogs/rmq.py deleted file mode 100644 index 2742fb969..000000000 --- a/bot/cogs/rmq.py +++ /dev/null @@ -1,229 +0,0 @@ -import asyncio -import datetime -import json -import logging -import pprint - -import aio_pika -from aio_pika import Message -from dateutil import parser as date_parser -from discord import Colour, Embed -from discord.ext.commands import Bot -from discord.utils import get - -from bot.constants import Channels, Guild, RabbitMQ - -log = logging.getLogger(__name__) - -LEVEL_COLOURS = { - "debug": Colour.blue(), - "info": Colour.green(), - "warning": Colour.gold(), - "error": Colour.red() -} - -DEFAULT_LEVEL_COLOUR = Colour.greyple() -EMBED_PARAMS = ( - "colour", "title", "url", "description", "timestamp" -) - -CONSUME_TIMEOUT = datetime.timedelta(seconds=10) - - -class RMQ: - """ - RabbitMQ event handling - """ - - rmq = None # type: aio_pika.Connection - channel = None # type: aio_pika.Channel - queue = None # type: aio_pika.Queue - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_ready(self): - self.rmq = await aio_pika.connect_robust( - host=RabbitMQ.host, port=RabbitMQ.port, login=RabbitMQ.username, password=RabbitMQ.password - ) - - log.info("Connected to RabbitMQ") - - self.channel = await self.rmq.channel() - self.queue = await self.channel.declare_queue("bot_events", durable=True) - - log.debug("Channel opened, queue declared") - - async for message in self.queue: - with message.process(): - message.ack() - await self.handle_message(message, message.body.decode()) - - async def send_text(self, queue: str, data: str): - message = Message(data.encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def send_json(self, queue: str, **data): - message = Message(json.dumps(data).encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def consume(self, queue: str, **kwargs): - queue_obj = await self.channel.declare_queue(queue, **kwargs) - - result = None - start_time = datetime.datetime.now() - - while result is None: - if datetime.datetime.now() - start_time >= CONSUME_TIMEOUT: - result = "Timed out while waiting for a response." - else: - result = await queue_obj.get(timeout=5, fail=False) - await asyncio.sleep(0.5) - - if result: - result.ack() - - return result - - async def handle_message(self, message, data): - log.debug(f"Message: {message}") - log.debug(f"Data: {data}") - - try: - data = json.loads(data) - except Exception: - await self.do_mod_log("error", "Unable to parse event", data) - else: - event = data["event"] - event_data = data["data"] - - try: - func = getattr(self, f"do_{event}") - await func(**event_data) - except Exception as e: - await self.do_mod_log( - "error", f"Unable to handle event: {event}", - str(e) - ) - - async def do_mod_log(self, level: str, title: str, message: str): - colour = LEVEL_COLOURS.get(level, DEFAULT_LEVEL_COLOUR) - embed = Embed( - title=title, description=f"```\n{message}\n```", - colour=colour, timestamp=datetime.datetime.now() - ) - - await self.bot.get_channel(Channels.modlog).send(embed=embed) - log.log(logging._nameToLevel[level.upper()], f"Modlog: {title} | {message}") - - async def do_send_message(self, target: int, message: str): - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Message", - f"Unable to find channel: {target}" - ) - else: - await channel.send(message) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Message sent to channel {target}\n\n{message}" - ) - - async def do_send_embed(self, target: int, **embed_params): - for param, value in list(embed_params.items()): # To keep a full copy - if param not in EMBED_PARAMS: - await self.do_mod_log( - "warning", "Warning: Send Embed", - f"Unknown embed parameter: {param}" - ) - del embed_params[param] - - if param == "timestamp": - embed_params[param] = date_parser.parse(value) - elif param == "colour": - embed_params[param] = Colour(value) - - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Embed", - f"Unable to find channel: {target}" - ) - else: - await channel.send(embed=Embed(**embed_params)) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Embed sent to channel {target}\n\n{pprint.pformat(embed_params, 4)}" - ) - - async def do_add_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.add_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Add Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Add Role", - f"Role {role.name} added to member {target}" - ) - - async def do_remove_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.remove_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Remove Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Remove Role", - f"Role {role.name} removed from member {target}" - ) - - -def setup(bot): - bot.add_cog(RMQ(bot)) - log.info("Cog loaded: RMQ") diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cb0454249..1988303f1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -9,7 +9,6 @@ from discord.ext.commands import ( Bot, CommandError, Context, NoPrivateMessage, command, guild_only ) -from bot.cogs.rmq import RMQ from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs from bot.decorators import InChannelCheckFailure, in_channel from bot.utils.messages import wait_for_deletion @@ -17,12 +16,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -RMQ_ARGS = { - "durable": False, - "arguments": {"x-message-ttl": 5000}, - "auto_delete": True -} - CODE_TEMPLATE = """ venv_file = "/snekbox/.venv/bin/activate_this.py" exec(open(venv_file).read(), dict(__file__=venv_file)) @@ -64,10 +57,6 @@ class Snekbox: self.bot = bot self.jobs = {} - @property - def rmq(self) -> RMQ: - return self.bot.get_cog("RMQ") - @command(name='eval', aliases=('e',)) @guild_only() @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) @@ -107,13 +96,8 @@ class Snekbox: code = CODE_TEMPLATE.replace("{CODE}", code) try: - await self.rmq.send_json( - "input", - snekid=str(ctx.author.id), message=code - ) - async with ctx.typing(): - message = await self.rmq.consume(str(ctx.author.id), **RMQ_ARGS) + message = ... # TODO paste_link = None if isinstance(message, str): diff --git a/bot/constants.py b/bot/constants.py index d2e3bb315..3ceecab85 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -374,15 +374,6 @@ class Keys(metaclass=YAMLGetter): youtube: str -class RabbitMQ(metaclass=YAMLGetter): - section = "rabbitmq" - - host: str - password: str - port: int - username: str - - class URLs(metaclass=YAMLGetter): section = "urls" diff --git a/bot/utils/service_discovery.py b/bot/utils/service_discovery.py deleted file mode 100644 index 8d79096bd..000000000 --- a/bot/utils/service_discovery.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime -import socket -import time -from contextlib import closing - -from bot.constants import RabbitMQ - -THIRTY_SECONDS = datetime.timedelta(seconds=30) - - -def wait_for_rmq(): - start = datetime.datetime.now() - - while True: - if datetime.datetime.now() - start > THIRTY_SECONDS: - return False - - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - if sock.connect_ex((RabbitMQ.host, RabbitMQ.port)) == 0: - return True - - time.sleep(0.5) diff --git a/config-default.yml b/config-default.yml index f6481cfcd..8d9d0f999 100644 --- a/config-default.yml +++ b/config-default.yml @@ -204,13 +204,6 @@ keys: youtube: !ENV "YOUTUBE_API_KEY" -rabbitmq: - host: "pdrmq" - password: !ENV ["RABBITMQ_DEFAULT_PASS", "guest"] - port: 5672 - username: !ENV ["RABBITMQ_DEFAULT_USER", "guest"] - - urls: # PyDis site vars site: &DOMAIN "pythondiscord.com" -- cgit v1.2.3 From 6aaff26efcf9132ed4a3f35e5f2ac4516441027b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Jun 2019 23:39:22 -0700 Subject: Add snekbox endpoint to constants --- bot/constants.py | 3 +++ config-default.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 3ceecab85..0bd950a7d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -377,6 +377,9 @@ class Keys(metaclass=YAMLGetter): class URLs(metaclass=YAMLGetter): section = "urls" + # Snekbox endpoints + snekbox_eval_api: str + # Discord API endpoints discord_api: str discord_invite_api: str diff --git a/config-default.yml b/config-default.yml index 8d9d0f999..7854b5db9 100644 --- a/config-default.yml +++ b/config-default.yml @@ -235,6 +235,9 @@ urls: site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] + # Snekbox + snekbox_eval_api: "http://localhost:8060/eval" + # Env vars deploy: !ENV "DEPLOY_URL" status: !ENV "STATUS_URL" -- cgit v1.2.3 From 50bbe09dfbfb6f5300dfce7e2edad2bfd9819dbc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 09:56:12 -0700 Subject: Snekbox: add a function to send a request to the API --- bot/cogs/snekbox.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1988303f1..70a19db87 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -57,6 +57,13 @@ class Snekbox: self.bot = bot self.jobs = {} + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() + @command(name='eval', aliases=('e',)) @guild_only() @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) -- cgit v1.2.3 From 1f9d9eb3072a0f63044efc7846a474339a69f219 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 10:11:43 -0700 Subject: Snekbox: move input code preparation to a separate function --- bot/cogs/snekbox.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 70a19db87..1d18d2d84 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -64,6 +64,28 @@ class Snekbox: async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: return await resp.json() + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" + match = FORMATTED_CODE_REGEX.fullmatch(code) + if match: + code, block, lang, delim = match.group("code", "block", "lang", "delim") + code = textwrap.dedent(code) + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + log.trace(f"Extracted {info} for evaluation:\n{code}") + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) + + code = textwrap.indent(code, " ") + return CODE_TEMPLATE.replace("{CODE}", code) + @command(name='eval', aliases=('e',)) @guild_only() @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) @@ -85,22 +107,7 @@ class Snekbox: log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}") self.jobs[ctx.author.id] = datetime.datetime.now() - # Strip whitespace and inline or block code markdown and extract the code and some formatting info - match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace(f"Eval message contains not or badly formatted code, stripping whitespace only:\n{code}") - - code = textwrap.indent(code, " ") - code = CODE_TEMPLATE.replace("{CODE}", code) + code = self.prepare_input(code) try: async with ctx.typing(): -- cgit v1.2.3 From e003dfc9e9fa2bb876a71f9b73a764d0f4bc6186 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 10:46:04 -0700 Subject: Snekbox: move paste service upload to a separate function --- bot/cogs/snekbox.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1d18d2d84..9b0c39ac1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,6 +3,7 @@ import logging import random import re import textwrap +from typing import Optional from discord import Colour, Embed from discord.ext.commands import ( @@ -64,6 +65,18 @@ class Snekbox: async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: return await resp.json() + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + log.exception("Failed to upload full output to paste service!") + @staticmethod def prepare_input(code: str) -> str: """Extract code from the Markdown, format it, and insert it into the code template.""" @@ -149,16 +162,7 @@ class Snekbox: truncated = True if truncated: - try: - response = await self.bot.http_session.post( - URLs.paste_service.format(key="documents"), - data=full_output - ) - data = await response.json() - if "key" in data: - paste_link = URLs.paste_service.format(key=data["key"]) - except Exception: - log.exception("Failed to upload full output to paste service!") + paste_link = await self.upload_output(full_output) if output.strip(): if paste_link: -- cgit v1.2.3 From 07d1126867256cd84844b8da883e4e837a60f9f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 11:05:35 -0700 Subject: Snekbox: move output formatting to a separate function --- bot/cogs/snekbox.py | 87 +++++++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 9b0c39ac1..cb31d8ccd 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,7 +3,7 @@ import logging import random import re import textwrap -from typing import Optional +from typing import Optional, Tuple from discord import Colour, Embed from discord.ext.commands import ( @@ -99,6 +99,50 @@ class Snekbox: code = textwrap.indent(code, " ") return CODE_TEMPLATE.replace("{CODE}", code) + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + output = output.strip(" \n") + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space + + if " 0: + output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] + output = "\n".join(output) + + if output.count("\n") > 10: + output = "\n".join(output.split("\n")[:10]) + + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + truncated = True + + elif len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long)" + truncated = True + + if truncated: + paste_link = await self.upload_output(full_output) + + return output.strip(), paste_link + @command(name='eval', aliases=('e',)) @guild_only() @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) @@ -125,46 +169,9 @@ class Snekbox: try: async with ctx.typing(): message = ... # TODO - paste_link = None + output, paste_link = await self.format_output(message) - if isinstance(message, str): - output = str.strip(" \n") - else: - output = message.body.decode().strip(" \n") - - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space - - if " 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] - output = "\n".join(output) - - if output.count("\n") > 10: - output = "\n".join(output.split("\n")[:10]) - - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - truncated = True - - elif len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long)" - truncated = True - - if truncated: - paste_link = await self.upload_output(full_output) - - if output.strip(): + if output: if paste_link: msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \ f"\nFull output: {paste_link}" -- cgit v1.2.3 From 0024395c9b081eb4898352fb3f0bd390d72b0976 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 13:04:27 -0700 Subject: Snekbox: refactor eval command --- bot/cogs/snekbox.py | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cb31d8ccd..f6cf31487 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -148,48 +148,48 @@ class Snekbox: @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) async def eval_command(self, ctx: Context, *, code: str = None): """ - Run some code. get the result back. We've done our best to make this safe, but do let us know if you - manage to find an issue with it! + Run Python code and get the results. - This command supports multiple lines of code, including code wrapped inside a formatted code block. + This command supports multiple lines of code, including code wrapped inside a formatted code + block. We've done our best to make this safe, but do let us know if you manage to find an + issue with it! """ - if ctx.author.id in self.jobs: - await ctx.send(f"{ctx.author.mention} You've already got a job running - please wait for it to finish!") - return + return await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + f"please wait for it to finish!" + ) if not code: # None or empty string return await ctx.invoke(self.bot.get_command("help"), "eval") - log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}") - self.jobs[ctx.author.id] = datetime.datetime.now() + log.info( + f"Received code from {ctx.author.name}#{ctx.author.discriminator} " + f"for evaluation:\n{code}" + ) + self.jobs[ctx.author.id] = datetime.datetime.now() code = self.prepare_input(code) try: async with ctx.typing(): - message = ... # TODO - output, paste_link = await self.format_output(message) + results = await self.post_eval(code) + output, paste_link = await self.format_output(results["stdout"]) - if output: - if paste_link: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \ - f"\nFull output: {paste_link}" - else: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" + if not output: + output = "[No output]" - response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)) + msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" - else: - await ctx.send( - f"{ctx.author.mention} Your eval job has completed.\n\n```py\n[No output]\n```" - ) + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + finally: del self.jobs[ctx.author.id] - except Exception: - del self.jobs[ctx.author.id] - raise @eval_command.error async def eval_command_error(self, ctx: Context, error: CommandError): -- cgit v1.2.3 From 6fe67047ba4df4949cf5f86aaa7df19d90788601 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 19:12:35 -0700 Subject: Snekbox: redirect stderr to stdout * Save original output before processing output --- bot/cogs/snekbox.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index f6cf31487..909479f4f 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -21,7 +21,11 @@ CODE_TEMPLATE = """ venv_file = "/snekbox/.venv/bin/activate_this.py" exec(open(venv_file).read(), dict(__file__=venv_file)) +import contextlib +import sys + try: + with contextlib.redirect_stderr(sys.stdout): {CODE} except Exception as e: print(e) @@ -75,6 +79,7 @@ class Snekbox: if "key" in data: return URLs.paste_service.format(key=data["key"]) except Exception: + # 400 (Bad Request) means the data is too large log.exception("Failed to upload full output to paste service!") @staticmethod @@ -96,7 +101,7 @@ class Snekbox: f"stripping whitespace only:\n{code}" ) - code = textwrap.indent(code, " ") + code = textwrap.indent(code, " ") return CODE_TEMPLATE.replace("{CODE}", code) async def format_output(self, output: str) -> Tuple[str, Optional[str]]: @@ -107,6 +112,7 @@ class Snekbox: and upload the full output to a paste service. """ output = output.strip(" \n") + original_output = output # To be uploaded to a pasting service if needed paste_link = None if "<@" in output: @@ -118,8 +124,6 @@ class Snekbox: if ESCAPE_REGEX.findall(output): return "Code block escape attempt detected; will not output result", paste_link - # the original output, to send to a pasting service if needed - full_output = output truncated = False if output.count("\n") > 0: output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] @@ -139,7 +143,7 @@ class Snekbox: truncated = True if truncated: - paste_link = await self.upload_output(full_output) + paste_link = await self.upload_output(original_output) return output.strip(), paste_link -- cgit v1.2.3 From acee1bfb6ecb1df0f21f06d1444dd2d7c06f9819 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 20:33:12 -0700 Subject: Snekbox: provide more descriptive messages for failures Uses the process's return code to determine an appropriate message. Furthermore, the signal's name is returned if applicable. * Log process's return code * Edit cog docstring --- bot/cogs/snekbox.py | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 909479f4f..a01348f3a 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,6 +3,7 @@ import logging import random import re import textwrap +from signal import Signals from typing import Optional, Tuple from discord import Colour, Embed @@ -55,7 +56,7 @@ BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) class Snekbox: """ - Safe evaluation using Snekbox + Safe evaluation of Python code using Snekbox """ def __init__(self, bot: Bot): @@ -104,6 +105,31 @@ class Snekbox: code = textwrap.indent(code, " ") return CODE_TEMPLATE.replace("{CODE}", code) + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stderr, returncode = results["stderr"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stderr.strip() + elif returncode == 128 + Signals.SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass + + return msg, error + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: """ Format the output and return a tuple of the formatted output and a URL to the full output. @@ -145,9 +171,13 @@ class Snekbox: if truncated: paste_link = await self.upload_output(original_output) - return output.strip(), paste_link + output = output.strip() + if not output: + output = "[No output]" - @command(name='eval', aliases=('e',)) + return output, paste_link + + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES) async def eval_command(self, ctx: Context, *, code: str = None): @@ -178,13 +208,14 @@ class Snekbox: try: async with ctx.typing(): results = await self.post_eval(code) - output, paste_link = await self.format_output(results["stdout"]) - - if not output: - output = "[No output]" + msg, error = self.get_results_message(results) - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```" if paste_link: msg = f"{msg}\nFull output: {paste_link}" @@ -192,6 +223,11 @@ class Snekbox: self.bot.loop.create_task( wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) ) + + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " + f"{results['returncode']}" + ) finally: del self.jobs[ctx.author.id] -- cgit v1.2.3 From d13802b1572220e33c13d7e67aef0039aa8d0293 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Jun 2019 20:53:14 -0700 Subject: Snekbox: make output formatting more efficient * Only prepend line numbers to the first 10 lines * Use generator expression for prepending line numbers to output * Add trace logging --- bot/cogs/snekbox.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index a01348f3a..64e926257 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -72,6 +72,8 @@ class Snekbox: async def upload_output(self, output: str) -> Optional[str]: """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") + url = URLs.paste_service.format(key="documents") try: async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: @@ -137,6 +139,8 @@ class Snekbox: Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters and upload the full output to a paste service. """ + log.trace("Formatting output...") + output = output.strip(" \n") original_output = output # To be uploaded to a pasting service if needed paste_link = None @@ -151,22 +155,22 @@ class Snekbox: return "Code block escape attempt detected; will not output result", paste_link truncated = False - if output.count("\n") > 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] - output = "\n".join(output) + lines = output.count("\n") - if output.count("\n") > 10: - output = "\n".join(output.split("\n")[:10]) + if lines > 0: + output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway + output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) + output = "\n".join(output) + if lines > 10: + truncated = True if len(output) >= 1000: output = f"{output[:1000]}\n... (truncated - too long, too many lines)" else: output = f"{output}\n... (truncated - too many lines)" - truncated = True - elif len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long)" truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" if truncated: paste_link = await self.upload_output(original_output) -- cgit v1.2.3 From 82be5b29c9e849aaf43b99a4ed3fd829289b12dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Jun 2019 18:45:51 -0700 Subject: Snekbox: adjust for API change that merged stderr into stdout --- bot/cogs/snekbox.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 64e926257..fd30aebcb 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -22,11 +22,7 @@ CODE_TEMPLATE = """ venv_file = "/snekbox/.venv/bin/activate_this.py" exec(open(venv_file).read(), dict(__file__=venv_file)) -import contextlib -import sys - try: - with contextlib.redirect_stderr(sys.stdout): {CODE} except Exception as e: print(e) @@ -104,19 +100,19 @@ class Snekbox: f"stripping whitespace only:\n{code}" ) - code = textwrap.indent(code, " ") + code = textwrap.indent(code, " ") return CODE_TEMPLATE.replace("{CODE}", code) @staticmethod def get_results_message(results: dict) -> Tuple[str, str]: """Return a user-friendly message and error corresponding to the process's return code.""" - stderr, returncode = results["stderr"], results["returncode"] + stdout, returncode = results["stdout"], results["returncode"] msg = f"Your eval job has completed with return code {returncode}" error = "" if returncode is None: msg = "Your eval job has failed" - error = stderr.strip() + error = stdout.strip() elif returncode == 128 + Signals.SIGKILL: msg = "Your eval job timed out or ran out of memory" elif returncode == 255: -- cgit v1.2.3 From ae959848e1332266464e824aa58a7c41fdce8312 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Jun 2019 19:20:43 -0700 Subject: Snekbox: remove code template Removing the template allows for line numbers in tracebacks to correspond to the input code. Since stderr has been merged into stdout, exceptions will already be captured. Thus, it is redundant to wrap the code in a try-except. Importing of the site module has been re-enabled on Snekbox which automatically adds site-packages to sys.path. Thus, the virtual environment does not need to be activated anymore in a template. --- bot/cogs/snekbox.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index fd30aebcb..e10c6c2aa 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -18,16 +18,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -CODE_TEMPLATE = """ -venv_file = "/snekbox/.venv/bin/activate_this.py" -exec(open(venv_file).read(), dict(__file__=venv_file)) - -try: -{CODE} -except Exception as e: - print(e) -""" - ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( r"^\s*" # any leading whitespace from the beginning of the string @@ -100,8 +90,7 @@ class Snekbox: f"stripping whitespace only:\n{code}" ) - code = textwrap.indent(code, " ") - return CODE_TEMPLATE.replace("{CODE}", code) + return code @staticmethod def get_results_message(results: dict) -> Tuple[str, str]: -- cgit v1.2.3 From 6dccbd38016b113983b195f15272a5f432df6dcb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Jun 2019 13:54:53 -0700 Subject: Snekbox: limit paste service uploads to 1000 characters --- bot/cogs/snekbox.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index e10c6c2aa..0f8d3e4b6 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -38,6 +38,7 @@ RAW_CODE_REGEX = re.compile( ) BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers) +MAX_PASTE_LEN = 1000 class Snekbox: @@ -60,6 +61,10 @@ class Snekbox: """Upload the eval output to a paste service and return a URL to it if successful.""" log.trace("Uploading full output to paste service...") + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" + url = URLs.paste_service.format(key="documents") try: async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: @@ -68,7 +73,7 @@ class Snekbox: if "key" in data: return URLs.paste_service.format(key=data["key"]) except Exception: - # 400 (Bad Request) means the data is too large + # 400 (Bad Request) means there are too many characters log.exception("Failed to upload full output to paste service!") @staticmethod -- cgit v1.2.3 From 54314b77a0687fd3d93ef9663ecd0555cc0f2b7b Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 23 Apr 2019 16:58:21 +0200 Subject: Adding the WatchChannel ABC --- bot/cogs/watchchannels/watchchannel.py | 341 +++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 bot/cogs/watchchannels/watchchannel.py diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py new file mode 100644 index 000000000..856211139 --- /dev/null +++ b/bot/cogs/watchchannels/watchchannel.py @@ -0,0 +1,341 @@ +import asyncio +import datetime +import logging +import re +from abc import ABC, abstractmethod +from collections import defaultdict, deque +from typing import Optional + +import aiohttp +import discord +from discord import Color, Embed, Message, errors +from discord.ext.commands import Bot, Context + +from bot.constants import ( + BigBrother as BigBrotherConfig, Guild as GuildConfig +) +from bot.pagination import LinePaginator +from bot.utils import messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +class WatchChannel(ABC): + """ + Base class for WatchChannels + + Abstracts the basic functionality for watchchannels in + a granular manner to allow for easy overwritting of + methods in the child class. + """ + + @abstractmethod + def __init__(self, bot: Bot) -> None: + """ + abstractmethod for __init__ which should still be called with super(). + + Note: Some of the attributes below need to be overwritten in the + __init__ of the child after the super().__init__(*args, **kwargs) + call. + """ + + self.bot = bot + + # These attributes need to be overwritten in the child class + self.destination = None # Channels.big_brother_logs + self.webhook_id = None # Some t.b.d. constant + self.api_endpoint = None # 'bot/infractions' + self.api_default_params = None # {'active': 'true', 'type': 'watch'} + + # These attributes can be left as they are in the child class + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = None + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = [None, None, 0] + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + log.exception( + f"{self.__class__.__name__} consume task has failed with:", + exc_info=exc + ) + return False + + return True + + # @Cog.listener() + async def start_watchchannel(self) -> None: + """Retrieves watched users from the API.""" + + await self.bot.wait_until_ready() + + if await self.initialize_channel() and await self.fetch_user_cache(): + log.trace(f"Started the {self.__class__.__name__} WatchChannel") + else: + log.error(f"Failed to start the {self.__class__.__name__} WatchChannel") + + async def initialize_channel(self) -> bool: + """ + Checks if channel and webhook are set; if not, tries to initialize them. + + Since the internal channel cache may not be available directly after `ready`, + this function will retry to get the channel a number of times. If both the + channel and webhook were initialized successfully. this function will return + `True`. + """ + + if self.channel is None: + for attempt in range(1, self.retries + 1): + self.channel = self.bot.get_channel(self.destination) + + if self.channel is None: + log.error(f"Failed to get the {self.__class__.__name__} channel; cannot watch users") + if attempt < self.initialization_retries: + log.error( + f"Attempt {attempt}/{self.retries}; " + f"Retrying in {self.retry_delay} seconds...") + await asyncio.sleep(self.retry_delay) + else: + log.trace(f"Retrieved the TextChannel for {self.__class__.__name__}") + break + else: + log.error(f"Cannot find channel with id `{self.destination}`; cannot watch users") + return False + + if self.webhook is None: + self.webhook = await self.bot.get_webhook_info(self.webhook_id) # This is `fetch_webhook` in current + if self.webhook is None: + log.error(f"Cannot find webhook with id `{self.webhook_id}`; cannot watch users") + return False + log.trace(f"Retrieved the webhook for {self.__class__.__name__}") + + log.trace(f"WatchChannel for {self.__class__.__name__} is fully initialized") + return True + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + + try: + data = await self.bot.api_client.get( + self.api_endpoint, + params=self.api_default_params + ) + except aiohttp.ClientResponseError as e: + log.exception( + f"Failed to fetch {self.__class__.__name__} watched users from API", + exc_info=e + ) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + # @Cog.listener() + async def on_message(self, msg: Message): + """Queues up messages sent by watched users.""" + + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True): + """Consumes the message queues to log watched users' messages.""" + + if delay_consumption: + log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(1) + + log.trace(f"{self.__class__.__name__} started consuming the message queue") + + # Prevent losing a partly processed consumption queue after Task failure + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task( + self.consume_messages(delay_consumption=False) + ) + else: + log.trace("Done consuming messages.") + + async def webhook_send( + self, content: Optional[str] = None, username: Optional[str] = None, + avatar_url: Optional[str] = None, embed: Optional[Embed] = None, + ): + """Sends a message to the webhook with the specified kwargs.""" + + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + log.exception( + f"Failed to send message to {self.__class__.__name__} webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant WatchChannel""" + + last_author, last_channel, count = self.message_history + limit = BigBrotherConfig.header_message_limit + + if msg.author.id != last_author or msg.channel.id != last_channel or count >= limit: + self.message_history = [msg.author.id, msg.channel.id, 0] + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + log.exception( + f"Failed to send an attachment to {self.__class__.__name__} webhook", + exc_info=exc + ) + + self.message_history[2] += 1 + + async def send_header(self, msg): + """Sends an header embed to the WatchChannel""" + + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + date_time = datetime.datetime.strptime( + inserted_at, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + reason = self.watched_users[user_id]['reason'] + + embed = Embed(description=( + f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})\n" + )) + embed.set_footer(text=( + f"Added {time_delta} by {actor} | " + f"Reason: {reason}" + )) + await self.webhook_send( + embed=embed, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + async def list_watched_users(self, ctx: Context, update_cache: bool = False) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + + if update_cache: + if not await self.fetch_user_cache(): + e = Embed( + description=f":x: **Failed to update {self.__class__.__name__} user cache**", + color=Color.red() + ) + ctx.send(embed=e) + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + date_time = datetime.datetime.strptime( + inserted_at, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + lines.append(f"• <@{user_id}> (added {time_delta})") + + await LinePaginator.paginate( + lines or ("There's nothing here yet.",), + ctx, + Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ), + empty=False + ) + + def cog_unload(self): + """Takes care of unloading the cog and cancelling the consumption task.""" + + log.trace(f"Unloading {self.__class__._name__} cog") + if not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + log.exception( + f"The {self.__class__._name__} consume task was cancelled. Messages may be lost.", + exc_info=e + ) -- cgit v1.2.3 From 75dc0b8a53a4105caf2d362bac75a7a23c856e5c Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 23 Apr 2019 16:59:34 +0200 Subject: Changing the send_attachments utility to add WebHook support --- bot/utils/messages.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fc38b0127..b285444c2 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,9 @@ import asyncio import contextlib from io import BytesIO -from typing import Sequence +from typing import Sequence, Union -from discord import Embed, File, Message, TextChannel +from discord import Embed, File, Message, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException @@ -78,7 +78,7 @@ async def wait_for_deletion( await message.delete() -async def send_attachments(message: Message, destination: TextChannel): +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]): """ Re-uploads each attachment in a message to the given channel. @@ -97,7 +97,14 @@ async def send_attachments(message: Message, destination: TextChannel): if attachment.size <= MAX_SIZE - 512: with BytesIO() as file: await attachment.save(file) - await destination.send(file=File(file, filename=attachment.filename)) + if isinstance(destination, TextChannel): + await destination.send(file=File(file, filename=attachment.filename)) + else: + await destination.send( + file=File(file, filename=attachment.filename), + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) else: large.append(attachment) except HTTPException as e: @@ -109,4 +116,11 @@ async def send_attachments(message: Message, destination: TextChannel): if large: embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) embed.set_footer(text="Attachments exceed upload size limit.") - await destination.send(embed=embed) + if isinstance(destination, TextChannel): + await destination.send(embed=embed) + else: + await destination.send( + embed=embed, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) -- cgit v1.2.3 From 5653c52c88508f03469960cf1abab45b2a5341d5 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 23 Apr 2019 17:01:12 +0200 Subject: Adding support to the 'active' parameter when posting an infraction --- bot/utils/moderation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index fcdf3c4d5..e81186253 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -14,8 +14,8 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], - type: str, reason: str, expires_at: datetime = None, hidden: bool = False + ctx: Context, user: Union[Member, Object, User], type: str, reason: str, + expires_at: datetime = None, hidden: bool = False, active: bool = True ): payload = { @@ -23,7 +23,8 @@ async def post_infraction( "hidden": hidden, "reason": reason, "type": type, - "user": user.id + "user": user.id, + "active": active } if expires_at: payload['expires_at'] = expires_at.isoformat() -- cgit v1.2.3 From cc5e439f1f9a313c857be33d7d8ab1dddd6a1607 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 23 Apr 2019 17:01:53 +0200 Subject: Adding the BigBrother watchchannel class --- bot/cogs/watchchannels/__init__.py | 12 ++++ bot/cogs/watchchannels/bigbrother.py | 119 +++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 bot/cogs/watchchannels/__init__.py create mode 100644 bot/cogs/watchchannels/bigbrother.py diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py new file mode 100644 index 000000000..e390bf062 --- /dev/null +++ b/bot/cogs/watchchannels/__init__.py @@ -0,0 +1,12 @@ +import logging + +from .bigbrother import BigBrother + + +log = logging.getLogger(__name__) + + +def setup(bot): + log.trace("Started adding BigBrother cog") + bot.add_cog(BigBrother(bot)) + log.trace("Finished adding BigBrother cog") diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py new file mode 100644 index 000000000..a7a66e6dc --- /dev/null +++ b/bot/cogs/watchchannels/bigbrother.py @@ -0,0 +1,119 @@ +import logging +from collections import ChainMap + +from discord import Color, Embed, User +from discord.ext.commands import Bot, Context, group + +from bot.constants import ( + Channels, Roles +) +from bot.decorators import with_role +from bot.utils.moderation import post_infraction +from .watchchannel import WatchChannel + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel): + """User monitoring to assist with moderation""" + + def __init__(self, bot): + super().__init__(bot) + self.log = log + + self.destination = Channels.big_brother_logs + self.webhook_id = 569096053333164052 + self.api_endpoint = 'bot/infractions' + self.api_default_params = {'active': 'true', 'type': 'watch'} + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the BigBrother watch channel""" + + await ctx.invoke(self.bot.get_command("help"), "bigbrother") + + @bigbrother_group.command(name='watched', aliases=('all',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = False) -> None: + """ + Shows the users that are currently being monitored in BigBrother. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + + await self.list_watched_users(ctx, update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: User, *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother-logs` channel. + + A `reason` for adding the user to BigBrother is required and will displayed + in the header when relaying messages of this user to the watchchannel. + """ + + await self.fetch_user_cache() + + if user.id in self.watched_users: + e = Embed( + description=":x: **The specified user is already being watched**", + color=Color.red() + ) + return await ctx.send(embed=e) + + response = await post_infraction( + ctx, user, type='watch', reason=reason, hidden=True + ) + if response is not None: + self.watched_users[user.id] = response + e = Embed( + description=f":white_check_mark: **Messages sent by {user} will now be relayed**", + color=Color.green() + ) + return await ctx.send(embed=e) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: User, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + [infraction] = active_watches + log.trace(infraction) + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + await post_infraction( + ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False + ) + e = Embed( + description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", + color=Color.green() + ) + return await ctx.send(embed=e) + self.watched_users.pop(str(user.id), None) + self.message_queue.pop(str(user.id), None) + self.consumption_queue.pop(str(user.id), None) + else: + e = Embed( + description=":x: **The specified user is currently not being watched**", + color=Color.red() + ) + return await ctx.send(embed=e) + + @bigbrother_group.command(name='debug') + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def debug(self, ctx): + for data in self.watched_users.values(): + await ctx.send(data) -- cgit v1.2.3 From 9e2e8580701f6ce74a75ee96436cf96fcdc24f07 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 23 Apr 2019 17:09:36 +0200 Subject: Cleaning up debug functions and unnecessary imports --- bot/cogs/watchchannels/bigbrother.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index a7a66e6dc..5e1f2c30b 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -2,7 +2,7 @@ import logging from collections import ChainMap from discord import Color, Embed, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Context, group from bot.constants import ( Channels, Roles @@ -111,9 +111,3 @@ class BigBrother(WatchChannel): color=Color.red() ) return await ctx.send(embed=e) - - @bigbrother_group.command(name='debug') - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def debug(self, ctx): - for data in self.watched_users.values(): - await ctx.send(data) -- cgit v1.2.3 From 59a75181e4f6c3a0994b0cc0603441ad0b1225e6 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:40:47 +0200 Subject: Adding proxy channel + improved methods to watchchannel --- bot/cogs/watchchannels/watchchannel.py | 117 +++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 48 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 856211139..5f9d8d1dd 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -8,12 +8,10 @@ from typing import Optional import aiohttp import discord -from discord import Color, Embed, Message, errors -from discord.ext.commands import Bot, Context +from discord import Color, Embed, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Context -from bot.constants import ( - BigBrother as BigBrotherConfig, Guild as GuildConfig -) +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig from bot.pagination import LinePaginator from bot.utils import messages from bot.utils.time import time_since @@ -23,6 +21,19 @@ log = logging.getLogger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") +def proxy_user(user_id: str) -> Object: + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + user = Object(user_id) + user.mention = user.id + user.display_name = f"<@{user.id}>" + user.avatar_url_as = lambda static_format: None + user.bot = False + return user + + class WatchChannel(ABC): """ Base class for WatchChannels @@ -41,12 +52,11 @@ class WatchChannel(ABC): __init__ of the child after the super().__init__(*args, **kwargs) call. """ - self.bot = bot # These attributes need to be overwritten in the child class self.destination = None # Channels.big_brother_logs - self.webhook_id = None # Some t.b.d. constant + self.webhook_id = None # Webhooks.big_brother self.api_endpoint = None # 'bot/infractions' self.api_default_params = None # {'active': 'true', 'type': 'watch'} @@ -54,7 +64,7 @@ class WatchChannel(ABC): self._consume_task = None self.watched_users = defaultdict(dict) self.message_queue = defaultdict(lambda: defaultdict(deque)) - self.consumption_queue = None + self.consumption_queue = {} self.retries = 5 self.retry_delay = 10 self.channel = None @@ -66,14 +76,13 @@ class WatchChannel(ABC): @property def consuming_messages(self) -> bool: """Checks if a consumption task is currently running.""" - if self._consume_task is None: return False if self._consume_task.done(): exc = self._consume_task.exception() if exc: - log.exception( + self.log.exception( f"{self.__class__.__name__} consume task has failed with:", exc_info=exc ) @@ -81,16 +90,14 @@ class WatchChannel(ABC): return True - # @Cog.listener() async def start_watchchannel(self) -> None: """Retrieves watched users from the API.""" - await self.bot.wait_until_ready() if await self.initialize_channel() and await self.fetch_user_cache(): - log.trace(f"Started the {self.__class__.__name__} WatchChannel") + self.log.trace(f"Started the {self.__class__.__name__} WatchChannel") else: - log.error(f"Failed to start the {self.__class__.__name__} WatchChannel") + self.log.error(f"Failed to start the {self.__class__.__name__} WatchChannel") async def initialize_channel(self) -> bool: """ @@ -101,33 +108,30 @@ class WatchChannel(ABC): channel and webhook were initialized successfully. this function will return `True`. """ - if self.channel is None: for attempt in range(1, self.retries + 1): self.channel = self.bot.get_channel(self.destination) if self.channel is None: - log.error(f"Failed to get the {self.__class__.__name__} channel; cannot watch users") + self.log.error(f"Failed to get the {self.__class__.__name__} channel; cannot watch users") if attempt < self.initialization_retries: - log.error( - f"Attempt {attempt}/{self.retries}; " - f"Retrying in {self.retry_delay} seconds...") + self.log.error(f"Attempt {attempt}/{self.retries}; Retrying in {self.retry_delay} seconds...") await asyncio.sleep(self.retry_delay) else: - log.trace(f"Retrieved the TextChannel for {self.__class__.__name__}") + self.log.trace(f"Retrieved the TextChannel for {self.__class__.__name__}") break else: - log.error(f"Cannot find channel with id `{self.destination}`; cannot watch users") + self.log.error(f"Cannot get channel with id `{self.destination}`; cannot watch users") return False if self.webhook is None: self.webhook = await self.bot.get_webhook_info(self.webhook_id) # This is `fetch_webhook` in current if self.webhook is None: - log.error(f"Cannot find webhook with id `{self.webhook_id}`; cannot watch users") + self.log.error(f"Cannot get webhook with id `{self.webhook_id}`; cannot watch users") return False - log.trace(f"Retrieved the webhook for {self.__class__.__name__}") + self.log.trace(f"Retrieved the webhook for {self.__class__.__name__}") - log.trace(f"WatchChannel for {self.__class__.__name__} is fully initialized") + self.log.trace(f"WatchChannel for {self.__class__.__name__} is fully initialized") return True async def fetch_user_cache(self) -> bool: @@ -136,14 +140,13 @@ class WatchChannel(ABC): This function returns `True` if the update succeeded. """ - try: data = await self.bot.api_client.get( self.api_endpoint, params=self.api_default_params ) except aiohttp.ClientResponseError as e: - log.exception( + self.log.exception( f"Failed to fetch {self.__class__.__name__} watched users from API", exc_info=e ) @@ -157,25 +160,22 @@ class WatchChannel(ABC): return True - # @Cog.listener() async def on_message(self, msg: Message): """Queues up messages sent by watched users.""" - if msg.author.id in self.watched_users: if not self.consuming_messages: self._consume_task = self.bot.loop.create_task(self.consume_messages()) - log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") self.message_queue[msg.author.id][msg.channel.id].append(msg) async def consume_messages(self, delay_consumption: bool = True): """Consumes the message queues to log watched users' messages.""" - if delay_consumption: - log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") await asyncio.sleep(1) - log.trace(f"{self.__class__.__name__} started consuming the message queue") + self.log.trace(f"{self.__class__.__name__} started consuming the message queue") # Prevent losing a partly processed consumption queue after Task failure if not self.consumption_queue: @@ -187,36 +187,34 @@ class WatchChannel(ABC): while channel_queue: msg = channel_queue.popleft() - log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") await self.relay_message(msg) self.consumption_queue.clear() if self.message_queue: - log.trace("Channel queue not empty: Continuing consuming queues") + self.log.trace("Channel queue not empty: Continuing consuming queues") self._consume_task = self.bot.loop.create_task( self.consume_messages(delay_consumption=False) ) else: - log.trace("Done consuming messages.") + self.log.trace("Done consuming messages.") async def webhook_send( self, content: Optional[str] = None, username: Optional[str] = None, avatar_url: Optional[str] = None, embed: Optional[Embed] = None, ): """Sends a message to the webhook with the specified kwargs.""" - try: await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) except discord.HTTPException as exc: - log.exception( + self.log.exception( f"Failed to send message to {self.__class__.__name__} webhook", exc_info=exc ) async def relay_message(self, msg: Message) -> None: """Relays the message to the relevant WatchChannel""" - last_author, last_channel, count = self.message_history limit = BigBrotherConfig.header_message_limit @@ -252,7 +250,7 @@ class WatchChannel(ABC): avatar_url=msg.author.avatar_url ) except discord.HTTPException as exc: - log.exception( + self.log.exception( f"Failed to send an attachment to {self.__class__.__name__} webhook", exc_info=exc ) @@ -261,7 +259,6 @@ class WatchChannel(ABC): async def send_header(self, msg): """Sends an header embed to the WatchChannel""" - user_id = msg.author.id guild = self.bot.get_guild(GuildConfig.id) @@ -304,16 +301,13 @@ class WatchChannel(ABC): description=f":x: **Failed to update {self.__class__.__name__} user cache**", color=Color.red() ) - ctx.send(embed=e) + await ctx.send(embed=e) + return lines = [] for user_id, user_data in self.watched_users.items(): inserted_at = user_data['inserted_at'] - date_time = datetime.datetime.strptime( - inserted_at, - "%Y-%m-%dT%H:%M:%S.%fZ" - ).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) + time_delta = self._get_time_delta(inserted_at) lines.append(f"• <@{user_id}> (added {time_delta})") await LinePaginator.paginate( @@ -326,16 +320,43 @@ class WatchChannel(ABC): empty=False ) + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format""" + + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + @staticmethod + def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str: + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + return date_time.strftime(output_format) + + def _remove_user(self, user_id: int) -> None: + """Removes user from the WatchChannel""" + + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + def cog_unload(self): """Takes care of unloading the cog and cancelling the consumption task.""" - log.trace(f"Unloading {self.__class__._name__} cog") + self.log.trace(f"Unloading {self.__class__._name__} cog") if not self._consume_task.done(): self._consume_task.cancel() try: self._consume_task.result() except asyncio.CancelledError as e: - log.exception( + self.log.exception( f"The {self.__class__._name__} consume task was cancelled. Messages may be lost.", exc_info=e ) -- cgit v1.2.3 From e84b04be18d0a736963af43a930899f47282b577 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:42:43 +0200 Subject: Adding proxy_user to type hints, adding anti-bot watch, and tweaking user feedback --- bot/cogs/watchchannels/bigbrother.py | 43 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 5e1f2c30b..55805cf87 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -1,15 +1,14 @@ import logging from collections import ChainMap +from typing import Union from discord import Color, Embed, User from discord.ext.commands import Context, group -from bot.constants import ( - Channels, Roles -) +from bot.constants import Channels, Roles, Webhooks from bot.decorators import with_role from bot.utils.moderation import post_infraction -from .watchchannel import WatchChannel +from .watchchannel import WatchChannel, proxy_user log = logging.getLogger(__name__) @@ -19,43 +18,51 @@ class BigBrother(WatchChannel): def __init__(self, bot): super().__init__(bot) - self.log = log + self.log = log # to ensure logs created in the super() get the name of this file self.destination = Channels.big_brother_logs - self.webhook_id = 569096053333164052 + self.webhook_id = Webhooks.big_brother self.api_endpoint = 'bot/infractions' - self.api_default_params = {'active': 'true', 'type': 'watch'} + self.api_default_params = { + 'active': 'true', 'type': 'watch', 'ordering': '-inserted_at' + } @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the BigBrother watch channel""" - await ctx.invoke(self.bot.get_command("help"), "bigbrother") - @bigbrother_group.command(name='watched', aliases=('all',)) + @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watched_command(self, ctx: Context, update_cache: bool = False) -> None: + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ Shows the users that are currently being monitored in BigBrother. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother-logs` channel. A `reason` for adding the user to BigBrother is required and will displayed in the header when relaying messages of this user to the watchchannel. """ + if user.bot: + e = Embed( + description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", + color=Color.red() + ) + return await ctx.send(embed=e) - await self.fetch_user_cache() + if not await self.fetch_user_cache(): + log.error("Failed to update user cache; can't watch user {user}") + return if user.id in self.watched_users: e = Embed( @@ -77,9 +84,8 @@ class BigBrother(WatchChannel): @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def unwatch_command(self, ctx: Context, user: User, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """Stop relaying messages by the given `user`.""" - active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( @@ -89,7 +95,6 @@ class BigBrother(WatchChannel): ) if active_watches: [infraction] = active_watches - log.trace(infraction) await self.bot.api_client.patch( f"{self.api_endpoint}/{infraction['id']}", json={'active': False} @@ -101,10 +106,8 @@ class BigBrother(WatchChannel): description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", color=Color.green() ) - return await ctx.send(embed=e) - self.watched_users.pop(str(user.id), None) - self.message_queue.pop(str(user.id), None) - self.consumption_queue.pop(str(user.id), None) + await ctx.send(embed=e) + self._remove_user(user.id) else: e = Embed( description=":x: **The specified user is currently not being watched**", -- cgit v1.2.3 From feaf3ff8c3d6970dd3156328d2eacc382f0ebe6b Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:43:17 +0200 Subject: Adding TalentPool to the watchchannels --- bot/cogs/watchchannels/__init__.py | 7 +- bot/cogs/watchchannels/talentpool.py | 267 +++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 bot/cogs/watchchannels/talentpool.py diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py index e390bf062..ac7713803 100644 --- a/bot/cogs/watchchannels/__init__.py +++ b/bot/cogs/watchchannels/__init__.py @@ -1,12 +1,15 @@ import logging from .bigbrother import BigBrother +from .talentpool import TalentPool log = logging.getLogger(__name__) def setup(bot): - log.trace("Started adding BigBrother cog") bot.add_cog(BigBrother(bot)) - log.trace("Finished adding BigBrother cog") + log.info("Cog loaded: BigBrother") + + bot.add_cog(TalentPool(bot)) + log.info("Cog loaded: TalentPool") diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py new file mode 100644 index 000000000..773547bab --- /dev/null +++ b/bot/cogs/watchchannels/talentpool.py @@ -0,0 +1,267 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from aiohttp.client_exceptions import ClientResponseError +from discord import Color, Embed, Member, User +from discord.ext.commands import Context, group + +from bot.constants import Channels, Guild, Roles, Webhooks +from bot.decorators import with_role +from bot.pagination import LinePaginator +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) +STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? + + +class TalentPool(WatchChannel): + """A TalentPool for helper nominees""" + def __init__(self, bot): + super().__init__(bot) + self.log = log # to ensure logs created in the super() get the name of this file + + self.destination = Channels.big_brother_logs + self.webhook_id = Webhooks.talent_pool + self.api_endpoint = 'bot/nominations' + self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to TalentPool.""" + + await ctx.invoke(self.bot.get_command("help"), "talentpool") + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored in BigBrother. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother-logs` channel. + + A `reason` for adding the user to BigBrother is required and will displayed + in the header when relaying messages of this user to the watchchannel. + """ + if user.bot: + e = Embed( + description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", + color=Color.red() + ) + return await ctx.send(embed=e) + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + e = Embed( + description=f":x: **Nominating staff members, eh? You cheeky bastard.**", + color=Color.red() + ) + return await ctx.send(embed=e) + + if not await self.fetch_user_cache(): + log.error(f"Failed to update user cache; can't watch user {user}") + e = Embed( + description=f":x: **Failed to update the user cache; can't add {user}**", + color=Color.red() + ) + return await ctx.send(embed=e) + + if user.id in self.watched_users: + e = Embed( + description=":x: **The specified user is already in the TalentPool**", + color=Color.red() + ) + return await ctx.send(embed=e) + + # Manual request with `raise_for_status` as False becausse we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + e = Embed( + description=":x: **The specified user can't be found in the database tables**", + color=Color.red() + ) + return await ctx.send(embed=e) + elif resp.status >= 400: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + e = Embed( + description=f":white_check_mark: **Messages sent by {user} will now be relayed**", + color=Color.green() + ) + return await ctx.send(embed=e) + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: + """Shows the specified user's nomination history""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + e = Embed( + description=":warning: **This user has never been nominated**", + color=Color.blue() + ) + return await ctx.send(embed=e) + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + e = Embed( + description=":x: **The specified user does not have an active Nomination**", + color=Color.red() + ) + return await ctx.send(embed=e) + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}/end", + json={'unnominate_reason': reason} + ) + e = Embed( + description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", + color=Color.green() + ) + await ctx.send(embed=e) + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_edit_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to TalentPool.""" + + await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + + @nomination_edit_group.command(name='reason') + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ClientResponseError as e: + if e.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + e = Embed( + description=f":x: **Can't find a nomination with id `{nomination_id}`**", + color=Color.red() + ) + return await ctx.send(embed=e) + else: + raise + + field = "reason" if nomination["active"] else "unnominate_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + e = Embed( + description=f":white_check_mark: **Updated the {field} of the nomination!**", + color=Color.green() + ) + await ctx.send(embed=e) + + def _nomination_to_string(self, nomination_object): + """Creates a string representation of a nomination""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = self._get_human_readable(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = self._get_human_readable(nomination_object["unwatched_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["unnominate_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() -- cgit v1.2.3 From 2d296ca5482751edabe7a8dc485c3e626c3a1546 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:43:43 +0200 Subject: Tweaking aliases to use the correct converters --- bot/cogs/alias.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 2ce4a51e3..d892c7b87 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,11 +1,13 @@ import inspect import logging +from typing import Union -from discord import Colour, Embed, User +from discord import Colour, Embed, Member, User from discord.ext.commands import ( Command, Context, clean_content, command, group ) +from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -71,23 +73,23 @@ class Alias: @command(name="watch", hidden=True) async def bigbrother_watch_alias( - self, ctx, user: User, *, reason: str = None + self, ctx, user: Union[Member, User, proxy_user], *, reason: str = None ): """ - Alias for invoking bigbrother watch user [text_channel]. + Alias for invoking bigbrother watch [user] [reason]. """ await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx, user: User): + async def bigbrother_unwatch_alias( + self, ctx, user: Union[User, proxy_user], *, reason: str = None + ): """ - Alias for invoking bigbrother unwatch user. - - user: discord.User - A user instance to unwatch + Alias for invoking bigbrother unwatch [user] [reason]. """ - await self.invoke(ctx, "bigbrother unwatch", user) + await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @command(name="home", hidden=True) async def site_home_alias(self, ctx): @@ -175,6 +177,26 @@ class Alias: await self.invoke(ctx, "docs get", symbol) + @command(name="nominate", hidden=True) + async def nomination_add_alias( + self, ctx, user: Union[Member, User, proxy_user], *, reason: str = None + ): + """ + Alias for invoking talentpool add [user] [reason]. + """ + + await self.invoke(ctx, "talentpool add", user, reason=reason) + + @command(name="unnominate", hidden=True) + async def nomination_end_alias( + self, ctx, user: Union[User, proxy_user], *, reason: str = None + ): + """ + Alias for invoking nomination end [user] [reason]. + """ + + await self.invoke(ctx, "nomination end", user, reason=reason) + def setup(bot): bot.add_cog(Alias(bot)) -- cgit v1.2.3 From 63aa34d474dad4e9062e5e690d5004829599442b Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:44:45 +0200 Subject: Adding Webhooks to constants --- bot/constants.py | 8 ++++++++ config-default.yml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 0bd950a7d..3c129afd2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -339,6 +339,14 @@ class Channels(metaclass=YAMLGetter): verification: int +class Webhooks(metaclass=YAMLGetter): + section = "guild" + subsection = "webhooks" + + talent_pool: int + big_brother: int + + class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" diff --git a/config-default.yml b/config-default.yml index 7854b5db9..8bfc84cb4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -128,6 +128,10 @@ guild: helpers: 267630620367257601 rockstars: &ROCKSTARS_ROLE 458226413825294336 + webhooks: + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + filter: -- cgit v1.2.3 From 38f03f01ae00f5fcb04dfd1c64e3bda1a49ef4d8 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 21:53:38 +0200 Subject: Truncating long names that misalign logs + adding 3 chars to account for watchchannels --- bot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index 54550842e..fecd7ceb3 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -55,7 +55,7 @@ else: logging.basicConfig( - format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s", + format="%(asctime)s pd.beardfist.com Bot: | %(name)33.33s | %(levelname)8s | %(message)s", datefmt="%b %d %H:%M:%S", level=logging.TRACE if DEBUG_MODE else logging.INFO, handlers=logging_handlers -- cgit v1.2.3 From 725656f471715fe96c92fac3b0155213f17029c1 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 27 Jun 2019 22:09:34 +0200 Subject: Removing the old talentpool/bigbrother files + changing the extension loader --- bot/__main__.py | 2 +- bot/cogs/bigbrother.py | 258 ------------------------------------------------ bot/cogs/nominations.py | 120 ---------------------- 3 files changed, 1 insertion(+), 379 deletions(-) delete mode 100644 bot/cogs/bigbrother.py delete mode 100644 bot/cogs/nominations.py diff --git a/bot/__main__.py b/bot/__main__.py index 8afec2718..44d4d9c02 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -38,7 +38,6 @@ bot.load_extension("bot.cogs.modlog") # Commands, etc bot.load_extension("bot.cogs.antispam") -bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") @@ -69,6 +68,7 @@ bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") +bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") bot.run(BotConfig.token) diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py deleted file mode 100644 index df7a0b576..000000000 --- a/bot/cogs/bigbrother.py +++ /dev/null @@ -1,258 +0,0 @@ -import asyncio -import logging -import re -from collections import defaultdict, deque -from typing import List, Union - -from discord import Color, Embed, Guild, Member, Message, User -from discord.ext.commands import Bot, Context, group - -from bot.constants import ( - BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Roles -) -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.moderation import post_infraction - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -class BigBrother: - """User monitoring to assist with moderation.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.watched_users = set() # { user_id } - self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } - self.last_log = [None, None, 0] # [user_id, channel_id, message_count] - self.consuming = False - - def update_cache(self, api_response: List[dict]): - """ - Updates the internal cache of watched users from the given `api_response`. - This function will only add (or update) existing keys, it will not delete - keys that were not present in the API response. - A user is only added if the bot can find a channel - with the given `channel_id` in its channel cache. - """ - - for entry in api_response: - user_id = entry['user'] - self.watched_users.add(user_id) - - async def on_ready(self): - """Retrieves watched users from the API.""" - - self.channel = self.bot.get_channel(Channels.big_brother_logs) - if self.channel is None: - log.error("Cannot find Big Brother channel. Cannot watch users.") - else: - data = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch' - } - ) - self.update_cache(data) - - async def on_member_ban(self, guild: Guild, user: Union[User, Member]): - if guild.id == GuildConfig.id and user.id in self.watched_users: - [active_watch] = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch', - 'user__id': str(user.id) - } - ) - await self.bot.api_client.put( - 'bot/infractions/' + str(active_watch['id']), - json={'active': False} - ) - self.watched_users.remove(user.id) - del self.channel_queues[user.id] - await self.channel.send( - f"{Emojis.bb_message}:hammer: {user} got banned, so " - f"`BigBrother` will no longer relay their messages." - ) - - async def on_message(self, msg: Message): - """Queues up messages sent by watched users.""" - - if msg.author.id in self.watched_users: - if not self.consuming: - self.bot.loop.create_task(self.consume_messages()) - - log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.channel_queues[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self): - """Consumes the message queues to log watched users' messages.""" - - if not self.consuming: - self.consuming = True - log.trace("Sleeping before consuming...") - await asyncio.sleep(BigBrotherConfig.log_delay) - - log.trace("Begin consuming messages.") - channel_queues = self.channel_queues.copy() - self.channel_queues.clear() - for _, queues in channel_queues.items(): - for queue in queues.values(): - while queue: - msg = queue.popleft() - log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") - - self.last_log[2] += 1 # Increment message count. - await self.send_header(msg) - await self.log_message(msg) - - if self.channel_queues: - log.trace("Queue not empty; continue consumption.") - self.bot.loop.create_task(self.consume_messages()) - else: - log.trace("Done consuming messages.") - self.consuming = False - - async def send_header(self, message: Message): - """ - Sends a log message header to the given channel. - - A header is only sent if the user or channel are different than the previous, or if the configured message - limit for a single header has been exceeded. - - :param message: the first message in the queue - """ - - last_user, last_channel, msg_count = self.last_log - limit = BigBrotherConfig.header_message_limit - - # Send header if user/channel are different or if message limit exceeded. - if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: - self.last_log = [message.author.id, message.channel.id, 0] - - embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") - embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url) - await self.channel.send(embed=embed) - - async def log_message(self, message: Message): - """ - Logs a watched user's message in the given channel. - - Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview - embeds from being automatically generated. - - :param message: the message to log - """ - - content = message.clean_content - if content: - # Put all non-media URLs in inline code blocks. - media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(content): - if url not in media_urls: - content = content.replace(url, f"`{url}`") - - await self.channel.send(content) - - await messages.send_attachments(message, self.channel) - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def bigbrother_group(self, ctx: Context): - """Monitor users, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "bigbrother") - - @bigbrother_group.command(name='watched', aliases=('all',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watched_command(self, ctx: Context, from_cache: bool = True): - """ - Shows all users that are currently monitored and in which channel. - By default, the users are returned from the cache. - If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'. - """ - - if from_cache: - lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title="Watched users (cached)", color=Color.blue()), - empty=False - ) - - else: - active_watches = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch' - } - ) - self.update_cache(active_watches) - lines = tuple( - f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" - for entry in active_watches - ) - - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title="Watched users", color=Color.blue()), - empty=False - ) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel - - A `reason` for watching is required, which is added for the user to be watched as a - note (aka: shadow warning) - """ - - if user.id in self.watched_users: - return await ctx.send(":x: That user is already watched.") - - await post_infraction( - ctx, user, type='watch', reason=reason, hidden=True - ) - self.watched_users.add(user.id) - await ctx.send(f":ok_hand: will now relay messages sent by {user}") - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def unwatch_command(self, ctx: Context, user: User): - """Stop relaying messages by the given `user`.""" - - active_watches = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'watch', - 'user__id': str(user.id) - } - ) - if active_watches: - [infraction] = active_watches - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction['id']), - json={'active': False} - ) - await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") - self.watched_users.remove(user.id) - if user.id in self.channel_queues: - del self.channel_queues[user.id] - else: - await ctx.send(":x: that user is currently not being watched") - - -def setup(bot: Bot): - bot.add_cog(BigBrother(bot)) - log.info("Cog loaded: BigBrother") diff --git a/bot/cogs/nominations.py b/bot/cogs/nominations.py deleted file mode 100644 index 93ee0d885..000000000 --- a/bot/cogs/nominations.py +++ /dev/null @@ -1,120 +0,0 @@ -import logging - -from discord import Color, Embed, User -from discord.ext.commands import Context, group - -from bot.cogs.bigbrother import BigBrother, Roles -from bot.constants import Channels -from bot.decorators import with_role -from bot.pagination import LinePaginator - - -log = logging.getLogger(__name__) - - -class Nominations(BigBrother): - """Monitor potential helpers, NSA-style.""" - - async def on_ready(self): - """Retrieve nominees from the API.""" - - self.channel = self.bot.get_channel(Channels.talent_pool) - if self.channel is None: - log.error("Cannot find talent pool channel. Cannot watch nominees.") - else: - nominations = await self.bot.api_client.get( - 'bot/nominations', - params={'active': 'true'} - ) - self.update_cache(nominations) - - async def on_member_ban(self, *_): - pass - - @group(name='nominations', aliases=('n',), invoke_without_command=True) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def bigbrother_group(self, ctx: Context): - """Nominate helpers, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "nominations") - - @bigbrother_group.command(name='nominated', aliases=('nominees', 'all')) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watched_command(self, ctx: Context, from_cache: bool = True): - if from_cache: - lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users) - - else: - active_nominations = await self.bot.api_client.get( - 'bot/nominations', - params={'active': 'true'} - ) - self.update_cache(active_nominations) - lines = tuple( - f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}" - for entry in active_nominations - ) - - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed( - title="Nominated users" + " (cached)" * from_cache, - color=Color.blue() - ), - empty=False - ) - - @bigbrother_group.command(name='nominate', aliases=('n',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """Talent pool the given `user`.""" - - active_nominations = await self.bot.api_client.get( - 'bot/nominations/' + str(user.id), - ) - if active_nominations: - active_nominations = await self.bot.api_client.put( - 'bot/nominations/' + str(user.id), - json={'active': True} - ) - await ctx.send(":ok_hand: user's watch was updated") - - else: - active_nominations = await self.bot.api_client.post( - 'bot/nominations/' + str(user.id), - json={ - 'active': True, - 'author': ctx.author.id, - 'reason': reason, - } - ) - self.watched_users.add(user.id) - await ctx.send(":ok_hand: user added to talent pool") - - @bigbrother_group.command(name='unnominate', aliases=('un',)) - @with_role(Roles.owner, Roles.admin, Roles.moderator) - async def unwatch_command(self, ctx: Context, user: User): - """Stop talent pooling the given `user`.""" - - nomination = await self.bot.api_client.get( - 'bot/nominations/' + str(user.id) - ) - - if not nomination['active']: - await ctx.send(":x: the nomination is already inactive") - - else: - await self.bot.api_client.put( - 'bot/nominations/' + str(user.id), - json={'active': False} - ) - self.watched_users.remove(user.id) - if user.id in self.channel_queues: - del self.channel_queues[user.id] - await ctx.send(f":ok_hand: {user} is no longer part of the talent pool") - - -def setup(bot): - bot.add_cog(Nominations(bot)) - log.info("Cog loaded: Nominations") -- cgit v1.2.3 From 76ef948eb27e05b9c3f5cb00944d337c2ba903a0 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 28 Jun 2019 12:22:18 +0200 Subject: Updating constants to include talent-pool --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 3c129afd2..82df8c6f0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -336,6 +336,7 @@ class Channels(metaclass=YAMLGetter): off_topic_3: int python: int reddit: int + talent_pool: int verification: int diff --git a/config-default.yml b/config-default.yml index 8bfc84cb4..7d9d56aff 100644 --- a/config-default.yml +++ b/config-default.yml @@ -109,6 +109,7 @@ guild: python: 267624335836053506 reddit: 458224812528238616 staff_lounge: &STAFF_LOUNGE 464905259261755392 + talent_pool: &TALENT_POOL 534321732593647616 verification: 352442727016693763 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] -- cgit v1.2.3 From c5d4505cc4f21d87d6177cb8208d4c64d85e1052 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 28 Jun 2019 19:20:06 +0200 Subject: force child to set parameters via __init__; return value annotations; correcting spelling mistakes; + small fixes --- bot/cogs/watchchannels/bigbrother.py | 21 ++++++++------- bot/cogs/watchchannels/talentpool.py | 27 +++++++++---------- bot/cogs/watchchannels/watchchannel.py | 47 ++++++++++++++++------------------ 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 55805cf87..6e894de1e 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -16,16 +16,15 @@ log = logging.getLogger(__name__) class BigBrother(WatchChannel): """User monitoring to assist with moderation""" - def __init__(self, bot): - super().__init__(bot) - self.log = log # to ensure logs created in the super() get the name of this file - - self.destination = Channels.big_brother_logs - self.webhook_id = Webhooks.big_brother - self.api_endpoint = 'bot/infractions' - self.api_default_params = { - 'active': 'true', 'type': 'watch', 'ordering': '-inserted_at' - } + def __init__(self, bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -77,7 +76,7 @@ class BigBrother(WatchChannel): if response is not None: self.watched_users[user.id] = response e = Embed( - description=f":white_check_mark: **Messages sent by {user} will now be relayed**", + description=f":white_check_mark: **Messages sent by {user} will now be relayed to BigBrother**", color=Color.green() ) return await ctx.send(embed=e) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 773547bab..6773ddc89 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -18,14 +18,15 @@ STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- I class TalentPool(WatchChannel): """A TalentPool for helper nominees""" - def __init__(self, bot): - super().__init__(bot) - self.log = log # to ensure logs created in the super() get the name of this file - - self.destination = Channels.big_brother_logs - self.webhook_id = Webhooks.talent_pool - self.api_endpoint = 'bot/nominations' - self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} + def __init__(self, bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -83,7 +84,7 @@ class TalentPool(WatchChannel): ) return await ctx.send(embed=e) - # Manual request with `raise_for_status` as False becausse we want the actual response + # Manual request with `raise_for_status` as False because we want the actual response session = self.bot.api_client.session url = self.bot.api_client._url_for(self.api_endpoint) kwargs = { @@ -103,12 +104,12 @@ class TalentPool(WatchChannel): color=Color.red() ) return await ctx.send(embed=e) - elif resp.status >= 400: - resp.raise_for_status() + + resp.raise_for_status() self.watched_users[user.id] = response_data e = Embed( - description=f":white_check_mark: **Messages sent by {user} will now be relayed**", + description=f":white_check_mark: **Messages sent by {user} will now be relayed to TalentPool**", color=Color.green() ) return await ctx.send(embed=e) @@ -223,7 +224,7 @@ class TalentPool(WatchChannel): ) await ctx.send(embed=e) - def _nomination_to_string(self, nomination_object): + def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination""" guild = self.bot.get_guild(Guild.id) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 5f9d8d1dd..566f7d52a 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -44,7 +44,7 @@ class WatchChannel(ABC): """ @abstractmethod - def __init__(self, bot: Bot) -> None: + def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: """ abstractmethod for __init__ which should still be called with super(). @@ -54,11 +54,11 @@ class WatchChannel(ABC): """ self.bot = bot - # These attributes need to be overwritten in the child class - self.destination = None # Channels.big_brother_logs - self.webhook_id = None # Webhooks.big_brother - self.api_endpoint = None # 'bot/infractions' - self.api_default_params = None # {'active': 'true', 'type': 'watch'} + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs # These attributes can be left as they are in the child class self._consume_task = None @@ -99,6 +99,10 @@ class WatchChannel(ABC): else: self.log.error(f"Failed to start the {self.__class__.__name__} WatchChannel") + # Let's try again in a minute. + await asyncio.sleep(60) + self._start = self.bot.loop.create_task(self.start_watchchannel()) + async def initialize_channel(self) -> bool: """ Checks if channel and webhook are set; if not, tries to initialize them. @@ -160,7 +164,7 @@ class WatchChannel(ABC): return True - async def on_message(self, msg: Message): + async def on_message(self, msg: Message) -> None: """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: if not self.consuming_messages: @@ -169,11 +173,11 @@ class WatchChannel(ABC): self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") self.message_queue[msg.author.id][msg.channel.id].append(msg) - async def consume_messages(self, delay_consumption: bool = True): + async def consume_messages(self, delay_consumption: bool = True) -> None: """Consumes the message queues to log watched users' messages.""" if delay_consumption: self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") - await asyncio.sleep(1) + await asyncio.sleep(BigBrotherConfig.log_delay) self.log.trace(f"{self.__class__.__name__} started consuming the message queue") @@ -203,7 +207,7 @@ class WatchChannel(ABC): async def webhook_send( self, content: Optional[str] = None, username: Optional[str] = None, avatar_url: Optional[str] = None, embed: Optional[Embed] = None, - ): + ) -> None: """Sends a message to the webhook with the specified kwargs.""" try: await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) @@ -226,6 +230,7 @@ class WatchChannel(ABC): cleaned_content = msg.clean_content if cleaned_content: + # Put all non-media URLs in a codeblock to prevent embeds media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} for url in URL_RE.findall(cleaned_content): if url not in media_urls: @@ -257,8 +262,8 @@ class WatchChannel(ABC): self.message_history[2] += 1 - async def send_header(self, msg): - """Sends an header embed to the WatchChannel""" + async def send_header(self, msg) -> None: + """Sends a header embed to the WatchChannel""" user_id = msg.author.id guild = self.bot.get_guild(GuildConfig.id) @@ -266,11 +271,7 @@ class WatchChannel(ABC): actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] - date_time = datetime.datetime.strptime( - inserted_at, - "%Y-%m-%dT%H:%M:%S.%fZ" - ).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) + time_delta = self._get_time_delta(inserted_at) reason = self.watched_users[user_id]['reason'] @@ -287,22 +288,21 @@ class WatchChannel(ABC): avatar_url=msg.author.avatar_url ) - async def list_watched_users(self, ctx: Context, update_cache: bool = False) -> None: + async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: """ Gives an overview of the watched user list for this channel. The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ - if update_cache: if not await self.fetch_user_cache(): e = Embed( - description=f":x: **Failed to update {self.__class__.__name__} user cache**", + description=f":x: **Failed to update {self.__class__.__name__} user cache, serving from cache**", color=Color.red() ) await ctx.send(embed=e) - return + update_cache = False lines = [] for user_id, user_data in self.watched_users.items(): @@ -323,7 +323,6 @@ class WatchChannel(ABC): @staticmethod def _get_time_delta(time_string: str) -> str: """Returns the time in human-readable time delta format""" - date_time = datetime.datetime.strptime( time_string, "%Y-%m-%dT%H:%M:%S.%fZ" @@ -342,14 +341,12 @@ class WatchChannel(ABC): def _remove_user(self, user_id: int) -> None: """Removes user from the WatchChannel""" - self.watched_users.pop(user_id, None) self.message_queue.pop(user_id, None) self.consumption_queue.pop(user_id, None) - def cog_unload(self): + def cog_unload(self) -> None: """Takes care of unloading the cog and cancelling the consumption task.""" - self.log.trace(f"Unloading {self.__class__._name__} cog") if not self._consume_task.done(): self._consume_task.cancel() -- cgit v1.2.3 From a9734f83c44afbbe56ee70fb3bf0284b2b62f061 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 28 Jun 2019 21:02:14 +0200 Subject: Changing 'return await ctx.send' to return on next line for correct return value annotation --- bot/cogs/watchchannels/bigbrother.py | 11 +++++++---- bot/cogs/watchchannels/talentpool.py | 38 ++++++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 6e894de1e..1721fefb9 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -57,7 +57,8 @@ class BigBrother(WatchChannel): description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return if not await self.fetch_user_cache(): log.error("Failed to update user cache; can't watch user {user}") @@ -68,7 +69,8 @@ class BigBrother(WatchChannel): description=":x: **The specified user is already being watched**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return response = await post_infraction( ctx, user, type='watch', reason=reason, hidden=True @@ -79,7 +81,8 @@ class BigBrother(WatchChannel): description=f":white_check_mark: **Messages sent by {user} will now be relayed to BigBrother**", color=Color.green() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -112,4 +115,4 @@ class BigBrother(WatchChannel): description=":x: **The specified user is currently not being watched**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 6773ddc89..e267d4594 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -60,14 +60,16 @@ class TalentPool(WatchChannel): description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): e = Embed( description=f":x: **Nominating staff members, eh? You cheeky bastard.**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return if not await self.fetch_user_cache(): log.error(f"Failed to update user cache; can't watch user {user}") @@ -75,14 +77,16 @@ class TalentPool(WatchChannel): description=f":x: **Failed to update the user cache; can't add {user}**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return if user.id in self.watched_users: e = Embed( description=":x: **The specified user is already in the TalentPool**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return # Manual request with `raise_for_status` as False because we want the actual response session = self.bot.api_client.session @@ -103,7 +107,8 @@ class TalentPool(WatchChannel): description=":x: **The specified user can't be found in the database tables**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return resp.raise_for_status() @@ -112,7 +117,7 @@ class TalentPool(WatchChannel): description=f":white_check_mark: **Messages sent by {user} will now be relayed to TalentPool**", color=Color.green() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -130,7 +135,8 @@ class TalentPool(WatchChannel): description=":warning: **This user has never been nominated**", color=Color.blue() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return embed = Embed( title=f"Nominations for {user.display_name} `({user.id})`", @@ -167,12 +173,13 @@ class TalentPool(WatchChannel): description=":x: **The specified user does not have an active Nomination**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}/end", - json={'unnominate_reason': reason} + await self.bot.api_client.put( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason} ) e = Embed( description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", @@ -206,11 +213,12 @@ class TalentPool(WatchChannel): description=f":x: **Can't find a nomination with id `{nomination_id}`**", color=Color.red() ) - return await ctx.send(embed=e) + await ctx.send(embed=e) + return else: raise - field = "reason" if nomination["active"] else "unnominate_reason" + field = "reason" if nomination["active"] else "end_reason" self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") await self.bot.api_client.patch( @@ -249,7 +257,7 @@ class TalentPool(WatchChannel): """ ) else: - end_date = self._get_human_readable(nomination_object["unwatched_at"]) + end_date = self._get_human_readable(nomination_object["ended_at"]) lines = textwrap.dedent( f""" =============== @@ -259,7 +267,7 @@ class TalentPool(WatchChannel): Reason: {nomination_object["reason"]} End date: {end_date} - Unwatch reason: {nomination_object["unnominate_reason"]} + Unwatch reason: {nomination_object["end_reason"]} Nomination ID: `{nomination_object["id"]}` =============== """ -- cgit v1.2.3 From 92327507bd9176e65157dc8fdf0696e2d8b26541 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 29 Jun 2019 17:21:27 +0200 Subject: Making sure a watch/unwatch reason is required when using the alias as well --- bot/cogs/alias.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index d892c7b87..f71d5d81f 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -72,9 +72,7 @@ class Alias: await self.invoke(ctx, "site resources") @command(name="watch", hidden=True) - async def bigbrother_watch_alias( - self, ctx, user: Union[Member, User, proxy_user], *, reason: str = None - ): + async def bigbrother_watch_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): """ Alias for invoking bigbrother watch [user] [reason]. """ @@ -82,9 +80,7 @@ class Alias: await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias( - self, ctx, user: Union[User, proxy_user], *, reason: str = None - ): + async def bigbrother_unwatch_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): """ Alias for invoking bigbrother unwatch [user] [reason]. """ @@ -178,9 +174,7 @@ class Alias: await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias( - self, ctx, user: Union[Member, User, proxy_user], *, reason: str = None - ): + async def nomination_add_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): """ Alias for invoking talentpool add [user] [reason]. """ @@ -188,9 +182,7 @@ class Alias: await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias( - self, ctx, user: Union[User, proxy_user], *, reason: str = None - ): + async def nomination_end_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): """ Alias for invoking nomination end [user] [reason]. """ -- cgit v1.2.3 From 7608561f06173c2ca30dadd55ebac9d42b88030f Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 29 Jun 2019 17:22:21 +0200 Subject: Applying changes requested in the reviews. Somewhat major changes include: - Reworked the start_watchchannel retry logic; - Updated docstrings; - Removed/changed Tetris-style argument lists to one-per-line or all on one line. --- bot/cogs/watchchannels/bigbrother.py | 22 +++-- bot/cogs/watchchannels/talentpool.py | 11 ++- bot/cogs/watchchannels/watchchannel.py | 173 +++++++++++++++------------------ bot/utils/messages.py | 2 +- 4 files changed, 99 insertions(+), 109 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 1721fefb9..dc5e76f55 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -14,7 +14,7 @@ log = logging.getLogger(__name__) class BigBrother(WatchChannel): - """User monitoring to assist with moderation""" + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" def __init__(self, bot) -> None: super().__init__( @@ -29,7 +29,7 @@ class BigBrother(WatchChannel): @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def bigbrother_group(self, ctx: Context) -> None: - """Monitors users by relaying their messages to the BigBrother watch channel""" + """Monitors users by relaying their messages to the BigBrother watch channel.""" await ctx.invoke(self.bot.get_command("help"), "bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) @@ -54,7 +54,7 @@ class BigBrother(WatchChannel): """ if user.bot: e = Embed( - description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", + description=f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.", color=Color.red() ) await ctx.send(embed=e) @@ -66,7 +66,7 @@ class BigBrother(WatchChannel): if user.id in self.watched_users: e = Embed( - description=":x: **The specified user is already being watched**", + description=":x: The specified user is already being watched.", color=Color.red() ) await ctx.send(embed=e) @@ -78,7 +78,7 @@ class BigBrother(WatchChannel): if response is not None: self.watched_users[user.id] = response e = Embed( - description=f":white_check_mark: **Messages sent by {user} will now be relayed to BigBrother**", + description=f":white_check_mark: Messages sent by {user} will now be relayed to BigBrother.", color=Color.green() ) await ctx.send(embed=e) @@ -97,22 +97,24 @@ class BigBrother(WatchChannel): ) if active_watches: [infraction] = active_watches + await self.bot.api_client.patch( f"{self.api_endpoint}/{infraction['id']}", json={'active': False} ) - await post_infraction( - ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False - ) + + await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) + e = Embed( - description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", + description=f":white_check_mark: Messages sent by {user} will no longer be relayed.", color=Color.green() ) await ctx.send(embed=e) + self._remove_user(user.id) else: e = Embed( - description=":x: **The specified user is currently not being watched**", + description=":x: The specified user is currently not being watched.", color=Color.red() ) await ctx.send(embed=e) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index e267d4594..5e515fe2e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -17,7 +17,8 @@ STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- I class TalentPool(WatchChannel): - """A TalentPool for helper nominees""" + """Relays messages of helper candidates to the talent-pool channel to observe them.""" + def __init__(self, bot) -> None: super().__init__( bot, @@ -109,8 +110,8 @@ class TalentPool(WatchChannel): ) await ctx.send(embed=e) return - - resp.raise_for_status() + else: + resp.raise_for_status() self.watched_users[user.id] = response_data e = Embed( @@ -122,7 +123,7 @@ class TalentPool(WatchChannel): @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: - """Shows the specified user's nomination history""" + """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, params={ @@ -233,7 +234,7 @@ class TalentPool(WatchChannel): await ctx.send(embed=e) def _nomination_to_string(self, nomination_object: dict) -> str: - """Creates a string representation of a nomination""" + """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) actor_id = nomination_object["actor"] diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 566f7d52a..8f0bc765d 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -2,6 +2,7 @@ import asyncio import datetime import logging import re +import textwrap from abc import ABC, abstractmethod from collections import defaultdict, deque from typing import Optional @@ -11,7 +12,8 @@ import discord from discord import Color, Embed, Message, Object, errors from discord.ext.commands import BadArgument, Bot, Context -from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig +from bot.cogs.modlog import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import messages from bot.utils.time import time_since @@ -22,36 +24,26 @@ URL_RE = re.compile(r"(https?://[^\s]+)") def proxy_user(user_id: str) -> Object: + """A proxy user object that mocks a real User instance for when the later is not available.""" try: user_id = int(user_id) except ValueError: raise BadArgument + user = Object(user_id) user.mention = user.id user.display_name = f"<@{user.id}>" user.avatar_url_as = lambda static_format: None user.bot = False + return user class WatchChannel(ABC): - """ - Base class for WatchChannels - - Abstracts the basic functionality for watchchannels in - a granular manner to allow for easy overwritting of - methods in the child class. - """ + """ABC that implements watch channel functionality to relay all messages of a user to a watch channel.""" @abstractmethod def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: - """ - abstractmethod for __init__ which should still be called with super(). - - Note: Some of the attributes below need to be overwritten in the - __init__ of the child after the super().__init__(*args, **kwargs) - call. - """ self.bot = bot self.destination = destination # E.g., Channels.big_brother_logs @@ -73,6 +65,11 @@ class WatchChannel(ABC): self._start = self.bot.loop.create_task(self.start_watchchannel()) + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + @property def consuming_messages(self) -> bool: """Checks if a consumption task is currently running.""" @@ -83,7 +80,7 @@ class WatchChannel(ABC): exc = self._consume_task.exception() if exc: self.log.exception( - f"{self.__class__.__name__} consume task has failed with:", + f"The message queue consume task has failed with:", exc_info=exc ) return False @@ -91,52 +88,58 @@ class WatchChannel(ABC): return True async def start_watchchannel(self) -> None: - """Retrieves watched users from the API.""" + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" await self.bot.wait_until_ready() - if await self.initialize_channel() and await self.fetch_user_cache(): - self.log.trace(f"Started the {self.__class__.__name__} WatchChannel") + # After updating d.py, this block can be replaced by `fetch_channel` with a try-except + for attempt in range(1, self.retries+1): + self.channel = self.bot.get_channel(self.destination) + if self.channel is None: + if attempt < self.retries: + await asyncio.sleep(self.retry_delay) + else: + break else: - self.log.error(f"Failed to start the {self.__class__.__name__} WatchChannel") + self.log.error(f"Failed to retrieve the text channel with id `{self.destination}") - # Let's try again in a minute. - await asyncio.sleep(60) - self._start = self.bot.loop.create_task(self.start_watchchannel()) + # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py + try: + self.webhook = await self.bot.get_webhook_info(self.webhook_id) + except (discord.HTTPException, discord.NotFound, discord.Forbidden): + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - async def initialize_channel(self) -> bool: - """ - Checks if channel and webhook are set; if not, tries to initialize them. + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") - Since the internal channel cache may not be available directly after `ready`, - this function will retry to get the channel a number of times. If both the - channel and webhook were initialized successfully. this function will return - `True`. - """ - if self.channel is None: - for attempt in range(1, self.retries + 1): - self.channel = self.bot.get_channel(self.destination) - - if self.channel is None: - self.log.error(f"Failed to get the {self.__class__.__name__} channel; cannot watch users") - if attempt < self.initialization_retries: - self.log.error(f"Attempt {attempt}/{self.retries}; Retrying in {self.retry_delay} seconds...") - await asyncio.sleep(self.retry_delay) - else: - self.log.trace(f"Retrieved the TextChannel for {self.__class__.__name__}") - break - else: - self.log.error(f"Cannot get channel with id `{self.destination}`; cannot watch users") - return False + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. - if self.webhook is None: - self.webhook = await self.bot.get_webhook_info(self.webhook_id) # This is `fetch_webhook` in current - if self.webhook is None: - self.log.error(f"Cannot get webhook with id `{self.webhook_id}`; cannot watch users") - return False - self.log.trace(f"Retrieved the webhook for {self.__class__.__name__}") + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - self.log.trace(f"WatchChannel for {self.__class__.__name__} is fully initialized") - return True + The Cog has been unloaded.""" + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=False, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon=Icons.token_removed, + color=Color.red() + ) async def fetch_user_cache(self) -> bool: """ @@ -145,15 +148,9 @@ class WatchChannel(ABC): This function returns `True` if the update succeeded. """ try: - data = await self.bot.api_client.get( - self.api_endpoint, - params=self.api_default_params - ) + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) except aiohttp.ClientResponseError as e: - self.log.exception( - f"Failed to fetch {self.__class__.__name__} watched users from API", - exc_info=e - ) + self.log.exception(f"Failed to fetch the watched users from the API", exc_info=e) return False self.watched_users = defaultdict(dict) @@ -181,7 +178,7 @@ class WatchChannel(ABC): self.log.trace(f"{self.__class__.__name__} started consuming the message queue") - # Prevent losing a partly processed consumption queue after Task failure + # If the previous consumption Task failed, first consume the existing comsumption_queue if not self.consumption_queue: self.consumption_queue = self.message_queue.copy() self.message_queue.clear() @@ -198,15 +195,16 @@ class WatchChannel(ABC): if self.message_queue: self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task( - self.consume_messages(delay_consumption=False) - ) + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) else: self.log.trace("Done consuming messages.") async def webhook_send( - self, content: Optional[str] = None, username: Optional[str] = None, - avatar_url: Optional[str] = None, embed: Optional[Embed] = None, + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, ) -> None: """Sends a message to the webhook with the specified kwargs.""" try: @@ -218,7 +216,7 @@ class WatchChannel(ABC): ) async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant WatchChannel""" + """Relays the message to the relevant watch channel""" last_author, last_channel, count = self.message_history limit = BigBrotherConfig.header_message_limit @@ -230,7 +228,7 @@ class WatchChannel(ABC): cleaned_content = msg.clean_content if cleaned_content: - # Put all non-media URLs in a codeblock to prevent embeds + # Put all non-media URLs in a code block to prevent embeds media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} for url in URL_RE.findall(cleaned_content): if url not in media_urls: @@ -263,7 +261,7 @@ class WatchChannel(ABC): self.message_history[2] += 1 async def send_header(self, msg) -> None: - """Sends a header embed to the WatchChannel""" + """Sends a header embed with information about the relayed messages to the watch channel""" user_id = msg.author.id guild = self.bot.get_guild(GuildConfig.id) @@ -275,18 +273,10 @@ class WatchChannel(ABC): reason = self.watched_users[user_id]['reason'] - embed = Embed(description=( - f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})\n" - )) - embed.set_footer(text=( - f"Added {time_delta} by {actor} | " - f"Reason: {reason}" - )) - await self.webhook_send( - embed=embed, - username=msg.author.display_name, - avatar_url=msg.author.avatar_url - ) + embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}") + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: """ @@ -310,15 +300,12 @@ class WatchChannel(ABC): time_delta = self._get_time_delta(inserted_at) lines.append(f"• <@{user_id}> (added {time_delta})") - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed( - title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", - color=Color.blue() - ), - empty=False + lines = lines or ("There's nothing here yet.",) + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) @staticmethod def _get_time_delta(time_string: str) -> str: @@ -346,7 +333,7 @@ class WatchChannel(ABC): self.consumption_queue.pop(user_id, None) def cog_unload(self) -> None: - """Takes care of unloading the cog and cancelling the consumption task.""" + """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace(f"Unloading {self.__class__._name__} cog") if not self._consume_task.done(): self._consume_task.cancel() @@ -354,6 +341,6 @@ class WatchChannel(ABC): self._consume_task.result() except asyncio.CancelledError as e: self.log.exception( - f"The {self.__class__._name__} consume task was cancelled. Messages may be lost.", + f"The {self.__class__._name__} consume task was canceled. Messages may be lost.", exc_info=e ) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b285444c2..5c9b5b4d7 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -80,7 +80,7 @@ async def wait_for_deletion( async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]): """ - Re-uploads each attachment in a message to the given channel. + Re-uploads each attachment in a message to the given channel or webhook. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. -- cgit v1.2.3 From 9c96c41928f999c3c6163e4fcdcaa762fbf4b786 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 1 Jul 2019 15:39:01 +0200 Subject: Apply docstring and logging message suggestions Co-Authored-By: Mark --- bot/cogs/watchchannels/watchchannel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 8f0bc765d..020eabe45 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -40,7 +40,7 @@ def proxy_user(user_id: str) -> Object: class WatchChannel(ABC): - """ABC that implements watch channel functionality to relay all messages of a user to a watch channel.""" + """ABC with functionality for relaying users' messages to a certain channel.""" @abstractmethod def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: @@ -100,7 +100,7 @@ class WatchChannel(ABC): else: break else: - self.log.error(f"Failed to retrieve the text channel with id `{self.destination}") + self.log.error(f"Failed to retrieve the text channel with id {self.destination}") # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py try: -- cgit v1.2.3 From d8fc80cefa272901e27e54564298596ab7ce4514 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 3 Jul 2019 16:31:05 +0200 Subject: Applied the following changes requested by reviews: - Bot responses are now plain messages instead of embeds; - The message history in the WatchChannel cog is now stored using a new dataclass, MessageHistory; - Removed the truncation option from the modified name field in logging; - bigbrother now provides user feedback when watching fails due to failing cache update; - Changed the lay-out of the mod_log alert and set ping_everyone to True; - Changed the function signature of `post_infraction` utility function to the one-parameter-per-line style; - Moved the declaration of File within bot/utils/messages.py to before the if/else to make things DRY. --- bot/__init__.py | 2 +- bot/cogs/watchchannels/bigbrother.py | 35 +++++-------------- bot/cogs/watchchannels/talentpool.py | 64 +++++++--------------------------- bot/cogs/watchchannels/watchchannel.py | 25 +++++++++---- bot/utils/messages.py | 6 ++-- bot/utils/moderation.py | 9 +++-- 6 files changed, 50 insertions(+), 91 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index fecd7ceb3..b6919a489 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -55,7 +55,7 @@ else: logging.basicConfig( - format="%(asctime)s pd.beardfist.com Bot: | %(name)33.33s | %(levelname)8s | %(message)s", + format="%(asctime)s pd.beardfist.com Bot: | %(name)33s | %(levelname)8s | %(message)s", datefmt="%b %d %H:%M:%S", level=logging.TRACE if DEBUG_MODE else logging.INFO, handlers=logging_handlers diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index dc5e76f55..ff26794f7 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -53,36 +53,25 @@ class BigBrother(WatchChannel): in the header when relaying messages of this user to the watchchannel. """ if user.bot: - e = Embed( - description=f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return if not await self.fetch_user_cache(): - log.error("Failed to update user cache; can't watch user {user}") + + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") return if user.id in self.watched_users: - e = Embed( - description=":x: The specified user is already being watched.", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(":x: The specified user is already being watched.") return response = await post_infraction( ctx, user, type='watch', reason=reason, hidden=True ) + if response is not None: self.watched_users[user.id] = response - e = Embed( - description=f":white_check_mark: Messages sent by {user} will now be relayed to BigBrother.", - color=Color.green() - ) - await ctx.send(embed=e) - return + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to BigBrother.") @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -105,16 +94,8 @@ class BigBrother(WatchChannel): await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) - e = Embed( - description=f":white_check_mark: Messages sent by {user} will no longer be relayed.", - color=Color.green() - ) - await ctx.send(embed=e) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: - e = Embed( - description=":x: The specified user is currently not being watched.", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(":x: The specified user is currently not being watched.") diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 5e515fe2e..45b695e55 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -57,36 +57,19 @@ class TalentPool(WatchChannel): in the header when relaying messages of this user to the watchchannel. """ if user.bot: - e = Embed( - description=f":x: **I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): - e = Embed( - description=f":x: **Nominating staff members, eh? You cheeky bastard.**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:") return if not await self.fetch_user_cache(): - log.error(f"Failed to update user cache; can't watch user {user}") - e = Embed( - description=f":x: **Failed to update the user cache; can't add {user}**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: Failed to update the user cache; can't add {user}") return if user.id in self.watched_users: - e = Embed( - description=":x: **The specified user is already in the TalentPool**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(":x: The specified user is already being watched in the TalentPool") return # Manual request with `raise_for_status` as False because we want the actual response @@ -105,7 +88,7 @@ class TalentPool(WatchChannel): if resp.status == 400 and response_data.get('user', False): e = Embed( - description=":x: **The specified user can't be found in the database tables**", + description=":x: The specified user can't be found in the database tables", color=Color.red() ) await ctx.send(embed=e) @@ -114,11 +97,7 @@ class TalentPool(WatchChannel): resp.raise_for_status() self.watched_users[user.id] = response_data - e = Embed( - description=f":white_check_mark: **Messages sent by {user} will now be relayed to TalentPool**", - color=Color.green() - ) - await ctx.send(embed=e) + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to TalentPool") @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) @@ -132,11 +111,7 @@ class TalentPool(WatchChannel): } ) if not result: - e = Embed( - description=":warning: **This user has never been nominated**", - color=Color.blue() - ) - await ctx.send(embed=e) + await ctx.send(":warning: This user has never been nominated") return embed = Embed( @@ -170,11 +145,7 @@ class TalentPool(WatchChannel): ) if not active_nomination: - e = Embed( - description=":x: **The specified user does not have an active Nomination**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(":x: The specified user does not have an active Nomination") return [nomination] = active_nomination @@ -182,11 +153,7 @@ class TalentPool(WatchChannel): f"{self.api_endpoint}/{nomination['id']}", json={'end_reason': reason} ) - e = Embed( - description=f":white_check_mark: **Messages sent by {user} will no longer be relayed**", - color=Color.green() - ) - await ctx.send(embed=e) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @@ -210,11 +177,7 @@ class TalentPool(WatchChannel): except ClientResponseError as e: if e.status == 404: self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") - e = Embed( - description=f":x: **Can't find a nomination with id `{nomination_id}`**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return else: raise @@ -222,16 +185,13 @@ class TalentPool(WatchChannel): field = "reason" if nomination["active"] else "end_reason" self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + await self.bot.api_client.patch( f"{self.api_endpoint}/{nomination_id}", json={field: reason} ) - e = Embed( - description=f":white_check_mark: **Updated the {field} of the nomination!**", - color=Color.green() - ) - await ctx.send(embed=e) + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 8f0bc765d..16212e3b0 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -5,7 +5,8 @@ import re import textwrap from abc import ABC, abstractmethod from collections import defaultdict, deque -from typing import Optional +from dataclasses import dataclass +from typing import Iterator, Optional import aiohttp import discord @@ -39,6 +40,16 @@ def proxy_user(user_id: str) -> Object: return user +@dataclass +class MessageHistory: + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + def __iter__(self) -> Iterator: + return iter((self.last_author, self.last_channel, self.message_count)) + + class WatchChannel(ABC): """ABC that implements watch channel functionality to relay all messages of a user to a watch channel.""" @@ -52,7 +63,6 @@ class WatchChannel(ABC): self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} self.log = logger # Logger of the child cog for a correct name in the logs - # These attributes can be left as they are in the child class self._consume_task = None self.watched_users = defaultdict(dict) self.message_queue = defaultdict(lambda: defaultdict(deque)) @@ -61,7 +71,7 @@ class WatchChannel(ABC): self.retry_delay = 10 self.channel = None self.webhook = None - self.message_history = [None, None, 0] + self.message_history = MessageHistory() self._start = self.bot.loop.create_task(self.start_watchchannel()) @@ -118,13 +128,14 @@ class WatchChannel(ABC): TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} - The Cog has been unloaded.""" + The Cog has been unloaded. + """ ) await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_everyone=False, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -221,7 +232,7 @@ class WatchChannel(ABC): limit = BigBrotherConfig.header_message_limit if msg.author.id != last_author or msg.channel.id != last_channel or count >= limit: - self.message_history = [msg.author.id, msg.channel.id, 0] + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) await self.send_header(msg) @@ -258,7 +269,7 @@ class WatchChannel(ABC): exc_info=exc ) - self.message_history[2] += 1 + self.message_history.message_count += 1 async def send_header(self, msg) -> None: """Sends a header embed with information about the relayed messages to the watch channel""" diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 5c9b5b4d7..94a8b36ed 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -97,11 +97,13 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web if attachment.size <= MAX_SIZE - 512: with BytesIO() as file: await attachment.save(file) + attachment_file = File(file, filename=attachment.filename) + if isinstance(destination, TextChannel): - await destination.send(file=File(file, filename=attachment.filename)) + await destination.send(file=attachment_file) else: await destination.send( - file=File(file, filename=attachment.filename), + file=attachment_file, username=message.author.display_name, avatar_url=message.author.avatar_url ) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index e81186253..c1eb98dd6 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -14,8 +14,13 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], type: str, reason: str, - expires_at: datetime = None, hidden: bool = False, active: bool = True + ctx: Context, + user: Union[Member, Object, User], + type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True, ): payload = { -- cgit v1.2.3 From 7e6d25a7f2a465c4a08d55f94ab760de27c30fb0 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 3 Jul 2019 20:04:31 +0200 Subject: Change end nomination API endpoint to PATCH endpoint --- bot/cogs/watchchannels/bigbrother.py | 1 - bot/cogs/watchchannels/talentpool.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index ff26794f7..5bf644f38 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -57,7 +57,6 @@ class BigBrother(WatchChannel): return if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") return diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 45b695e55..b1be6b9d9 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -145,13 +145,13 @@ class TalentPool(WatchChannel): ) if not active_nomination: - await ctx.send(":x: The specified user does not have an active Nomination") + await ctx.send(":x: The specified user does not have an active nomination") return [nomination] = active_nomination - await self.bot.api_client.put( + await self.bot.api_client.patch( f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason} + json={'end_reason': reason, 'active': False} ) await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") self._remove_user(user.id) -- cgit v1.2.3 From 30ac49aefd6473d8cf037fded1b978ddc95c88cb Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 3 Jul 2019 21:27:25 +0200 Subject: Removing last embed responses and unused imports --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 6 +----- bot/cogs/watchchannels/watchchannel.py | 6 +----- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 5bf644f38..169c3b206 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -2,7 +2,7 @@ import logging from collections import ChainMap from typing import Union -from discord import Color, Embed, User +from discord import User from discord.ext.commands import Context, group from bot.constants import Channels, Roles, Webhooks diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index b1be6b9d9..6abf7405b 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -87,11 +87,7 @@ class TalentPool(WatchChannel): response_data = await resp.json() if resp.status == 400 and response_data.get('user', False): - e = Embed( - description=":x: The specified user can't be found in the database tables", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(":x: The specified user can't be found in the database tables") return else: resp.raise_for_status() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index c2d3d0bec..b300bb658 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -298,11 +298,7 @@ class WatchChannel(ABC): """ if update_cache: if not await self.fetch_user_cache(): - e = Embed( - description=f":x: **Failed to update {self.__class__.__name__} user cache, serving from cache**", - color=Color.red() - ) - await ctx.send(embed=e) + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") update_cache = False lines = [] -- cgit v1.2.3 From a4e00a3c831360ff3e7bad7e68a29db232a2c306 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 3 Jul 2019 22:01:27 +0200 Subject: Update bot/cogs/watchchannels/watchchannel.py Co-Authored-By: Mark --- bot/cogs/watchchannels/watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index b300bb658..7f7efacaa 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -341,7 +341,7 @@ class WatchChannel(ABC): def cog_unload(self) -> None: """Takes care of unloading the cog and canceling the consumption task.""" - self.log.trace(f"Unloading {self.__class__._name__} cog") + self.log.trace(f"Unloading the cog") if not self._consume_task.done(): self._consume_task.cancel() try: -- cgit v1.2.3 From ffde8d451e02f1cb084e08557b52f2e5ffae6206 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 3 Jul 2019 22:04:24 +0200 Subject: Removing redundant self.__class__.__name__ occurrences Co-Authored-By: Mark --- bot/cogs/watchchannels/watchchannel.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7f7efacaa..977695134 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -187,7 +187,7 @@ class WatchChannel(ABC): self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") await asyncio.sleep(BigBrotherConfig.log_delay) - self.log.trace(f"{self.__class__.__name__} started consuming the message queue") + self.log.trace(f"Started consuming the message queue") # If the previous consumption Task failed, first consume the existing comsumption_queue if not self.consumption_queue: @@ -222,7 +222,7 @@ class WatchChannel(ABC): await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) except discord.HTTPException as exc: self.log.exception( - f"Failed to send message to {self.__class__.__name__} webhook", + f"Failed to send a message to the webhook", exc_info=exc ) @@ -265,7 +265,7 @@ class WatchChannel(ABC): ) except discord.HTTPException as exc: self.log.exception( - f"Failed to send an attachment to {self.__class__.__name__} webhook", + f"Failed to send an attachment to the webhook", exc_info=exc ) @@ -348,6 +348,6 @@ class WatchChannel(ABC): self._consume_task.result() except asyncio.CancelledError as e: self.log.exception( - f"The {self.__class__._name__} consume task was canceled. Messages may be lost.", + f"The consume task was canceled. Messages may be lost.", exc_info=e ) -- cgit v1.2.3 From ad013b94d98987beb20f81e0c9e7142c1ffc9e78 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 11:26:32 +0200 Subject: Removing the iter/unpacking support on dataclass in favour of multiline if --- bot/cogs/watchchannels/watchchannel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index b300bb658..a0be3b1d6 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -46,9 +46,6 @@ class MessageHistory: last_channel: Optional[int] = None message_count: int = 0 - def __iter__(self) -> Iterator: - return iter((self.last_author, self.last_channel, self.message_count)) - class WatchChannel(ABC): """ABC with functionality for relaying users' messages to a certain channel.""" @@ -228,10 +225,13 @@ class WatchChannel(ABC): async def relay_message(self, msg: Message) -> None: """Relays the message to the relevant watch channel""" - last_author, last_channel, count = self.message_history limit = BigBrotherConfig.header_message_limit - if msg.author.id != last_author or msg.channel.id != last_channel or count >= limit: + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.count >= limit + ): self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) await self.send_header(msg) -- cgit v1.2.3 From c0a29b4ea1b3ddc6db119987a883dcbf8c9aa916 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 11:31:58 +0200 Subject: Fixing bug with misnamed MessageHistory attribute message_count in if-statement --- bot/cogs/watchchannels/watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 46b20b4f0..9f67367fc 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -230,7 +230,7 @@ class WatchChannel(ABC): if ( msg.author.id != self.message_history.last_author or msg.channel.id != self.message_history.last_channel - or self.message_history.count >= limit + or self.message_history.message_count >= limit ): self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) -- cgit v1.2.3 From 0a25926f267188e4079b1575d883b9a47c6a5d10 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 13:01:14 +0200 Subject: Removing unused import --- bot/cogs/watchchannels/watchchannel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 9f67367fc..87a219a96 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -6,7 +6,7 @@ import textwrap from abc import ABC, abstractmethod from collections import defaultdict, deque from dataclasses import dataclass -from typing import Iterator, Optional +from typing import Optional import aiohttp import discord -- cgit v1.2.3 From 722790f4fc946923123ba042eb1d4f41ecfceb83 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 14:13:43 +0200 Subject: Replacing BigBrother by TalentPool in TalentPool docstrings --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 169c3b206..20cc66012 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -47,7 +47,7 @@ class BigBrother(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel. + Relay messages sent by the given `user` to the `#big-brother` channel. A `reason` for adding the user to BigBrother is required and will displayed in the header when relaying messages of this user to the watchchannel. diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 6abf7405b..d03ce0a7a 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -40,7 +40,7 @@ class TalentPool(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ - Shows the users that are currently being monitored in BigBrother. + Shows the users that are currently being monitored in TalentPool. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. @@ -51,9 +51,9 @@ class TalentPool(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel. + Relay messages sent by the given `user` to the `#talent-pool` channel. - A `reason` for adding the user to BigBrother is required and will displayed + A `reason` for adding the user to TalentPool is required and will displayed in the header when relaying messages of this user to the watchchannel. """ if user.bot: -- cgit v1.2.3 From f089c963b39dabfa9ff2e776f7edc6e329a4bc54 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 21:54:10 +0200 Subject: Applying docstring suggestions Co-Authored-By: Mark --- bot/cogs/watchchannels/bigbrother.py | 8 ++++---- bot/cogs/watchchannels/talentpool.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 20cc66012..e7b3d70bc 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -29,14 +29,14 @@ class BigBrother(WatchChannel): @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def bigbrother_group(self, ctx: Context) -> None: - """Monitors users by relaying their messages to the BigBrother watch channel.""" + """Monitors users by relaying their messages to the Big Brother watch channel.""" await ctx.invoke(self.bot.get_command("help"), "bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ - Shows the users that are currently being monitored in BigBrother. + Shows the users that are currently being monitored by Big Brother. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. @@ -49,7 +49,7 @@ class BigBrother(WatchChannel): """ Relay messages sent by the given `user` to the `#big-brother` channel. - A `reason` for adding the user to BigBrother is required and will displayed + A `reason` for adding the user to Big Brother is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ if user.bot: @@ -70,7 +70,7 @@ class BigBrother(WatchChannel): if response is not None: self.watched_users[user.id] = response - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to BigBrother.") + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index d03ce0a7a..6f06ebc36 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -32,7 +32,7 @@ class TalentPool(WatchChannel): @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def nomination_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to TalentPool.""" + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.invoke(self.bot.get_command("help"), "talentpool") @@ -40,7 +40,7 @@ class TalentPool(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: """ - Shows the users that are currently being monitored in TalentPool. + Shows the users that are currently being monitored in the talent pool. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. @@ -53,7 +53,7 @@ class TalentPool(WatchChannel): """ Relay messages sent by the given `user` to the `#talent-pool` channel. - A `reason` for adding the user to TalentPool is required and will displayed + A `reason` for adding the user to the talent pool is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ if user.bot: @@ -69,7 +69,7 @@ class TalentPool(WatchChannel): return if user.id in self.watched_users: - await ctx.send(":x: The specified user is already being watched in the TalentPool") + await ctx.send(":x: The specified user is already being watched in the talent pool") return # Manual request with `raise_for_status` as False because we want the actual response -- cgit v1.2.3 From a395ba657c582bee03beb334619f376ba4a43134 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 22:01:15 +0200 Subject: Adding correct docstring to TalentPool edit group method; adding periods to docstrings in the WatchChannel ABC --- bot/cogs/watchchannels/talentpool.py | 2 +- bot/cogs/watchchannels/watchchannel.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 6f06ebc36..d90bd2cbd 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -155,7 +155,7 @@ class TalentPool(WatchChannel): @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def nomination_edit_group(self, ctx: Context) -> None: - """Highlights the activity of helper nominees by relaying their messages to TalentPool.""" + """Commands to edit nominations.""" await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 87a219a96..fe6d6bb6e 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -224,7 +224,7 @@ class WatchChannel(ABC): ) async def relay_message(self, msg: Message) -> None: - """Relays the message to the relevant watch channel""" + """Relays the message to the relevant watch channel.""" limit = BigBrotherConfig.header_message_limit if ( @@ -272,7 +272,7 @@ class WatchChannel(ABC): self.message_history.message_count += 1 async def send_header(self, msg) -> None: - """Sends a header embed with information about the relayed messages to the watch channel""" + """Sends a header embed with information about the relayed messages to the watch channel.""" user_id = msg.author.id guild = self.bot.get_guild(GuildConfig.id) @@ -316,7 +316,7 @@ class WatchChannel(ABC): @staticmethod def _get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format""" + """Returns the time in human-readable time delta format.""" date_time = datetime.datetime.strptime( time_string, "%Y-%m-%dT%H:%M:%S.%fZ" @@ -334,7 +334,7 @@ class WatchChannel(ABC): return date_time.strftime(output_format) def _remove_user(self, user_id: int) -> None: - """Removes user from the WatchChannel""" + """Removes a user from a watch channel.""" self.watched_users.pop(user_id, None) self.message_queue.pop(user_id, None) self.consumption_queue.pop(user_id, None) -- cgit v1.2.3 From 20683f67f336579fec9be8050ee2c8e3a40ae537 Mon Sep 17 00:00:00 2001 From: SebastiaanZ <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 4 Jul 2019 22:19:33 +0200 Subject: Changing class-level docstring of TalentPool class to be consistent with the BigBrother class --- bot/cogs/watchchannels/talentpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index d90bd2cbd..75954dd4a 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -17,7 +17,7 @@ STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- I class TalentPool(WatchChannel): - """Relays messages of helper candidates to the talent-pool channel to observe them.""" + """Relays messages of helper candidates to a watch channel to observe them.""" def __init__(self, bot) -> None: super().__init__( -- cgit v1.2.3 From 443a6d7d77ff8fb30abdc8bdd1983bcc800e392a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 5 Jul 2019 17:27:19 +0200 Subject: Apply suggestions from code review Co-Authored-By: Mark --- bot/cogs/watchchannels/talentpool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 75954dd4a..6fbe2bc03 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -54,7 +54,7 @@ class TalentPool(WatchChannel): Relay messages sent by the given `user` to the `#talent-pool` channel. A `reason` for adding the user to the talent pool is required and will be displayed - in the header when relaying messages of this user to the watchchannel. + in the header when relaying messages of this user to the channel. """ if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -93,7 +93,7 @@ class TalentPool(WatchChannel): resp.raise_for_status() self.watched_users[user.id] = response_data - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to TalentPool") + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) -- cgit v1.2.3 From 40f39877b522d8d19025f25b536cdc1afe1d2596 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jul 2019 14:32:45 +0200 Subject: Whitelisting the kivy discord server from our filters --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index af0621ece..91b4d6cce 100644 --- a/config-default.yml +++ b/config-default.yml @@ -167,6 +167,7 @@ filter: - 327254708534116352 # Adafruit - 544525886180032552 # kennethreitz.org - 590806733924859943 # Discord Hack Week + - 423249981340778496 # Kivy domain_blacklist: - pornhub.com -- cgit v1.2.3 From c69c6d5548063c7aa95a69478189aaccffa63917 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jul 2019 14:36:26 +0200 Subject: moving over the communities to whitelist from master. --- config-default.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config-default.yml b/config-default.yml index 7854b5db9..dff8ed599 100644 --- a/config-default.yml +++ b/config-default.yml @@ -146,6 +146,13 @@ filter: - 267624335836053506 # Python Discord - 440186186024222721 # Python Discord: ModLog Emojis - 273944235143593984 # STEM + - 348658686962696195 # RLBot + - 531221516914917387 # Pallets + - 249111029668249601 # Gentoo + - 327254708534116352 # Adafruit + - 544525886180032552 # kennethreitz.org + - 590806733924859943 # Discord Hack Week + - 423249981340778496 # Kivy domain_blacklist: - pornhub.com -- cgit v1.2.3 From 89563e50a228868b6d693438e4c8087debc8390c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 16 Jul 2019 15:15:17 -0700 Subject: Remove unused import --- bot/cogs/site.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index c22beafd1..37bf4f4ea 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,4 +1,3 @@ -import gettext import logging from discord import Colour, Embed -- cgit v1.2.3 From 3e8690b6ac580ff49fac7022b74ba1b8f505ae83 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 28 Jul 2019 18:54:48 +0200 Subject: Revert 4d35f8f7137edb97e1124fa9087bd86399398047. --- bot/__main__.py | 1 + bot/cogs/events.py | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 bot/cogs/events.py diff --git a/bot/__main__.py b/bot/__main__.py index b3f80ef55..9bfd99098 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -31,6 +31,7 @@ bot.http_session = ClientSession( bot.api_client = APIClient(loop=asyncio.get_event_loop()) # Internal/debug +bot.load_extension("bot.cogs.events") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.modlog") diff --git a/bot/cogs/events.py b/bot/cogs/events.py new file mode 100644 index 000000000..160791fb0 --- /dev/null +++ b/bot/cogs/events.py @@ -0,0 +1,285 @@ +import logging + +from aiohttp import ClientResponseError +from discord import Colour, Embed, Member, Object +from discord.ext.commands import ( + BadArgument, Bot, BotMissingPermissions, + CommandError, CommandInvokeError, CommandNotFound, + Context, NoPrivateMessage, UserInputError +) + +from bot.cogs.modlog import ModLog +from bot.constants import ( + Channels, Colours, DEBUG_MODE, + Guild, Icons, Keys, + Roles, URLs +) +from bot.utils import chunks + +log = logging.getLogger(__name__) + +RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) + + +class Events: + """No commands, just event handlers.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + return self.bot.get_cog("ModLog") + + async def send_updated_users(self, *users, replace_all=False): + users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) + + for chunk in chunks(users, 1000): + response = None + + try: + if replace_all: + response = await self.bot.http_session.post( + url=URLs.site_user_api, + json=chunk, + headers={"X-API-Key": Keys.site_api} + ) + else: + response = await self.bot.http_session.put( + url=URLs.site_user_api, + json=chunk, + headers={"X-API-Key": Keys.site_api} + ) + + await response.json() # We do this to ensure we got a proper response from the site + except Exception: + if not response: + log.exception(f"Failed to send {len(chunk)} users") + else: + text = await response.text() + log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) + break # Stop right now, thank you very much + + result = {} + + if replace_all: + response = None + + try: + response = await self.bot.http_session.post( + url=URLs.site_user_complete_api, + headers={"X-API-Key": Keys.site_api} + ) + + result = await response.json() + except Exception: + if not response: + log.exception(f"Failed to send {len(chunk)} users") + else: + text = await response.text() + log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) + + return result + + async def send_delete_users(self, *users): + try: + response = await self.bot.http_session.delete( + url=URLs.site_user_api, + json=list(users), + headers={"X-API-Key": Keys.site_api} + ) + + return await response.json() + except Exception: + log.exception(f"Failed to delete {len(users)} users") + return {} + + async def get_user(self, user_id): + response = await self.bot.http_session.get( + url=URLs.site_user_api, + params={"user_id": user_id}, + headers={"X-API-Key": Keys.site_api} + ) + + resp = await response.json() + return resp["data"] + + async def on_command_error(self, ctx: Context, e: CommandError): + command = ctx.command + parent = None + + if command is not None: + parent = command.parent + + if parent and command: + help_command = (self.bot.get_command("help"), parent.name, command.name) + elif command: + help_command = (self.bot.get_command("help"), command.name) + else: + help_command = (self.bot.get_command("help"),) + + if hasattr(command, "on_error"): + log.debug(f"Command {command} has a local error handler, ignoring.") + return + + if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + # Return to not raise the exception + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + elif isinstance(e, BadArgument): + await ctx.send(f"Bad argument: {e}\n") + await ctx.invoke(*help_command) + elif isinstance(e, UserInputError): + await ctx.invoke(*help_command) + elif isinstance(e, NoPrivateMessage): + await ctx.send("Sorry, this command can't be used in a private message!") + elif isinstance(e, BotMissingPermissions): + await ctx.send( + f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" + f"Here's what I'm missing: **{e.missing_perms}**" + ) + elif isinstance(e, CommandInvokeError): + if isinstance(e.original, ClientResponseError): + if e.original.code == 404: + await ctx.send("There does not seem to be anything matching your query.") + else: + await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") + + else: + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" + ) + raise e.original + raise e + + async def on_ready(self): + users = [] + + for member in self.bot.get_guild(Guild.id).members: # type: Member + roles = [str(r.id) for r in member.roles] # type: List[int] + + users.append({ + "avatar": member.avatar_url_as(format="png"), + "user_id": str(member.id), + "roles": roles, + "username": member.name, + "discriminator": member.discriminator + }) + + if users: + log.info(f"{len(users)} user roles to be updated") + + done = await self.send_updated_users(*users, replace_all=True) + + if any(done.values()): + embed = Embed( + title="Users updated" + ) + + for key, value in done.items(): + if value: + if key == "deleted_oauth": + key = "Deleted (OAuth)" + elif key == "deleted_jam_profiles": + key = "Deleted (Jammer Profiles)" + elif key == "deleted_responses": + key = "Deleted (Jam Form Responses)" + elif key == "jam_bans": + key = "Ex-Jammer Bans" + else: + key = key.title() + + embed.add_field( + name=key, value=str(value) + ) + + if not DEBUG_MODE: + await self.bot.get_channel(Channels.devlog).send( + embed=embed + ) + + async def on_member_update(self, before: Member, after: Member): + if ( + before.roles == after.roles + and before.name == after.name + and before.discriminator == after.discriminator + and before.avatar == after.avatar): + return + + before_role_names = [role.name for role in before.roles] # type: List[str] + after_role_names = [role.name for role in after.roles] # type: List[str] + role_ids = [str(r.id) for r in after.roles] # type: List[str] + + log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}") + + changes = await self.send_updated_users({ + "avatar": after.avatar_url_as(format="png"), + "user_id": str(after.id), + "roles": role_ids, + "username": after.name, + "discriminator": after.discriminator + }) + + log.debug(f"User {after.id} updated; changes: {changes}") + + async def on_member_join(self, member: Member): + role_ids = [str(r.id) for r in member.roles] # type: List[str] + new_roles = [] + + try: + user_objs = await self.get_user(str(member.id)) + except Exception as e: + log.exception("Failed to persist roles") + + await self.mod_log.send_log_message( + Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", + f"```py\n{e}\n```", + member.avatar_url_as(static_format="png") + ) + else: + if user_objs: + old_roles = user_objs[0].get("roles", []) + + for role in RESTORE_ROLES: + if role in old_roles: + new_roles.append(Object(int(role))) + + for role in new_roles: + if str(role) not in role_ids: + role_ids.append(str(role.id)) + + changes = await self.send_updated_users({ + "avatar": member.avatar_url_as(format="png"), + "user_id": str(member.id), + "roles": role_ids, + "username": member.name, + "discriminator": member.discriminator + }) + + log.debug(f"User {member.id} joined; changes: {changes}") + + if new_roles: + await member.add_roles( + *new_roles, + reason="Roles restored" + ) + + await self.mod_log.send_log_message( + Icons.crown_blurple, Colour.blurple(), "Roles restored", + f"Restored {len(new_roles)} roles", + member.avatar_url_as(static_format="png") + ) + + async def on_member_remove(self, member: Member): + changes = await self.send_delete_users({ + "user_id": str(member.id) + }) + + log.debug(f"User {member.id} left; changes: {changes}") + + +def setup(bot): + bot.add_cog(Events(bot)) + log.info("Cog loaded: Events") -- cgit v1.2.3 From d1bd7c4e4cc9699d3c34ab56046e17f774991e28 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 28 Jul 2019 18:54:59 +0200 Subject: Drop user updating from `events` cog. --- bot/cogs/events.py | 203 ----------------------------------------------------- 1 file changed, 203 deletions(-) diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 160791fb0..d69af365b 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -27,83 +27,6 @@ class Events: def __init__(self, bot: Bot): self.bot = bot - @property - def mod_log(self) -> ModLog: - return self.bot.get_cog("ModLog") - - async def send_updated_users(self, *users, replace_all=False): - users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) - - for chunk in chunks(users, 1000): - response = None - - try: - if replace_all: - response = await self.bot.http_session.post( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - else: - response = await self.bot.http_session.put( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - - await response.json() # We do this to ensure we got a proper response from the site - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - break # Stop right now, thank you very much - - result = {} - - if replace_all: - response = None - - try: - response = await self.bot.http_session.post( - url=URLs.site_user_complete_api, - headers={"X-API-Key": Keys.site_api} - ) - - result = await response.json() - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - - return result - - async def send_delete_users(self, *users): - try: - response = await self.bot.http_session.delete( - url=URLs.site_user_api, - json=list(users), - headers={"X-API-Key": Keys.site_api} - ) - - return await response.json() - except Exception: - log.exception(f"Failed to delete {len(users)} users") - return {} - - async def get_user(self, user_id): - response = await self.bot.http_session.get( - url=URLs.site_user_api, - params={"user_id": user_id}, - headers={"X-API-Key": Keys.site_api} - ) - - resp = await response.json() - return resp["data"] - async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None @@ -154,132 +77,6 @@ class Events: raise e.original raise e - async def on_ready(self): - users = [] - - for member in self.bot.get_guild(Guild.id).members: # type: Member - roles = [str(r.id) for r in member.roles] # type: List[int] - - users.append({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": roles, - "username": member.name, - "discriminator": member.discriminator - }) - - if users: - log.info(f"{len(users)} user roles to be updated") - - done = await self.send_updated_users(*users, replace_all=True) - - if any(done.values()): - embed = Embed( - title="Users updated" - ) - - for key, value in done.items(): - if value: - if key == "deleted_oauth": - key = "Deleted (OAuth)" - elif key == "deleted_jam_profiles": - key = "Deleted (Jammer Profiles)" - elif key == "deleted_responses": - key = "Deleted (Jam Form Responses)" - elif key == "jam_bans": - key = "Ex-Jammer Bans" - else: - key = key.title() - - embed.add_field( - name=key, value=str(value) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send( - embed=embed - ) - - async def on_member_update(self, before: Member, after: Member): - if ( - before.roles == after.roles - and before.name == after.name - and before.discriminator == after.discriminator - and before.avatar == after.avatar): - return - - before_role_names = [role.name for role in before.roles] # type: List[str] - after_role_names = [role.name for role in after.roles] # type: List[str] - role_ids = [str(r.id) for r in after.roles] # type: List[str] - - log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}") - - changes = await self.send_updated_users({ - "avatar": after.avatar_url_as(format="png"), - "user_id": str(after.id), - "roles": role_ids, - "username": after.name, - "discriminator": after.discriminator - }) - - log.debug(f"User {after.id} updated; changes: {changes}") - - async def on_member_join(self, member: Member): - role_ids = [str(r.id) for r in member.roles] # type: List[str] - new_roles = [] - - try: - user_objs = await self.get_user(str(member.id)) - except Exception as e: - log.exception("Failed to persist roles") - - await self.mod_log.send_log_message( - Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", - f"```py\n{e}\n```", - member.avatar_url_as(static_format="png") - ) - else: - if user_objs: - old_roles = user_objs[0].get("roles", []) - - for role in RESTORE_ROLES: - if role in old_roles: - new_roles.append(Object(int(role))) - - for role in new_roles: - if str(role) not in role_ids: - role_ids.append(str(role.id)) - - changes = await self.send_updated_users({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": role_ids, - "username": member.name, - "discriminator": member.discriminator - }) - - log.debug(f"User {member.id} joined; changes: {changes}") - - if new_roles: - await member.add_roles( - *new_roles, - reason="Roles restored" - ) - - await self.mod_log.send_log_message( - Icons.crown_blurple, Colour.blurple(), "Roles restored", - f"Restored {len(new_roles)} roles", - member.avatar_url_as(static_format="png") - ) - - async def on_member_remove(self, member: Member): - changes = await self.send_delete_users({ - "user_id": str(member.id) - }) - - log.debug(f"User {member.id} left; changes: {changes}") - - def setup(bot): bot.add_cog(Events(bot)) log.info("Cog loaded: Events") -- cgit v1.2.3 From a1ba380f90e370548608035ed0c32794f95a5a5b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 28 Jul 2019 18:58:24 +0200 Subject: Move error handling to more descriptive `ErrorHandler` cog. --- bot/__main__.py | 2 +- bot/cogs/error_handler.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/events.py | 82 ----------------------------------------------- 3 files changed, 79 insertions(+), 83 deletions(-) create mode 100644 bot/cogs/error_handler.py delete mode 100644 bot/cogs/events.py diff --git a/bot/__main__.py b/bot/__main__.py index 9bfd99098..4bc7d1202 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -31,7 +31,7 @@ bot.http_session = ClientSession( bot.api_client = APIClient(loop=asyncio.get_event_loop()) # Internal/debug -bot.load_extension("bot.cogs.events") +bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.modlog") diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py new file mode 100644 index 000000000..2db133372 --- /dev/null +++ b/bot/cogs/error_handler.py @@ -0,0 +1,78 @@ +import logging + +from aiohttp import ClientResponseError +from discord.ext.commands import Bot, Context +from discord.ext.commands import ( + BadArgument, + BotMissingPermissions, + CommandError, + CommandInvokeError, + CommandNotFound, + NoPrivateMessage, + UserInputError, +) + + +log = logging.getLogger(__name__) + + +class ErrorHandler: + """Handles errors emttted from commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def on_command_error(self, ctx: Context, e: CommandError): + command = ctx.command + parent = None + + if command is not None: + parent = command.parent + + if parent and command: + help_command = (self.bot.get_command("help"), parent.name, command.name) + elif command: + help_command = (self.bot.get_command("help"), command.name) + else: + help_command = (self.bot.get_command("help"),) + + if hasattr(command, "on_error"): + log.debug(f"Command {command} has a local error handler, ignoring.") + return + + if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + # Return to not raise the exception + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + elif isinstance(e, BadArgument): + await ctx.send(f"Bad argument: {e}\n") + await ctx.invoke(*help_command) + elif isinstance(e, UserInputError): + await ctx.invoke(*help_command) + elif isinstance(e, NoPrivateMessage): + await ctx.send("Sorry, this command can't be used in a private message!") + elif isinstance(e, BotMissingPermissions): + await ctx.send( + f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" + f"Here's what I'm missing: **{e.missing_perms}**" + ) + elif isinstance(e, CommandInvokeError): + if isinstance(e.original, ClientResponseError): + if e.original.code == 404: + await ctx.send("There does not seem to be anything matching your query.") + else: + await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") + + else: + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" + ) + raise e.original + raise e + + +def setup(bot: Bot): + bot.add_cog(ErrorHandler(bot)) + log.info("Cog loaded: Events") diff --git a/bot/cogs/events.py b/bot/cogs/events.py deleted file mode 100644 index d69af365b..000000000 --- a/bot/cogs/events.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging - -from aiohttp import ClientResponseError -from discord import Colour, Embed, Member, Object -from discord.ext.commands import ( - BadArgument, Bot, BotMissingPermissions, - CommandError, CommandInvokeError, CommandNotFound, - Context, NoPrivateMessage, UserInputError -) - -from bot.cogs.modlog import ModLog -from bot.constants import ( - Channels, Colours, DEBUG_MODE, - Guild, Icons, Keys, - Roles, URLs -) -from bot.utils import chunks - -log = logging.getLogger(__name__) - -RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) - - -class Events: - """No commands, just event handlers.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_command_error(self, ctx: Context, e: CommandError): - command = ctx.command - parent = None - - if command is not None: - parent = command.parent - - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) - - if hasattr(command, "on_error"): - log.debug(f"Command {command} has a local error handler, ignoring.") - return - - if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - # Return to not raise the exception - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) - elif isinstance(e, BadArgument): - await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) - elif isinstance(e, UserInputError): - await ctx.invoke(*help_command) - elif isinstance(e, NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): - await ctx.send( - f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" - f"Here's what I'm missing: **{e.missing_perms}**" - ) - elif isinstance(e, CommandInvokeError): - if isinstance(e.original, ClientResponseError): - if e.original.code == 404: - await ctx.send("There does not seem to be anything matching your query.") - else: - await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") - - else: - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original - raise e - -def setup(bot): - bot.add_cog(Events(bot)) - log.info("Cog loaded: Events") -- cgit v1.2.3 From 58dc7fb07e2f4a12f65d91641fd78ab8c10a7933 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 28 Jul 2019 19:01:07 +0200 Subject: Handle more API status codes. --- bot/cogs/error_handler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 2db133372..0bb2faf43 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -62,8 +62,12 @@ class ErrorHandler: if isinstance(e.original, ClientResponseError): if e.original.code == 404: await ctx.send("There does not seem to be anything matching your query.") + elif e.original.code == 400: + await ctx.send("According to the API, your request is malformed.") + elif 500 <= e.original.code < 600: + await ctx.send("Sorry, there seems to be an internal issue with the API.") else: - await ctx.send("BEEP BEEP UNKNOWN API ERROR!=?!??!?!?!?") + await ctx.send(f"Got an unexpected status code from the API (`{e.original.code}`).") else: await ctx.send( -- cgit v1.2.3 From 528c5749ff410cbcdd3982d30ba4d966860282ef Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 4 Aug 2019 20:22:27 +0200 Subject: Raise specific exception for non-200s. --- bot/api.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/bot/api.py b/bot/api.py index 2e1a239ba..4f4ffeff3 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,3 +1,4 @@ +import typing from urllib.parse import quote as quote_url import aiohttp @@ -5,6 +6,10 @@ import aiohttp from .constants import Keys, URLs +class ResponseCodeError(typing.NamedTuple, ValueError): + response: aiohttp.ClientResponse + + class APIClient: def __init__(self, **kwargs): auth_headers = { @@ -16,33 +21,40 @@ class APIClient: else: kwargs['headers'] = auth_headers - self.session = aiohttp.ClientSession( - **kwargs, - raise_for_status=True - ) + self.session = aiohttp.ClientSession(**kwargs) @staticmethod def _url_for(endpoint: str): return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" - async def get(self, endpoint: str, *args, **kwargs): + def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): + if should_raise and response.status_code >= 400: + raise ResponseCodeError(response=response) + + async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def patch(self, endpoint: str, *args, **kwargs): + async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def post(self, endpoint: str, *args, **kwargs): + async def post(self, endpoint: str, *args, raise_for_status: bool = True,**kwargs): async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def put(self, endpoint: str, *args, **kwargs): + async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def delete(self, endpoint: str, *args, **kwargs): + async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: if resp.status == 204: return None + + self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() -- cgit v1.2.3 From 7cb8a8181cf721c4b7539faca054994ce76a4685 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 20:36:20 +0200 Subject: Update code to make use of the new `ResponseCodeError`. --- bot/api.py | 2 +- bot/cogs/error_handler.py | 16 ++++++++++------ bot/cogs/sync/cog.py | 10 +++++----- bot/cogs/watchchannels/talentpool.py | 6 +++--- bot/cogs/watchchannels/watchchannel.py | 6 +++--- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/bot/api.py b/bot/api.py index 4f4ffeff3..935ff699f 100644 --- a/bot/api.py +++ b/bot/api.py @@ -41,7 +41,7 @@ class APIClient: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def post(self, endpoint: str, *args, raise_for_status: bool = True,**kwargs): + async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 0bb2faf43..b6ca7fccf 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,6 +1,5 @@ import logging -from aiohttp import ClientResponseError from discord.ext.commands import Bot, Context from discord.ext.commands import ( BadArgument, @@ -12,6 +11,8 @@ from discord.ext.commands import ( UserInputError, ) +from bot.api import ResponseCodeError + log = logging.getLogger(__name__) @@ -59,15 +60,18 @@ class ErrorHandler: f"Here's what I'm missing: **{e.missing_perms}**" ) elif isinstance(e, CommandInvokeError): - if isinstance(e.original, ClientResponseError): - if e.original.code == 404: + if isinstance(e.original, ResponseCodeError): + if e.original.response.status_code == 404: await ctx.send("There does not seem to be anything matching your query.") - elif e.original.code == 400: + elif e.original.response.status_code == 400: await ctx.send("According to the API, your request is malformed.") - elif 500 <= e.original.code < 600: + elif 500 <= e.original.response.status_code < 600: await ctx.send("Sorry, there seems to be an internal issue with the API.") else: - await ctx.send(f"Got an unexpected status code from the API (`{e.original.code}`).") + await ctx.send( + "Got an unexpected status code from the " + f"API (`{e.original.response.code}`)." + ) else: await ctx.send( diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ab591ebf8..9e71f749d 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -1,12 +1,12 @@ import logging from typing import Callable, Iterable -import aiohttp from discord import Guild, Member, Role from discord.ext import commands from discord.ext.commands import Bot from bot import constants +from bot.api import ResponseCodeError from bot.cogs.sync import syncers log = logging.getLogger(__name__) @@ -94,9 +94,9 @@ class Sync: # fields that may have changed since the last time we've seen them. await self.bot.api_client.put('bot/users/' + str(member.id), json=packed) - except aiohttp.client_exceptions.ClientResponseError as e: + except ResponseCodeError as e: # If we didn't get 404, something else broke - propagate it up. - if e.status != 404: + if e.response.status_code != 404: raise got_error = True # yikes @@ -137,8 +137,8 @@ class Sync: 'roles': sorted(role.id for role in after.roles) } ) - except aiohttp.client_exceptions.ClientResponseError as e: - if e.status != 404: + except ResponseCodeError as e: + if e.response.status_code != 404: raise log.warning( diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 6fbe2bc03..44bf6371b 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -3,10 +3,10 @@ import textwrap from collections import ChainMap from typing import Union -from aiohttp.client_exceptions import ClientResponseError from discord import Color, Embed, Member, User from discord.ext.commands import Context, group +from bot.api import ResponseCodeError from bot.constants import Channels, Guild, Roles, Webhooks from bot.decorators import with_role from bot.pagination import LinePaginator @@ -170,8 +170,8 @@ class TalentPool(WatchChannel): """ try: nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ClientResponseError as e: - if e.status == 404: + except ResponseCodeError as e: + if e.response.status_code == 404: self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index fe6d6bb6e..3a24e3f21 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -8,11 +8,11 @@ from collections import defaultdict, deque from dataclasses import dataclass from typing import Optional -import aiohttp import discord from discord import Color, Embed, Message, Object, errors from discord.ext.commands import BadArgument, Bot, Context +from bot.api import ResponseCodeError from bot.cogs.modlog import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator @@ -157,8 +157,8 @@ class WatchChannel(ABC): """ try: data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) - except aiohttp.ClientResponseError as e: - self.log.exception(f"Failed to fetch the watched users from the API", exc_info=e) + except ResponseCodeError as err: + self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err) return False self.watched_users = defaultdict(dict) -- cgit v1.2.3 From 0e73db0aab6845956bf40493a93c07c4d81e81fe Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 21:11:14 +0200 Subject: Finalize error handling. --- bot/api.py | 8 ++++---- bot/cogs/error_handler.py | 13 ++++++++----- bot/cogs/sync/cog.py | 4 ++-- bot/cogs/watchchannels/talentpool.py | 2 +- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/bot/api.py b/bot/api.py index 935ff699f..e926a262e 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,4 +1,3 @@ -import typing from urllib.parse import quote as quote_url import aiohttp @@ -6,8 +5,9 @@ import aiohttp from .constants import Keys, URLs -class ResponseCodeError(typing.NamedTuple, ValueError): - response: aiohttp.ClientResponse +class ResponseCodeError(ValueError): + def __init__(self, response: aiohttp.ClientResponse): + self.response = response class APIClient: @@ -28,7 +28,7 @@ class APIClient: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): - if should_raise and response.status_code >= 400: + if should_raise and response.status >= 400: raise ResponseCodeError(response=response) async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index b6ca7fccf..25aa177e1 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,6 +1,5 @@ import logging -from discord.ext.commands import Bot, Context from discord.ext.commands import ( BadArgument, BotMissingPermissions, @@ -10,6 +9,7 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError, ) +from discord.ext.commands import Bot, Context from bot.api import ResponseCodeError @@ -61,11 +61,13 @@ class ErrorHandler: ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): - if e.original.response.status_code == 404: + if e.original.response.status == 404: await ctx.send("There does not seem to be anything matching your query.") - elif e.original.response.status_code == 400: + elif e.original.response.status == 400: + content = await e.original.resopnse.json() + log.debug("API gave bad request on command. Response: %r.", content) await ctx.send("According to the API, your request is malformed.") - elif 500 <= e.original.response.status_code < 600: + elif 500 <= e.original.response.status < 600: await ctx.send("Sorry, there seems to be an internal issue with the API.") else: await ctx.send( @@ -78,7 +80,8 @@ class ErrorHandler: f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" ) raise e.original - raise e + else: + raise e def setup(bot: Bot): diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 9e71f749d..222c1668b 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -96,7 +96,7 @@ class Sync: except ResponseCodeError as e: # If we didn't get 404, something else broke - propagate it up. - if e.response.status_code != 404: + if e.response.status != 404: raise got_error = True # yikes @@ -138,7 +138,7 @@ class Sync: } ) except ResponseCodeError as e: - if e.response.status_code != 404: + if e.response.status != 404: raise log.warning( diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 44bf6371b..47d207d05 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -171,7 +171,7 @@ class TalentPool(WatchChannel): try: nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") except ResponseCodeError as e: - if e.response.status_code == 404: + if e.response.status == 404: self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return -- cgit v1.2.3 From bea4e8d24122a3ef80ba000d11a6bd68dad267a5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 21:37:12 +0200 Subject: Suppress response code errors on tag reinvoke. --- bot/cogs/error_handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 25aa177e1..62e2e2d7b 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -1,3 +1,4 @@ +import contextlib import logging from discord.ext.commands import ( @@ -46,7 +47,8 @@ class ErrorHandler: ctx.invoked_from_error_handler = True # Return to not raise the exception - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + with contextlib.suppress(ResponseCodeError): + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From f6c73864c5851235fa26689f614bd6af0d347c9e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 21:39:01 +0200 Subject: Fix typo in bot/cogs/error_handler.py. Originally authored by @MarkKoz. Co-Authored-By: Mark --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 62e2e2d7b..5033c95cf 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) class ErrorHandler: - """Handles errors emttted from commands.""" + """Handles errors emitted from commands.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From 74f4162d2859333909c74b038ee3a1dcaee65c9c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 22:01:30 +0200 Subject: Be more helpful. --- bot/cogs/error_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5033c95cf..de880fcf5 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -53,6 +53,7 @@ class ErrorHandler: await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) elif isinstance(e, UserInputError): + await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) elif isinstance(e, NoPrivateMessage): await ctx.send("Sorry, this command can't be used in a private message!") -- cgit v1.2.3 From 7083e892efe1ba0cb33537dc182b593abdad272f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 5 Aug 2019 22:19:30 +0200 Subject: Blame Mark. --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index de880fcf5..2063df09d 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -67,7 +67,7 @@ class ErrorHandler: if e.original.response.status == 404: await ctx.send("There does not seem to be anything matching your query.") elif e.original.response.status == 400: - content = await e.original.resopnse.json() + content = await e.original.response.json() log.debug("API gave bad request on command. Response: %r.", content) await ctx.send("According to the API, your request is malformed.") elif 500 <= e.original.response.status < 600: -- cgit v1.2.3 From b3b20c958aae7110293fe4338b3e001b298ab1bf Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 14 Aug 2019 23:02:22 +0800 Subject: Remove fun cog --- bot/__main__.py | 1 - bot/cogs/fun.py | 53 ----------------------------------------------------- 2 files changed, 54 deletions(-) delete mode 100644 bot/cogs/fun.py diff --git a/bot/__main__.py b/bot/__main__.py index ead6d287a..f037a1475 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -64,7 +64,6 @@ bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.free") -bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") diff --git a/bot/cogs/fun.py b/bot/cogs/fun.py deleted file mode 100644 index 57fa7cb1c..000000000 --- a/bot/cogs/fun.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging - -from discord import Message -from discord.ext.commands import Bot - -from bot.constants import Channels - -RESPONSES = { - "_pokes {us}_": "_Pokes {them}_", - "_eats {us}_": "_Tastes slimy and snake-like_", - "_pets {us}_": "_Purrs_" -} - -log = logging.getLogger(__name__) - - -class Fun: - """ - Fun, entirely useless stuff - """ - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_ready(self): - keys = list(RESPONSES.keys()) - - for key in keys: - changed_key = key.replace("{us}", self.bot.user.mention) - - if key != changed_key: - RESPONSES[changed_key] = RESPONSES[key] - del RESPONSES[key] - - async def on_message(self, message: Message): - if message.channel.id != Channels.bot: - return - - content = message.content - - if content and content[0] == "*" and content[-1] == "*": - content = f"_{content[1:-1]}_" - - response = RESPONSES.get(content) - - if response: - log.debug(f"{message.author} said '{message.clean_content}'. Responding with '{response}'.") - await message.channel.send(response.format(them=message.author.mention)) - - -def setup(bot): - bot.add_cog(Fun(bot)) - log.info("Cog loaded: Fun") -- cgit v1.2.3 From c6ffdbc7072ab0910e26966712da905bc85400db Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 14 Aug 2019 22:12:09 +0200 Subject: Add a site logging handler. --- bot/__main__.py | 5 ++-- bot/api.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 4bc7d1202..23bfe03bf 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -6,11 +6,11 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Game from discord.ext.commands import Bot, when_mentioned_or -from bot.api import APIClient +from bot.api import APIClient, APILoggingHandler from bot.constants import Bot as BotConfig, DEBUG_MODE -log = logging.getLogger(__name__) +log = logging.getLogger('bot') bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), @@ -29,6 +29,7 @@ bot.http_session = ClientSession( ) ) bot.api_client = APIClient(loop=asyncio.get_event_loop()) +log.addHandler(APILoggingHandler(bot.api_client)) # Internal/debug bot.load_extension("bot.cogs.error_handler") diff --git a/bot/api.py b/bot/api.py index e926a262e..6ac7ddb95 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,9 +1,13 @@ +import asyncio +import logging from urllib.parse import quote as quote_url import aiohttp from .constants import Keys, URLs +log = logging.getLogger(__name__) + class ResponseCodeError(ValueError): def __init__(self, response: aiohttp.ClientResponse): @@ -58,3 +62,76 @@ class APIClient: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() + + +def loop_is_running() -> bool: + # asyncio does not have a way to say "call this when the event + # loop is running", see e.g. `callWhenRunning` from twisted. + + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + +class APILoggingHandler(logging.StreamHandler): + def __init__(self, client: APIClient): + logging.StreamHandler.__init__(self) + self.client = client + + # internal batch of shipoff tasks that must not be scheduled + # on the event loop yet - scheduled when the event loop is ready. + self.queue = [] + + async def ship_off(self, payload: dict): + try: + await self.client.post('logs', json=payload) + except ResponseCodeError as err: + log.warning( + "Cannot send logging record to the site, got code %d.", + err.response.status, + extra={'via_handler': True} + ) + except Exception as err: + log.warning( + "Cannot send logging record to the site: %r", + err, + extra={'via_handler': True} + ) + + def emit(self, record: logging.LogRecord): + # Ignore logging messages which are sent by this logging handler + # itself. This is required because if we were to not ignore + # messages emitted by this handler, we would infinitely recurse + # back down into this logging handler, making the reactor run + # like crazy, and eventually OOM something. Let's not do that... + if not record.__dict__.get('via_handler'): + payload = { + 'application': 'bot', + 'logger_name': record.name, + 'level': record.levelname.lower(), + 'module': record.module, + 'line': record.lineno, + 'message': self.format(record) + } + + task = self.ship_off(payload) + if not loop_is_running(): + self.queue.append(task) + else: + asyncio.create_task(task) + self.schedule_queued_tasks() + + def schedule_queued_tasks(self): + for task in self.queue: + asyncio.create_task(task) + + if self.queue: + log.debug( + "Scheduled %d pending logging tasks.", + len(self.queue), + extra={'via_handler': True} + ) + + self.queue.clear() -- cgit v1.2.3 From 99b48e387e99e66669c0e554e122414aa8534f14 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 15 Aug 2019 20:26:01 +0200 Subject: Adding support for storing role positions and deleting roles --- bot/cogs/sync/cog.py | 52 +++++++++++++++++++++++++++++++----------------- bot/cogs/sync/syncers.py | 26 +++++++++++++++--------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 222c1668b..30b66c74e 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -30,42 +30,48 @@ class Sync: self.bot = bot async def on_ready(self): + """Syncs the roles/users of the guild with the database.""" guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: for syncer in self.ON_READY_SYNCERS: syncer_name = syncer.__name__[5:] # drop off `sync_` log.info("Starting `%s` syncer.", syncer_name) - total_created, total_updated = await syncer(self.bot, guild) - log.info( - "`%s` syncer finished, created `%d`, updated `%d`.", - syncer_name, total_created, total_updated - ) + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + if total_deleted is None: + log.info( + "`%s` syncer finished, created `%d`, updated `%d`.", + syncer_name, total_created, total_updated + ) + else: + log.info( + "`%s` syncer finished, created `%d`, updated `%d`, `%d` deleted.", + syncer_name, total_created, total_updated, total_deleted + ) async def on_guild_role_create(self, role: Role): + """Adds newly create role to the database table over the API.""" await self.bot.api_client.post( 'bot/roles', json={ 'colour': role.colour.value, 'id': role.id, 'name': role.name, - 'permissions': role.permissions.value + 'permissions': role.permissions.value, + 'position': role.position, } ) async def on_guild_role_delete(self, role: Role): - log.warning( - ( - "Attempted to delete role `%s` (`%d`), but role deletion " - "is currently not implementeed." - ), - role.name, role.id - ) + """Deletes role from the database when it's deleted from the guild.""" + await self.bot.api_client.delete('bot/roles/' + str(role.id)) async def on_guild_role_update(self, before: Role, after: Role): + """Syncs role with the database if any of the stored attributes were updated.""" if ( before.name != after.name or before.colour != after.colour or before.permissions != after.permissions + or before.position != after.position ): await self.bot.api_client.put( 'bot/roles/' + str(after.id), @@ -73,11 +79,19 @@ class Sync: 'colour': after.colour.value, 'id': after.id, 'name': after.name, - 'permissions': after.permissions.value + 'permissions': after.permissions.value, + 'position': after.position, } ) async def on_member_join(self, member: Member): + """ + Adds a new user or updates existing user to the database when a member joins the guild. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ packed = { 'avatar_hash': member.avatar, 'discriminator': int(member.discriminator), @@ -106,19 +120,21 @@ class Sync: await self.bot.api_client.post('bot/users', json=packed) async def on_member_leave(self, member: Member): + """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( 'bot/users/' + str(member.id), json={ 'avatar_hash': member.avatar, 'discriminator': int(member.discriminator), 'id': member.id, - 'in_guild': True, + 'in_guild': False, 'name': member.name, 'roles': sorted(role.id for role in member.roles) } ) async def on_member_update(self, before: Member, after: Member): + """Updates the user information if any of relevant attributes have changed.""" if ( before.name != after.name or before.avatar != after.avatar @@ -157,11 +173,11 @@ class Sync: """Manually synchronize the guild's roles with the roles on the site.""" initial_response = await ctx.send("📊 Synchronizing roles.") - total_created, total_updated = await syncers.sync_roles(self.bot, ctx.guild) + total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild) await initial_response.edit( content=( f"👌 Role synchronization complete, created **{total_created}** " - f"and updated **{total_created}** roles." + f", updated **{total_created}** roles, and deleted **{total_deleted}** roles." ) ) @@ -171,7 +187,7 @@ class Sync: """Manually synchronize the guild's users with the users on the site.""" initial_response = await ctx.send("📊 Synchronizing users.") - total_created, total_updated = await syncers.sync_users(self.bot, ctx.guild) + total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild) await initial_response.edit( content=( f"👌 User synchronization complete, created **{total_created}** " diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 3037d2e31..8f3f71440 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -6,13 +6,13 @@ from discord.ext.commands import Bot # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. -Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) +Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) def get_roles_for_sync( guild_roles: Set[Role], api_roles: Set[Role] -) -> Tuple[Set[Role], Set[Role]]: +) -> Tuple[Set[Role], Set[Role], Set[Role]]: """ Determine which roles should be created or updated on the site. @@ -36,12 +36,14 @@ def get_roles_for_sync( guild_role_ids = {role.id for role in guild_roles} api_role_ids = {role.id for role in api_roles} new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids # New roles are those which are on the cached guild but not on the # API guild, going by the role ID. We need to send them in for creation. roles_to_create = {role for role in guild_roles if role.id in new_role_ids} roles_to_update = guild_roles - api_roles - roles_to_create - return roles_to_create, roles_to_update + roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids} + return roles_to_create, roles_to_update, roles_to_delete async def sync_roles(bot: Bot, guild: Guild): @@ -71,11 +73,12 @@ async def sync_roles(bot: Bot, guild: Guild): guild_roles = { Role( id=role.id, name=role.name, - colour=role.colour.value, permissions=role.permissions.value + colour=role.colour.value, permissions=role.permissions.value, + position=role.position, ) for role in guild.roles } - roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles) + roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles) for role in roles_to_create: await bot.api_client.post( @@ -84,7 +87,8 @@ async def sync_roles(bot: Bot, guild: Guild): 'id': role.id, 'name': role.name, 'colour': role.colour, - 'permissions': role.permissions + 'permissions': role.permissions, + 'position': role.position, } ) @@ -95,11 +99,15 @@ async def sync_roles(bot: Bot, guild: Guild): 'id': role.id, 'name': role.name, 'colour': role.colour, - 'permissions': role.permissions + 'permissions': role.permissions, + 'position': role.position, } ) - return (len(roles_to_create), len(roles_to_update)) + for role in roles_to_delete: + await bot.api_client.delete('bot/roles/' + str(role.id)) + + return len(roles_to_create), len(roles_to_update), len(roles_to_delete) def get_users_for_sync( @@ -224,4 +232,4 @@ async def sync_users(bot: Bot, guild: Guild): } ) - return (len(users_to_create), len(users_to_update)) + return len(users_to_create), len(users_to_update), None -- cgit v1.2.3 From bb6e9a050d14c375243fecb15f8fdffe7ad67787 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 15 Aug 2019 20:52:29 +0200 Subject: Updating role sync tests for position and adding tests for deletion detection --- tests/cogs/sync/test_roles.py | 81 ++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py index 18682f39f..c561ba447 100644 --- a/tests/cogs/sync/test_roles.py +++ b/tests/cogs/sync/test_roles.py @@ -2,63 +2,102 @@ from bot.cogs.sync.syncers import Role, get_roles_for_sync def test_get_roles_for_sync_empty_return_for_equal_roles(): - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - assert get_roles_for_sync(guild_roles, api_roles) == (set(), set()) + assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8)} + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} assert get_roles_for_sync(guild_roles, api_roles) == ( set(), - guild_roles + guild_roles, + set(), ) def test_get_roles_only_returns_roles_that_require_update(): api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) } guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) } assert get_roles_for_sync(guild_roles, api_roles) == ( set(), - {Role(id=41, name='new name', colour=35, permissions=0x8)}, + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), ) def test_get_roles_returns_new_roles_in_first_tuple_element(): api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8), + Role(id=41, name='name', colour=35, permissions=0x8, position=1), } guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) } assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0)}, - set() + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), ) def test_get_roles_returns_roles_to_update_and_new_roles(): api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8), + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + + +def test_get_roles_returns_roles_to_delete(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + + +def test_get_roles_returns_roles_to_delete_update_and_new_roles(): + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), } guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16), - Role(id=53, name='other role', colour=55, permissions=0) + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), } assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0)}, - {Role(id=41, name='new name', colour=40, permissions=0x16)} + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, ) -- cgit v1.2.3 From 64751a5895207b9511d07abef75084d4dda04a75 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 16 Aug 2019 10:38:04 +0200 Subject: Adding missing function annotations --- bot/cogs/sync/cog.py | 24 ++++++++++++------------ bot/cogs/sync/syncers.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 30b66c74e..999cb1e09 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,7 +3,7 @@ from typing import Callable, Iterable from discord import Guild, Member, Role from discord.ext import commands -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Context from bot import constants from bot.api import ResponseCodeError @@ -26,10 +26,10 @@ class Sync: syncers.sync_users ) - def __init__(self, bot): + def __init__(self, bot: Bot) -> None: self.bot = bot - async def on_ready(self): + async def on_ready(self) -> None: """Syncs the roles/users of the guild with the database.""" guild = self.bot.get_guild(self.SYNC_SERVER_ID) if guild is not None: @@ -48,7 +48,7 @@ class Sync: syncer_name, total_created, total_updated, total_deleted ) - async def on_guild_role_create(self, role: Role): + async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" await self.bot.api_client.post( 'bot/roles', @@ -61,11 +61,11 @@ class Sync: } ) - async def on_guild_role_delete(self, role: Role): + async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" await self.bot.api_client.delete('bot/roles/' + str(role.id)) - async def on_guild_role_update(self, before: Role, after: Role): + async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" if ( before.name != after.name @@ -84,7 +84,7 @@ class Sync: } ) - async def on_member_join(self, member: Member): + async def on_member_join(self, member: Member) -> None: """ Adds a new user or updates existing user to the database when a member joins the guild. @@ -119,7 +119,7 @@ class Sync: # If we got `404`, the user is new. Create them. await self.bot.api_client.post('bot/users', json=packed) - async def on_member_leave(self, member: Member): + async def on_member_leave(self, member: Member) -> None: """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( 'bot/users/' + str(member.id), @@ -133,7 +133,7 @@ class Sync: } ) - async def on_member_update(self, before: Member, after: Member): + async def on_member_update(self, before: Member, after: Member) -> None: """Updates the user information if any of relevant attributes have changed.""" if ( before.name != after.name @@ -164,12 +164,12 @@ class Sync: @commands.group(name='sync') @commands.has_permissions(administrator=True) - async def sync_group(self, ctx): + async def sync_group(self, ctx: Context) -> None: """Run synchronizations between the bot and site manually.""" @sync_group.command(name='roles') @commands.has_permissions(administrator=True) - async def sync_roles_command(self, ctx): + async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" initial_response = await ctx.send("📊 Synchronizing roles.") @@ -183,7 +183,7 @@ class Sync: @sync_group.command(name='users') @commands.has_permissions(administrator=True) - async def sync_users_command(self, ctx): + async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" initial_response = await ctx.send("📊 Synchronizing users.") diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 8f3f71440..23dd09d43 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -46,7 +46,7 @@ def get_roles_for_sync( return roles_to_create, roles_to_update, roles_to_delete -async def sync_roles(bot: Bot, guild: Guild): +async def sync_roles(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], Set[Role]]: """ Synchronize roles found on the given `guild` with the ones on the API. @@ -164,7 +164,7 @@ def get_users_for_sync( return users_to_create, users_to_update -async def sync_users(bot: Bot, guild: Guild): +async def sync_users(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], None]: """ Synchronize users found on the given `guild` with the ones on the API. -- cgit v1.2.3 From bafbcc9c07e2e63702ff0c2a725913ce0f7ab6d5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 16 Aug 2019 10:41:10 +0200 Subject: Kaizen: Change all string concats to f-strings --- bot/cogs/sync/cog.py | 8 ++++---- bot/cogs/sync/syncers.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 999cb1e09..a17d712d5 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -63,7 +63,7 @@ class Sync: async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" - await self.bot.api_client.delete('bot/roles/' + str(role.id)) + await self.bot.api_client.delete(f'bot/roles/{role.id}') async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" @@ -74,7 +74,7 @@ class Sync: or before.position != after.position ): await self.bot.api_client.put( - 'bot/roles/' + str(after.id), + f'bot/roles/{after.id}', json={ 'colour': after.colour.value, 'id': after.id, @@ -106,7 +106,7 @@ class Sync: try: # First try an update of the user to set the `in_guild` field and other # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put('bot/users/' + str(member.id), json=packed) + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) except ResponseCodeError as e: # If we didn't get 404, something else broke - propagate it up. @@ -122,7 +122,7 @@ class Sync: async def on_member_leave(self, member: Member) -> None: """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( - 'bot/users/' + str(member.id), + f'bot/users/{member.id}', json={ 'avatar_hash': member.avatar, 'discriminator': int(member.discriminator), diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 23dd09d43..9f6dc997d 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -94,7 +94,7 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], Set[ for role in roles_to_update: await bot.api_client.put( - 'bot/roles/' + str(role.id), + f'bot/roles/{role.id}', json={ 'id': role.id, 'name': role.name, @@ -105,7 +105,7 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], Set[ ) for role in roles_to_delete: - await bot.api_client.delete('bot/roles/' + str(role.id)) + await bot.api_client.delete(f'bot/roles/{role.id}') return len(roles_to_create), len(roles_to_update), len(roles_to_delete) @@ -221,7 +221,7 @@ async def sync_users(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], None for user in users_to_update: await bot.api_client.put( - 'bot/users/' + str(user.id), + f'bot/users/{user.id}', json={ 'avatar_hash': user.avatar_hash, 'discriminator': user.discriminator, -- cgit v1.2.3 From c500beaa57c8f396e3f8b7fa6fa3fbbad89c81f2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 16 Aug 2019 10:54:27 +0200 Subject: Incorporating Mark's feedback on return annotations --- bot/cogs/sync/syncers.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 9f6dc997d..414c24adb 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -24,13 +24,15 @@ def get_roles_for_sync( Roles that were retrieved from the API at startup. Returns: - Tuple[Set[Role], Set[Role]]: - A tuple with two elements. The first element represents + Tuple[Set[Role], Set[Role]. Set[Role]]: + A tuple with three elements. The first element represents roles to be created on the site, meaning that they were present on the cached guild but not on the API. The second element represents roles to be updated, meaning they were present on both the cached guild and the API but non-ID - fields have changed inbetween. + fields have changed inbetween. The third represents roles + to be deleted on the site, meaning the roles are present on + the API but not in the cached guild. """ guild_role_ids = {role.id for role in guild_roles} @@ -46,7 +48,7 @@ def get_roles_for_sync( return roles_to_create, roles_to_update, roles_to_delete -async def sync_roles(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], Set[Role]]: +async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: """ Synchronize roles found on the given `guild` with the ones on the API. @@ -59,9 +61,10 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], Set[ to synchronize roles with. Returns: - Tuple[int, int]: - A tuple with two integers representing how many roles were created - (element `0`) and how many roles were updated (element `1`). + Tuple[int, int, int]: + A tuple with three integers representing how many roles were created + (element `0`) , how many roles were updated (element `1`), and how many + roles were deleted (element `2`) on the API. """ roles = await bot.api_client.get('bot/roles') @@ -164,7 +167,7 @@ def get_users_for_sync( return users_to_create, users_to_update -async def sync_users(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], None]: +async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: """ Synchronize users found on the given `guild` with the ones on the API. @@ -178,9 +181,10 @@ async def sync_users(bot: Bot, guild: Guild) -> Tuple[Set[Role], Set[Role], None to synchronize roles with. Returns: - Tuple[int, int]: - A tuple with two integers representing how many users were created - (element `0`) and how many users were updated (element `1`). + Tuple[int, int, None]: + A tuple with two integers, representing how many users were created + (element `0`) and how many users were updated (element `1`), and `None` + to indicate that a user sync never deletes entries from the API. """ current_users = await bot.api_client.get('bot/users') -- cgit v1.2.3 From e214b75a79ee87a9ea79c7bb9d97b25e1380a7d0 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 23 Aug 2019 10:37:11 +0200 Subject: Changing logging interpolation style to f-strings --- bot/cogs/sync/cog.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index a17d712d5..79177b69e 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -39,13 +39,12 @@ class Sync: total_created, total_updated, total_deleted = await syncer(self.bot, guild) if total_deleted is None: log.info( - "`%s` syncer finished, created `%d`, updated `%d`.", - syncer_name, total_created, total_updated + f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`." ) else: log.info( - "`%s` syncer finished, created `%d`, updated `%d`, `%d` deleted.", - syncer_name, total_created, total_updated, total_deleted + f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, " + f"deleted `{total_deleted}`." ) async def on_guild_role_create(self, role: Role) -> None: -- cgit v1.2.3 From 789e296c411c738abe6ce5b87d61127c89e9f9e9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 23 Aug 2019 17:10:08 +0200 Subject: Changing deleted messages log to point at staff-subdomain --- config-default.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 2f5dcf5dc..e8ad1d572 100644 --- a/config-default.yml +++ b/config-default.yml @@ -237,6 +237,7 @@ urls: site: &DOMAIN "pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] + site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] @@ -249,7 +250,7 @@ urls: site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] - site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"] + site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] -- cgit v1.2.3 From b5e70f85eae4b0a5a168af3d37177114677ca641 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 26 Aug 2019 16:39:06 +0200 Subject: Do not send log messages below DEBUG to the site. --- bot/api.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/api.py b/bot/api.py index 6ac7ddb95..cd19896e1 100644 --- a/bot/api.py +++ b/bot/api.py @@ -101,12 +101,20 @@ class APILoggingHandler(logging.StreamHandler): ) def emit(self, record: logging.LogRecord): - # Ignore logging messages which are sent by this logging handler - # itself. This is required because if we were to not ignore - # messages emitted by this handler, we would infinitely recurse - # back down into this logging handler, making the reactor run - # like crazy, and eventually OOM something. Let's not do that... - if not record.__dict__.get('via_handler'): + # Two checks are performed here: + if ( + # 1. Do not log anything below `DEBUG`. This is only applicable + # for the monkeypatched `TRACE` logging level, which has a + # lower numeric value than `DEBUG`. + record.levelno > logging.DEBUG + # 2. Ignore logging messages which are sent by this logging + # handler itself. This is required because if we were to + # not ignore messages emitted by this handler, we would + # infinitely recurse back down into this logging handler, + # making the reactor run like crazy, and eventually OOM + # something. Let's not do that... + and not record.__dict__.get('via_handler') + ): payload = { 'application': 'bot', 'logger_name': record.name, -- cgit v1.2.3 From 05dddcaf250e8ca6428bee26d64f6d9b8d4cbb98 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Sep 2019 10:55:07 -0400 Subject: Update contrib doc for allowing edits from maintainers --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6648ce1f0..a0a1200ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Note that contributions may be rejected on the basis of a contributor failing to 1. **No force-pushes** or modifying the Git history in any way. 2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! + * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. 3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html). * Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint. * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors. @@ -100,6 +101,8 @@ Github [has introduced a new PR feature](https://github.blog/2019-02-14-introduc This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. +As stated earlier, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. + ## Footnotes This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md). -- cgit v1.2.3 From 9d73153c5f8428e8e27ed28042e0a69312d70c30 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Sep 2019 13:34:01 -0400 Subject: Use pipenv lint script for pre-commit hook --- .pre-commit-config.yaml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4776bc63b..860357868 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,10 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 +- repo: local hooks: - - id: flake8 - additional_dependencies: [ - "flake8-bugbear", - "flake8-import-order", - "flake8-tidy-imports", - "flake8-todo", - "flake8-string-format" - ] \ No newline at end of file + - id: flake8 + name: Flake8 + description: This hook runs flake8 within our project's pipenv environment. + entry: pipenv run lint + language: python + types: [python] + require_serial: true \ No newline at end of file -- cgit v1.2.3 From db7ab80b825a7749b3c503de6f9c1605a7d2c76c Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 3 Sep 2019 14:03:11 -0400 Subject: Remove pydocstyle pinning now that flake8-docstrings is fixed --- Pipfile | 14 +-- Pipfile.lock | 277 ++++++++++++++++++++++++++++++----------------------------- 2 files changed, 146 insertions(+), 145 deletions(-) diff --git a/Pipfile b/Pipfile index e2ad73ef3..2e56a3d7b 100644 --- a/Pipfile +++ b/Pipfile @@ -21,15 +21,15 @@ dateparser = "*" urllib3 = ">=1.24.2,<1.25" [dev-packages] -"flake8" = ">=3.6" -"flake8-bugbear" = "*" -"flake8-import-order" = "*" -"flake8-tidy-imports" = "*" -"flake8-todo" = "*" -"flake8-string-format" = "*" +flake8 = "~=3.7" +flake8-bugbear = "~=19.8" +flake8-import-order = "~=0.18" +flake8-string-format = "~=0.2" +flake8-tidy-imports = "~=2.0" +flake8-todo = "~=0.7" +pre-commit = "~=1.18" safety = "*" dodgy = "*" -pre-commit = "*" pytest = "*" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index e2585756f..6b91ff8aa 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ad3b645e777f7b21a2bfb472e182361f904ae5f1f41df59300c4c68c89bd2fd1" + "sha256": "61607e940ea00e1197900c04fe1298e048d1f415db2f1a2a3a157406c6ea2b0c" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:47b12535897117b9876db2e2c0506b6c89bd4dbd90617cd8b20163d4196137ed", - "sha256:821ee9f652ba472919ebffdc37c661fc740c24309a2291b37ac8a160b4003ce6" + "sha256:c1424962ef9e28fbda840d76425cdd139605e480a4d68164303cda8d356ba9de", + "sha256:dcfe9c11af2ab9ff6c1c5a366d094c2a7542bab534d98a4aea29518672c9d7ac" ], "index": "pypi", - "version": "==5.5.3" + "version": "==6.1.0" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:038bd43d68f8e77bf79c7cc362da9df5ca6497c23c3bf20ee43ce1622448ef8a", - "sha256:f36be480de4009ddb621a8795c52f0c146813799f56d79e126dfa60e13e41dd9" + "sha256:472734ab3cf18001fb8cedb38ee13008292230a461b6482dbdf65590441ce32c", + "sha256:4b6b2b43616b7a6b353ecf9896ae29ac2f74a38c4c53bfe73824ac2807faca5d" ], - "version": "==2.5.5" + "version": "==2.7.4" }, "alabaster": { "hashes": [ @@ -97,11 +97,11 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858", - "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348", - "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718" + "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612", + "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b", + "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469" ], - "version": "==4.7.1" + "version": "==4.8.0" }, "certifi": { "hashes": [ @@ -160,11 +160,11 @@ }, "deepdiff": { "hashes": [ - "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56", - "sha256:856966b80109df002a1ee406ba21cd66e64746167b2ea8f5353d692762326ac9" + "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", + "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127" ], "index": "pypi", - "version": "==4.0.6" + "version": "==4.0.7" }, "discord-py": { "editable": true, @@ -176,11 +176,11 @@ }, "docutils": { "hashes": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" ], - "version": "==0.14" + "version": "==0.15.2" }, "fuzzywuzzy": { "hashes": [ @@ -227,33 +227,31 @@ }, "lxml": { "hashes": [ - "sha256:06c7616601430aa140a69f97e3116308fffe0848f543b639a5ec2e8920ae72fd", - "sha256:177202792f9842374a8077735c69c41a4282183f7851443d2beb8ee310720819", - "sha256:19317ad721ceb9e39847d11131903931e2794e447d4751ebb0d9236f1b349ff2", - "sha256:36d206e62f3e5dbaafd4ec692b67157e271f5da7fd925fda8515da675eace50d", - "sha256:387115b066c797c85f9861a9613abf50046a15aac16759bc92d04f94acfad082", - "sha256:3ce1c49d4b4a7bc75fb12acb3a6247bb7a91fe420542e6d671ba9187d12a12c2", - "sha256:4d2a5a7d6b0dbb8c37dab66a8ce09a8761409c044017721c21718659fa3365a1", - "sha256:58d0a1b33364d1253a88d18df6c0b2676a1746d27c969dc9e32d143a3701dda5", - "sha256:62a651c618b846b88fdcae0533ec23f185bb322d6c1845733f3123e8980c1d1b", - "sha256:69ff21064e7debc9b1b1e2eee8c2d686d042d4257186d70b338206a80c5bc5ea", - "sha256:7060453eba9ba59d821625c6af6a266bd68277dce6577f754d1eb9116c094266", - "sha256:7d26b36a9c4bce53b9cfe42e67849ae3c5c23558bc08363e53ffd6d94f4ff4d2", - "sha256:83b427ad2bfa0b9705e02a83d8d607d2c2f01889eb138168e462a3a052c42368", - "sha256:923d03c84534078386cf50193057aae98fa94cace8ea7580b74754493fda73ad", - "sha256:b773715609649a1a180025213f67ffdeb5a4878c784293ada300ee95a1f3257b", - "sha256:baff149c174e9108d4a2fee192c496711be85534eab63adb122f93e70aa35431", - "sha256:bca9d118b1014b4c2d19319b10a3ebed508ff649396ce1855e1c96528d9b2fa9", - "sha256:ce580c28845581535dc6000fc7c35fdadf8bea7ccb57d6321b044508e9ba0685", - "sha256:d34923a569e70224d88e6682490e24c842907ba2c948c5fd26185413cbe0cd96", - "sha256:dd9f0e531a049d8b35ec5e6c68a37f1ba6ec3a591415e6804cbdf652793d15d7", - "sha256:ecb805cbfe9102f3fd3d2ef16dfe5ae9e2d7a7dfbba92f4ff1e16ac9784dbfb0", - "sha256:ede9aad2197a0202caff35d417b671f5f91a3631477441076082a17c94edd846", - "sha256:ef2d1fc370400e0aa755aab0b20cf4f1d0e934e7fd5244f3dd4869078e4942b9", - "sha256:f2fec194a49bfaef42a548ee657362af5c7a640da757f6f452a35da7dd9f923c" + "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4", + "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc", + "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1", + "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046", + "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36", + "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5", + "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d", + "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916", + "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0", + "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27", + "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc", + "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7", + "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38", + "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5", + "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832", + "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a", + "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f", + "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9", + "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692", + "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84", + "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79", + "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681" ], "index": "pypi", - "version": "==4.3.4" + "version": "==4.4.1" }, "markdownify": { "hashes": [ @@ -337,10 +335,10 @@ }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", + "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" ], - "version": "==19.0" + "version": "==19.1" }, "pamqp": { "hashes": [ @@ -369,8 +367,7 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", - "sha256:b360ff0cd21cdecd07372020a2d7f3234e1acc8c31ab4b4d3a6fa6e5bc6259cd" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" ], "version": "==2.19" }, @@ -419,10 +416,10 @@ }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", + "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" ], - "version": "==2.4.0" + "version": "==2.4.2" }, "python-dateutil": { "hashes": [ @@ -440,43 +437,45 @@ }, "pytz": { "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" + "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", + "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" ], - "version": "==2019.1" + "version": "==2019.2" }, "pyyaml": { "hashes": [ - "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", - "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", - "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", - "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", - "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", - "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", - "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", - "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", - "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", - "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", - "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" + "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", + "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", + "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", + "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", + "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", + "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", + "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", + "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", + "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", + "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", + "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", + "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", + "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" ], "index": "pypi", - "version": "==5.1.1" + "version": "==5.1.2" }, "regex": { "hashes": [ - "sha256:1c70ccb8bf4ded0cbe53092e9f56dcc9d6b0efcf6e80b6ef9b0ece8a557d6635", - "sha256:2948310c01535ccb29bb600dd033b07b91f36e471953889b7f3a1e66b39d0c19", - "sha256:2ab13db0411cb308aa590d33c909ea4efeced40188d8a4a7d3d5970657fe73bc", - "sha256:38e6486c7e14683cd1b17a4218760f0ea4c015633cf1b06f7c190fb882a51ba7", - "sha256:80dde4ff10b73b823da451687363cac93dd3549e059d2dc19b72a02d048ba5aa", - "sha256:84daedefaa56320765e9c4d43912226d324ef3cc929f4d75fa95f8c579a08211", - "sha256:b98e5876ca1e63b41c4aa38d7d5cc04a736415d4e240e9ae7ebc4f780083c7d5", - "sha256:ca4f47131af28ef168ff7c80d4b4cad019cb4cabb5fa26143f43aa3dbd60389c", - "sha256:cf7838110d3052d359da527372666429b9485ab739286aa1a11ed482f037a88c", - "sha256:dd4e8924915fa748e128864352875d3d0be5f4597ab1b1d475988b8e3da10dd7", - "sha256:f2c65530255e4010a5029eb11138f5ecd5aa70363f57a3444d83b3253b0891be" + "sha256:1e9f9bc44ca195baf0040b1938e6801d2f3409661c15fe57f8164c678cfc663f", + "sha256:587b62d48ca359d2d4f02d486f1f0aa9a20fbaf23a9d4198c4bed72ab2f6c849", + "sha256:835ccdcdc612821edf132c20aef3eaaecfb884c9454fdc480d5887562594ac61", + "sha256:93f6c9da57e704e128d90736430c5c59dd733327882b371b0cae8833106c2a21", + "sha256:a46f27d267665016acb3ec8c6046ec5eae8cf80befe85ba47f43c6f5ec636dcd", + "sha256:c5c8999b3a341b21ac2c6ec704cfcccbc50f1fedd61b6a8ee915ca7fd4b0a557", + "sha256:d4d1829cf97632673aa49f378b0a2c3925acd795148c5ace8ef854217abbee89", + "sha256:d96479257e8e4d1d7800adb26bf9c5ca5bab1648a1eddcac84d107b73dc68327", + "sha256:f20f4912daf443220436759858f96fefbfc6c6ba9e67835fd6e4e9b73582791a", + "sha256:f2b37b5b2c2a9d56d9e88efef200ec09c36c7f323f9d58d0b985a90923df386d", + "sha256:fe765b809a1f7ce642c2edeee351e7ebd84391640031ba4b60af8d91a9045890" ], - "version": "==2019.6.8" + "version": "==2019.8.19" }, "requests": { "hashes": [ @@ -495,25 +494,24 @@ }, "snowballstemmer": { "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + "sha256:9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9" ], - "version": "==1.2.1" + "version": "==1.9.0" }, "soupsieve": { "hashes": [ - "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece", - "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca" + "sha256:8662843366b8d8779dec4e2f921bebec9afd856a5ff2e82cd419acc5054a1a92", + "sha256:a5a6166b4767725fd52ae55fee8c8b6137d9a51e9f1edea461a062a759160118" ], - "version": "==1.9.1" + "version": "==1.9.3" }, "sphinx": { "hashes": [ - "sha256:15143166e786c7faa76fa990d3b6b38ebffe081ef81cffd1d656b07f3b28a1fa", - "sha256:5fd62ba64235d77a81554d47ff6b17578171b6dbbc992221e9ebc684898fff59" + "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845", + "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.0" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -559,9 +557,10 @@ }, "tzlocal": { "hashes": [ - "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e" + "sha256:11c9f16e0a633b4b60e1eede97d8a46340d042e67b670b290ca526576e039048", + "sha256:949b9dd5ba4be17190a80c0268167d7e6c92c62b30026cf9764caf3e308e5590" ], - "version": "==1.5.1" + "version": "==2.0.0" }, "urllib3": { "hashes": [ @@ -645,10 +644,10 @@ }, "cfgv": { "hashes": [ - "sha256:32edbe09de6f4521224b87822103a8c16a614d31a894735f7a5b3bcf0eb3c37e", - "sha256:3bd31385cd2bebddbba8012200aaf15aa208539f1b33973759b4d02fc2148da5" + "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144", + "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289" ], - "version": "==2.0.0" + "version": "==2.0.1" }, "chardet": { "hashes": [ @@ -687,19 +686,19 @@ }, "flake8": { "hashes": [ - "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", - "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", + "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696" ], "index": "pypi", - "version": "==3.7.7" + "version": "==3.7.8" }, "flake8-bugbear": { "hashes": [ - "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb", - "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d" + "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571", + "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8" ], "index": "pypi", - "version": "==19.3.0" + "version": "==19.8.0" }, "flake8-import-order": { "hashes": [ @@ -734,10 +733,10 @@ }, "identify": { "hashes": [ - "sha256:0a11379b46d06529795442742a043dc2fa14cd8c995ae81d1febbc5f1c014c87", - "sha256:43a5d24ffdb07bc7e21faf68b08e9f526a1f41f0056073f480291539ef961dfd" + "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017", + "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e" ], - "version": "==1.4.5" + "version": "==1.4.7" }, "idna": { "hashes": [ @@ -748,10 +747,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:6dfd58dfe281e8d240937776065dd3624ad5469c835248219bd16cf2e12dbeb7", - "sha256:cb6ee23b46173539939964df59d3d72c3e0c1b5d54b84f1d8a7e912fe43612db" + "sha256:9ff1b1c5a354142de080b8a4e9803e5d0d59283c93aed808617c787d16768375", + "sha256:b7143592e374e50584564794fcb8aaf00a23025f9db866627f89a21491847a8d" ], - "version": "==0.18" + "markers": "python_version < '3.8'", + "version": "==0.20" }, "mccabe": { "hashes": [ @@ -762,11 +762,10 @@ }, "more-itertools": { "hashes": [ - "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", - "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" + "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", + "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" ], - "markers": "python_version > '2.7'", - "version": "==7.0.0" + "version": "==7.2.0" }, "nodeenv": { "hashes": [ @@ -776,10 +775,10 @@ }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", + "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" ], - "version": "==19.0" + "version": "==19.1" }, "pluggy": { "hashes": [ @@ -790,11 +789,11 @@ }, "pre-commit": { "hashes": [ - "sha256:92e406d556190503630fd801958379861c94884693a032ba66629d0351fdccd4", - "sha256:cccc39051bc2457b0c0f7152a411f8e05e3ba2fe1a5613e4ee0833c1c1985ce3" + "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", + "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a" ], "index": "pypi", - "version": "==1.17.0" + "version": "==1.18.3" }, "py": { "hashes": [ @@ -819,35 +818,37 @@ }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", + "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" ], - "version": "==2.4.0" + "version": "==2.4.2" }, "pytest": { "hashes": [ - "sha256:4a784f1d4f2ef198fe9b7aef793e9fa1a3b2f84e822d9b3a64a181293a572d45", - "sha256:926855726d8ae8371803f7b2e6ec0a69953d9c6311fa7c3b6c1b929ff92d27da" + "sha256:95d13143cc14174ca1a01ec68e84d76ba5d9d493ac02716fd9706c949a505210", + "sha256:b78fe2881323bd44fd9bd76e5317173d4316577e7b1cddebae9136a4495ec865" ], "index": "pypi", - "version": "==4.6.3" + "version": "==5.1.2" }, "pyyaml": { "hashes": [ - "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", - "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", - "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", - "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", - "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", - "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", - "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", - "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", - "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", - "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", - "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" + "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", + "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", + "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", + "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", + "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", + "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", + "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", + "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", + "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", + "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", + "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", + "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", + "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" ], "index": "pypi", - "version": "==5.1.1" + "version": "==5.1.2" }, "requests": { "hashes": [ @@ -889,10 +890,10 @@ }, "virtualenv": { "hashes": [ - "sha256:b7335cddd9260a3dd214b73a2521ffc09647bde3e9457fcca31dc3be3999d04a", - "sha256:d28ca64c0f3f125f59cabf13e0a150e1c68e5eea60983cc4395d88c584495783" + "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30", + "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2" ], - "version": "==16.6.1" + "version": "==16.7.5" }, "wcwidth": { "hashes": [ @@ -903,10 +904,10 @@ }, "zipp": { "hashes": [ - "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d", - "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3" + "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", + "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" ], - "version": "==0.5.1" + "version": "==0.6.0" } } } -- cgit v1.2.3 From bf7bef47396c96876721fc0de42ca02b4572a3ea Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 9 Sep 2019 20:52:41 -0400 Subject: Make defcon days command turn on defcon, refactor log messaging --- bot/cogs/defcon.py | 134 +++++++++++++++++++++++++++-------------------------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c67fa2807..61125a0a1 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -127,30 +127,12 @@ class Defcon: except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message( - Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("enabled", e)) + await self.send_defcon_log("enabled", ctx.author, e) else: - await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") - - await self.mod_log.send_log_message( - Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - ) + await ctx.send(self.build_defcon_msg("enabled")) + await self.send_defcon_log("enabled", ctx.author) await self.update_channel_topic() @@ -176,27 +158,11 @@ class Defcon: ) except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message( - Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("disabled", e)) + await self.send_defcon_log("disabled", ctx.author, e) else: - await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.") - - await self.mod_log.send_log_message( - Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)" - ) + await ctx.send(self.build_defcon_msg("disabled")) + await self.send_defcon_log("disabled", ctx.author) await self.update_channel_topic() @@ -237,32 +203,15 @@ class Defcon: ) except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} " - f"days old to join to the server.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message( - Icons.defcon_updated, Colour.blurple(), "DEFCON updated", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("updated", e)) + await self.send_defcon_log("updated", ctx.author, e) else: - await ctx.send( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server" - ) + await ctx.send(self.build_defcon_msg("updated")) + await self.send_defcon_log("updated", ctx.author) - await self.mod_log.send_log_message( - Icons.defcon_updated, Colour.blurple(), "DEFCON updated", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}" - ) + # Enable DEFCON if it's not already + if not self.enabled: + self.enabled = True await self.update_channel_topic() @@ -281,6 +230,59 @@ class Defcon: defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon) await defcon_channel.edit(topic=new_topic) + def build_defcon_msg(self, change: str, e: Exception = None) -> str: + """ + Build in-channel response string for DEFCON action. + + `change` string may be one of the following: ('enabled', 'disabled', 'updated') + """ + if change.lower() == "enabled": + msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" + elif change.lower() == "disabled": + msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" + elif change.lower() == "updated": + msg = ( + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " + "days old to join the server.\n\n" + ) + + if e: + msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None: + """ + Send log message for DEFCON action. + + `change` string may be one of the following: ('enabled', 'disabled', 'updated') + """ + log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n" + + if change.lower() == "enabled": + icon = Icons.defcon_enabled + color = Colours.soft_green + status_msg = "DEFCON enabled" + log_msg += f"**Days:** {self.days.days}\n\n" + elif change.lower() == "disabled": + icon = Icons.defcon_disabled + color = Colours.soft_red + status_msg = "DEFCON enabled" + elif change.lower() == "updated": + icon = Icons.defcon_updated + color = Colour.blurple() + status_msg = "DEFCON updated" + log_msg += f"**Days:** {self.days.days}\n\n" + + if e: + log_msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + await self.mod_log.send_log_message(icon, color, status_msg, log_msg) + def setup(bot: Bot): bot.add_cog(Defcon(bot)) -- cgit v1.2.3 From 415640933d1b12d932b4c5e1e0c0814f1f8bbdb9 Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 9 Sep 2019 21:06:37 -0400 Subject: Update linting dependencies & rules * Add flake8-annotations * Add flake8-docstrings --- Pipfile | 2 ++ Pipfile.lock | 47 ++++++++++++++++++++++++++++++++++++++--------- tox.ini | 16 ++++++++++++++-- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/Pipfile b/Pipfile index 2e56a3d7b..1e29bd649 100644 --- a/Pipfile +++ b/Pipfile @@ -22,7 +22,9 @@ urllib3 = ">=1.24.2,<1.25" [dev-packages] flake8 = "~=3.7" +flake8-annotations = "~=1.0" flake8-bugbear = "~=19.8" +flake8-docstrings = "~=1.4" flake8-import-order = "~=0.18" flake8-string-format = "~=0.2" flake8-tidy-imports = "~=2.0" diff --git a/Pipfile.lock b/Pipfile.lock index 6b91ff8aa..e9b64e6cd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "61607e940ea00e1197900c04fe1298e048d1f415db2f1a2a3a157406c6ea2b0c" + "sha256": "a953197b27bbc2af413a1ddd465bb95254167e3a0bba525a3ad34ac738dd6ae4" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c1424962ef9e28fbda840d76425cdd139605e480a4d68164303cda8d356ba9de", - "sha256:dcfe9c11af2ab9ff6c1c5a366d094c2a7542bab534d98a4aea29518672c9d7ac" + "sha256:31e08189841a8350db5bec70608b4d2fbacb89c0a555a18ec47511716a9bfc41", + "sha256:adfe0acf34356ccd9654a9a1c46f7e8db1dc4497a774c0e54bf2d3af14571bd0" ], "index": "pypi", - "version": "==6.1.0" + "version": "==6.1.1" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:472734ab3cf18001fb8cedb38ee13008292230a461b6482dbdf65590441ce32c", - "sha256:4b6b2b43616b7a6b353ecf9896ae29ac2f74a38c4c53bfe73824ac2807faca5d" + "sha256:0b755b748d87d5ec63b4b7f162102333bf0901caf1f8a2bf29467bbdd54e637d", + "sha256:f8eef1f98bc331a266404d925745fac589dab30412688564d740754d6d643863" ], - "version": "==2.7.4" + "version": "==2.7.5" }, "alabaster": { "hashes": [ @@ -494,9 +494,9 @@ }, "snowballstemmer": { "hashes": [ - "sha256:9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9" + "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" ], - "version": "==1.9.0" + "version": "==1.9.1" }, "soupsieve": { "hashes": [ @@ -692,6 +692,14 @@ "index": "pypi", "version": "==3.7.8" }, + "flake8-annotations": { + "hashes": [ + "sha256:1309f2bc9853a2d77d578b089d331b0b832b40c97932641e136e1b49d3650c82", + "sha256:3ecdd27054c3eed6484139025698465e3c9f4e68dbd5043d0204fcb2550ee27b" + ], + "index": "pypi", + "version": "==1.0.0" + }, "flake8-bugbear": { "hashes": [ "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571", @@ -700,6 +708,14 @@ "index": "pypi", "version": "==19.8.0" }, + "flake8-docstrings": { + "hashes": [ + "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd", + "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06" + ], + "index": "pypi", + "version": "==1.4.0" + }, "flake8-import-order": { "hashes": [ "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543", @@ -809,6 +825,13 @@ ], "version": "==2.5.0" }, + "pydocstyle": { + "hashes": [ + "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", + "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" + ], + "version": "==4.0.1" + }, "pyflakes": { "hashes": [ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", @@ -873,6 +896,12 @@ ], "version": "==1.12.0" }, + "snowballstemmer": { + "hashes": [ + "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + ], + "version": "==1.9.1" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", diff --git a/tox.ini b/tox.ini index c84827570..8ff1ff064 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,18 @@ [flake8] max-line-length=120 application_import_names=bot -exclude=.cache,.venv -ignore=B311,W503,E226,S311,T000 +docstring-convention=all +ignore= + P102,B311,W503,E226,S311,T000 + # Missing Docstrings + D100,D104,D105,D107, + # Docstring Whitespace + D203,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 + # Type Annotations + TYP002,TYP003,TYP101,TYP102,TYP204,TYP206 +exclude=.cache,.venv,tests,constants.py import-order-style=pycharm -- cgit v1.2.3 From 63bdc14a66ee24c79fd8a012eada0e75753e33d5 Mon Sep 17 00:00:00 2001 From: sco1 Date: Mon, 9 Sep 2019 21:59:04 -0400 Subject: Docstring linting chunk 1 --- bot/__init__.py | 2 +- bot/api.py | 48 +++++++++++++++++++++++++++------- bot/cogs/defcon.py | 51 +++++++++++++++--------------------- bot/cogs/logging.py | 14 +++++----- bot/cogs/tags.py | 53 ++++++++++--------------------------- bot/cogs/token_remover.py | 21 +++++++++++++-- bot/cogs/wolfram.py | 66 +++++++++++------------------------------------ 7 files changed, 114 insertions(+), 141 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index 8efa5e53c..d094e8c13 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -9,7 +9,7 @@ logging.TRACE = 5 logging.addLevelName(logging.TRACE, "TRACE") -def monkeypatch_trace(self, msg, *args, **kwargs): +def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: """ Log 'msg % args' with severity 'TRACE'. diff --git a/bot/api.py b/bot/api.py index cd19896e1..b714bda24 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,5 +1,6 @@ import asyncio import logging +from typing import Union from urllib.parse import quote as quote_url import aiohttp @@ -10,11 +11,15 @@ log = logging.getLogger(__name__) class ResponseCodeError(ValueError): + """Represent a not-OK response code.""" + def __init__(self, response: aiohttp.ClientResponse): self.response = response class APIClient: + """Django Site API wrapper.""" + def __init__(self, **kwargs): auth_headers = { 'Authorization': f"Token {Keys.site_api}" @@ -28,34 +33,40 @@ class APIClient: self.session = aiohttp.ClientSession(**kwargs) @staticmethod - def _url_for(endpoint: str): + def _url_for(endpoint: str) -> str: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" - def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): + def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: + """Raise ResponseCodeError for non-OK response if an exception should be raised.""" if should_raise and response.status >= 400: raise ResponseCodeError(response=response) - async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + """Site API GET.""" async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + """Site API PATCH.""" async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + """Site API POST.""" async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict: + """Site API PUT.""" async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() - async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Union[dict, None]: + """Site API DELETE.""" async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: if resp.status == 204: return None @@ -65,6 +76,12 @@ class APIClient: def loop_is_running() -> bool: + """ + Determine if there is a running asyncio event loop. + + This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`), + which is currently not provided by asyncio + """ # asyncio does not have a way to say "call this when the event # loop is running", see e.g. `callWhenRunning` from twisted. @@ -76,6 +93,8 @@ def loop_is_running() -> bool: class APILoggingHandler(logging.StreamHandler): + """Site API logging handler.""" + def __init__(self, client: APIClient): logging.StreamHandler.__init__(self) self.client = client @@ -84,7 +103,8 @@ class APILoggingHandler(logging.StreamHandler): # on the event loop yet - scheduled when the event loop is ready. self.queue = [] - async def ship_off(self, payload: dict): + async def ship_off(self, payload: dict) -> None: + """Ship log payload to the logging API.""" try: await self.client.post('logs', json=payload) except ResponseCodeError as err: @@ -100,7 +120,14 @@ class APILoggingHandler(logging.StreamHandler): extra={'via_handler': True} ) - def emit(self, record: logging.LogRecord): + def emit(self, record: logging.LogRecord) -> None: + """ + Determine if a log record should be shipped to the logging API. + + The following two conditions are set: + 1. Do not log anything below DEBUG + 2. Ignore log records from the logging handler + """ # Two checks are performed here: if ( # 1. Do not log anything below `DEBUG`. This is only applicable @@ -131,7 +158,8 @@ class APILoggingHandler(logging.StreamHandler): asyncio.create_task(task) self.schedule_queued_tasks() - def schedule_queued_tasks(self): + def schedule_queued_tasks(self) -> None: + """Logging task scheduler.""" for task in self.queue: asyncio.create_task(task) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c67fa2807..fc4dca0ee 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -25,7 +25,8 @@ BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" class Defcon: - """Time-sensitive server defense mechanisms""" + """Time-sensitive server defense mechanisms.""" + days = None # type: timedelta enabled = False # type: bool @@ -36,9 +37,11 @@ class Defcon: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_ready(self): + async def on_ready(self) -> None: + """On cog load, try to synchronize DEFCON settings to the API.""" try: response = await self.bot.api_client.get('bot/bot-settings/defcon') data = response['data'] @@ -62,7 +65,8 @@ class Defcon: await self.update_channel_topic() - async def on_member_join(self, member: Member): + async def on_member_join(self, member: Member) -> None: + """If DEFON is enabled, check newly joining users to see if they meet the account age threshold.""" if self.enabled and self.days.days > 0: now = datetime.utcnow() @@ -95,21 +99,19 @@ class Defcon: @group(name='defcon', aliases=('dc',), invoke_without_command=True) @with_role(Roles.admin, Roles.owner) - async def defcon_group(self, ctx: Context): + async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" - await ctx.invoke(self.bot.get_command("help"), "defcon") @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) - async def enable_command(self, ctx: Context): + async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - Currently, this just adds an account age requirement. Use !defcon days to set how old an account must - be, in days. + Currently, this just adds an account age requirement. Use !defcon days to set how old an account must be, + in days. """ - self.enabled = True try: @@ -156,11 +158,8 @@ class Defcon: @defcon_group.command(name='disable', aliases=('off', 'd')) @with_role(Roles.admin, Roles.owner) - async def disable_command(self, ctx: Context): - """ - Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - """ - + async def disable_command(self, ctx: Context) -> None: + """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False try: @@ -202,11 +201,8 @@ class Defcon: @defcon_group.command(name='status', aliases=('s',)) @with_role(Roles.admin, Roles.owner) - async def status_command(self, ctx: Context): - """ - Check the current status of DEFCON mode. - """ - + async def status_command(self, ctx: Context) -> None: + """Check the current status of DEFCON mode.""" embed = Embed( colour=Colour.blurple(), title="DEFCON Status", description=f"**Enabled:** {self.enabled}\n" @@ -217,11 +213,8 @@ class Defcon: @defcon_group.command(name='days') @with_role(Roles.admin, Roles.owner) - async def days_command(self, ctx: Context, days: int): - """ - Set how old an account must be to join the server, in days, with DEFCON mode enabled. - """ - + async def days_command(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) try: @@ -266,11 +259,8 @@ class Defcon: await self.update_channel_topic() - async def update_channel_topic(self): - """ - Update the #defcon channel topic with the current DEFCON status - """ - + async def update_channel_topic(self) -> None: + """Update the #defcon channel topic with the current DEFCON status.""" if self.enabled: day_str = "days" if self.days.days > 1 else "day" new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" @@ -282,6 +272,7 @@ class Defcon: await defcon_channel.edit(topic=new_topic) -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """DEFCON cog load.""" bot.add_cog(Defcon(bot)) log.info("Cog loaded: Defcon") diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6b8462f3b..22d770c04 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -10,27 +10,27 @@ log = logging.getLogger(__name__) class Logging: - """ - Debug logging module - """ + """Debug logging module.""" def __init__(self, bot: Bot): self.bot = bot - async def on_ready(self): + async def on_ready(self) -> None: + """Announce our presence to the configured devlog channel.""" log.info("Bot connected!") embed = Embed(description="Connected!") embed.set_author( name="Python Bot", - url="https://gitlab.com/discord-python/projects/bot", - icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png" + url="https://github.com/python-discord/bot", + icon_url="https://github.com/python-discord/branding/blob/master/logos/logo_circle/logo_circle_256.png" ) if not DEBUG_MODE: await self.bot.get_channel(Channels.devlog).send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Logging cog load.""" bot.add_cog(Logging(bot)) log.info("Cog loaded: Logging") diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7b1003148..3a93f0d47 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -20,9 +20,7 @@ TEST_CHANNELS = ( class Tags: - """ - Save new tags and fetch existing tags. - """ + """Save new tags and fetch existing tags.""" def __init__(self, bot: Bot): self.bot = bot @@ -30,32 +28,19 @@ class Tags: self.headers = {"Authorization": f"Token {Keys.site_api}"} @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None): - """ - Get a list of all tags or a specified tag. - - :param ctx: Discord message context - :param tag_name: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. - """ - - def _command_on_cooldown(tag_name) -> bool: + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of all tags if no tag is specified.""" + def _command_on_cooldown(tag_name: str) -> bool: """ - Check if the command is currently on cooldown. - The cooldown duration is set in constants.py. + Check if the command is currently on cooldown, on a per-tag, per-channel basis. - This works on a per-tag, per-channel basis. - :param tag_name: The name of the command to check. - :return: True if the command is cooling down. Otherwise False. + The cooldown duration is set in constants.py. """ - now = time.time() cooldown_conditions = ( @@ -110,15 +95,8 @@ class Tags: tag_name: TagNameConverter, *, tag_content: TagContentConverter, - ): - """ - Create a new tag or update an existing one. - - :param ctx: discord message context - :param tag_name: The name of the tag to create or edit. - :param tag_content: The content of the tag. - """ - + ) -> None: + """Create a new tag or update an existing one.""" body = { 'title': tag_name.lower().strip(), 'embed': { @@ -141,14 +119,8 @@ class Tags: @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(Roles.admin, Roles.owner) - async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter): - """ - Remove a tag from the database. - - :param ctx: discord message context - :param tag_name: The name of the tag to delete. - """ - + async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: + """Remove a tag from the database.""" await self.bot.api_client.delete(f'bot/tags/{tag_name}') log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") @@ -159,6 +131,7 @@ class Tags: )) -def setup(bot): +def setup(bot: Bot) -> None: + """Tags cog load.""" bot.add_cog(Tags(bot)) log.info("Cog loaded: Tags") diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 05298a2ff..7e9f5ef84 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -44,9 +44,15 @@ class TokenRemover: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, msg: Message): + async def on_message(self, msg: Message) -> None: + """ + Check each message for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ if msg.author.bot: return @@ -83,6 +89,11 @@ class TokenRemover: @staticmethod def is_valid_user_id(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ b64_content += '=' * (-len(b64_content) % 4) try: @@ -93,6 +104,11 @@ class TokenRemover: @staticmethod def is_valid_timestamp(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid timestamp. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ b64_content += '=' * (-len(b64_content) % 4) try: @@ -103,6 +119,7 @@ class TokenRemover: return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Token Remover cog load.""" bot.add_cog(TokenRemover(bot)) log.info("Cog loaded: TokenRemover") diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index e8b16b243..0093a1615 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -6,7 +6,7 @@ from urllib import parse import discord from discord import Embed from discord.ext import commands -from discord.ext.commands import BucketType, Context, check, group +from discord.ext.commands import Bot, BucketType, Context, check, group from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator @@ -35,18 +35,7 @@ async def send_embed( img_url: str = None, f: discord.File = None ) -> None: - """ - Generates an embed with wolfram as the author, with message_txt as description, - adds custom colour if specified, a footer and image (could be a file with f param) and sends - the embed through ctx - :param ctx: Context - :param message_txt: str - Message to be sent - :param colour: int - Default: Colours.soft_red - Colour of embed - :param footer: str - Default: None - Adds a footer to the embed - :param img_url:str - Default: None - Adds an image to the embed - :param f: discord.File - Default: None - Add a file to the msg, often attached as image to embed - """ - + """Generate & send a response embed with Wolfram as the author.""" embed = Embed(colour=colour) embed.description = message_txt embed.set_author(name="Wolfram Alpha", @@ -63,14 +52,10 @@ async def send_embed( def custom_cooldown(*ignore: List[int]) -> check: """ - Custom cooldown mapping that applies a specific requests per day to users. - Staff is ignored by the user cooldown, however the cooldown implements a - total amount of uses per day for the entire guild. (Configurable in configs) + Implement per-user and per-guild cooldowns for requests to the Wolfram API. - :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown - :return: check + A list of roles may be provided to ignore the per-user cooldown """ - async def predicate(ctx: Context) -> bool: user_bucket = usercd.get_bucket(ctx.message) @@ -105,8 +90,8 @@ def custom_cooldown(*ignore: List[int]) -> check: return check(predicate) -async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: - # Give feedback that the bot is working. +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: + """Give feedback that the bot is working.""" async with ctx.channel.typing(): url_str = parse.urlencode({ "input": query, @@ -150,9 +135,7 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: class Wolfram: - """ - Commands for interacting with the Wolfram|Alpha API. - """ + """Commands for interacting with the Wolfram|Alpha API.""" def __init__(self, bot: commands.Bot): self.bot = bot @@ -160,14 +143,7 @@ class Wolfram: @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) @custom_cooldown(*STAFF_ROLES) async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """ - Requests all answers on a single image, - sends an image of all related pods - - :param ctx: Context - :param query: str - string request to api - """ - + """Requests all answers on a single image, sends an image of all related pods.""" url_str = parse.urlencode({ "i": query, "appid": APPID, @@ -203,13 +179,10 @@ class Wolfram: @custom_cooldown(*STAFF_ROLES) async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: """ - Requests a drawn image of given query - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + Requests a drawn image of given query. - :param ctx: Context - :param query: str - string request to api + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. """ - pages = await get_pod_pages(ctx, self.bot, query) if not pages: @@ -225,15 +198,12 @@ class Wolfram: @wolfram_command.command(name="cut", aliases=("c",)) @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx, *, query: str) -> None: + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: """ - Requests a drawn image of given query - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + Requests a drawn image of given query. - :param ctx: Context - :param query: str - string request to api + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. """ - pages = await get_pod_pages(ctx, self.bot, query) if not pages: @@ -249,14 +219,7 @@ class Wolfram: @wolfram_command.command(name="short", aliases=("sh", "s")) @custom_cooldown(*STAFF_ROLES) async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """ - Requests an answer to a simple question - Responds in plaintext - - :param ctx: Context - :param query: str - string request to api - """ - + """Requests an answer to a simple question.""" url_str = parse.urlencode({ "i": query, "appid": APPID, @@ -284,5 +247,6 @@ class Wolfram: def setup(bot: commands.Bot) -> None: + """Wolfram cog load.""" bot.add_cog(Wolfram(bot)) log.info("Cog loaded: Wolfram") -- cgit v1.2.3 From 6e9526729d8bb4142b6aadf39a92e6258404f95b Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 16:56:04 -0400 Subject: Docstring linting chunk 2 --- bot/cogs/alias.py | 140 +++++++++++++---------------------------------- bot/cogs/clean.py | 97 +++++++++----------------------- bot/cogs/cogs.py | 28 ++++------ bot/cogs/deployment.py | 31 ++++------- bot/cogs/information.py | 35 ++++-------- bot/cogs/modlog.py | 65 +++++++++++++--------- bot/cogs/verification.py | 39 +++++-------- 7 files changed, 150 insertions(+), 285 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 85d101448..a01a05715 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -4,7 +4,7 @@ from typing import Union from discord import Colour, Embed, Member, User from discord.ext.commands import ( - Command, Context, clean_content, command, group + Bot, Command, Context, clean_content, command, group ) from bot.cogs.watchchannels.watchchannel import proxy_user @@ -15,25 +15,13 @@ log = logging.getLogger(__name__) class Alias: - """ - Aliases for more used commands - """ + """Aliases for commonly used commands.""" - def __init__(self, bot): + def __init__(self, bot: Bot): self.bot = bot - async def invoke(self, ctx, cmd_name, *args, **kwargs): - """ - Invokes a command with args and kwargs. - Fail early through `command.can_run`, and logs warnings. - - :param ctx: Context instance for command call - :param cmd_name: Name of command/subcommand to be invoked - :param args: args to be passed to the command - :param kwargs: kwargs to be passed to the command - :return: None - """ - + async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: + """Invokes a command with args and kwargs.""" log.debug(f"{cmd_name} was invoked through an alias") cmd = self.bot.get_command(cmd_name) if not cmd: @@ -46,9 +34,8 @@ class Alias: await ctx.invoke(cmd, *args, **kwargs) @command(name='aliases') - async def aliases_command(self, ctx): + async def aliases_command(self, ctx: Context) -> None: """Show configured aliases on the bot.""" - embed = Embed( title='Configured aliases', colour=Colour.blue() @@ -64,140 +51,89 @@ class Alias: ) @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx): - """ - Alias for invoking site resources. - """ - + async def site_resources_alias(self, ctx: Context) -> None: + """Alias for invoking site resources.""" await self.invoke(ctx, "site resources") @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): - """ - Alias for invoking bigbrother watch [user] [reason]. - """ - + async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """Alias for invoking bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): - """ - Alias for invoking bigbrother unwatch [user] [reason]. - """ - + async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Alias for invoking bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @command(name="home", hidden=True) - async def site_home_alias(self, ctx): - """ - Alias for invoking site home. - """ - + async def site_home_alias(self, ctx: Context) -> None: + """Alias for invoking site home.""" await self.invoke(ctx, "site home") @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx): - """ - Alias for invoking site faq. - """ - + async def site_faq_alias(self, ctx: Context) -> None: + """Alias for invoking site faq.""" await self.invoke(ctx, "site faq") @command(name="rules", hidden=True) - async def site_rules_alias(self, ctx): - """ - Alias for invoking site rules. - """ - + async def site_rules_alias(self, ctx: Context) -> None: + """Alias for invoking site rules.""" await self.invoke(ctx, "site rules") @command(name="reload", hidden=True) - async def cogs_reload_alias(self, ctx, *, cog_name: str): - """ - Alias for invoking cogs reload [cog_name]. - - cog_name: str - name of the cog to be reloaded. - """ - + async def cogs_reload_alias(self, ctx: Context, *, cog_name: str) -> None: + """Alias for invoking cogs reload [cog_name].""" await self.invoke(ctx, "cogs reload", cog_name) @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx): - """ - Alias for invoking defcon enable. - """ - + async def defcon_enable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon enable.""" await self.invoke(ctx, "defcon enable") @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx): - """ - Alias for invoking defcon disable. - """ - + async def defcon_disable_alias(self, ctx: Context) -> None: + """Alias for invoking defcon disable.""" await self.invoke(ctx, "defcon disable") @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx): - """ - Alias for invoking tags get traceback. - """ - + async def tags_get_traceback_alias(self, ctx: Context) -> None: + """Alias for invoking tags get traceback.""" await self.invoke(ctx, "tags get traceback") @group(name="get", aliases=("show", "g"), hidden=True, invoke_without_command=True) - async def get_group_alias(self, ctx): - """ - Group for reverse aliases for commands like `tags get`, - allowing for `get tags` or `get docs`. - """ - + async def get_group_alias(self, ctx: Context) -> None: + """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" pass @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) async def tags_get_alias( self, ctx: Context, *, tag_name: TagNameConverter = None - ): - """ - Alias for invoking tags get [tag_name]. - - tag_name: str - tag to be viewed. - """ - + ) -> None: + """Alias for invoking tags get [tag_name].""" await self.invoke(ctx, "tags get", tag_name) @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) async def docs_get_alias( self, ctx: Context, symbol: clean_content = None - ): - """ - Alias for invoking docs get [symbol]. - - symbol: str - name of doc to be viewed. - """ - + ) -> None: + """Alias for invoking docs get [symbol].""" await self.invoke(ctx, "docs get", symbol) @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): - """ - Alias for invoking talentpool add [user] [reason]. - """ - + async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """Alias for invoking talentpool add [user] [reason].""" await self.invoke(ctx, "talentpool add", user, reason=reason) @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): - """ - Alias for invoking nomination end [user] [reason]. - """ - + async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Alias for invoking nomination end [user] [reason].""" await self.invoke(ctx, "nomination end", user, reason=reason) -def setup(bot): +def setup(bot: Bot) -> None: + """Alias cog load.""" bot.add_cog(Alias(bot)) log.info("Cog loaded: Alias") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index e7b6bac85..2c889c9f2 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -18,17 +18,13 @@ log = logging.getLogger(__name__) class Clean: """ - A cog that allows messages to be deleted in - bulk, while applying various filters. + A cog that allows messages to be deleted in bulk, while applying various filters. - You can delete messages sent by a specific user, - messages sent by bots, all messages, or messages - that match a specific regular expression. + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. - The deleted messages are saved and uploaded - to the database via an API endpoint, and a URL is - returned which can be used to view the messages - in the Discord dark theme style. + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. """ def __init__(self, bot: Bot): @@ -37,44 +33,25 @@ class Clean: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") async def _clean_messages( self, amount: int, ctx: Context, bots_only: bool = False, user: User = None, regex: Optional[str] = None - ): - """ - A helper function that does the actual message cleaning. - - :param bots_only: Set this to True if you only want to delete bot messages. - :param user: Specify a user and it will only delete messages by this user. - :param regular_expression: Specify a regular expression and it will only - delete messages that match this. - """ - + ) -> None: + """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: - """ - Returns true if the message was sent by a bot - """ - + """Returns true if the message was sent by a bot.""" return message.author.bot def predicate_specific_user(message: Message) -> bool: - """ - Return True if the message was sent by the - user provided in the _clean_messages call. - """ - + """Return True if the message was sent by the user provided in the _clean_messages call.""" return message.author == user - def predicate_regex(message: Message): - """ - Returns True if the regex provided in the - _clean_messages matches the message content - or any embed attributes the message may have. - """ - + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" content = [message.content] # Add the content for all embed attributes @@ -191,61 +168,38 @@ class Clean: @group(invoke_without_command=True, name="clean", hidden=True) @with_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context): - """ - Commands for cleaning messages in channels - """ - + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" await ctx.invoke(self.bot.get_command("help"), "clean") @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) - async def clean_user(self, ctx: Context, user: User, amount: int = 10): - """ - Delete messages posted by the provided user, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, user=user) @clean_group.command(name="all", aliases=["everything"]) @with_role(*MODERATION_ROLES) - async def clean_all(self, ctx: Context, amount: int = 10): - """ - Delete all messages, regardless of poster, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_all(self, ctx: Context, amount: int = 10) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx) @clean_group.command(name="bots", aliases=["bot"]) @with_role(*MODERATION_ROLES) - async def clean_bots(self, ctx: Context, amount: int = 10): - """ - Delete all messages posted by a bot, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_bots(self, ctx: Context, amount: int = 10) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, bots_only=True) @clean_group.command(name="regex", aliases=["word", "expression"]) @with_role(*MODERATION_ROLES) - async def clean_regex(self, ctx: Context, regex, amount: int = 10): - """ - Delete all messages that match a certain regex, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex) @clean_group.command(name="stop", aliases=["cancel", "abort"]) @with_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context): - """ - If there is an ongoing cleaning process, - attempt to immediately cancel it. - """ - + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" self.cleaning = False embed = Embed( @@ -255,6 +209,7 @@ class Clean: await ctx.send(embed=embed, delete_after=10) -def setup(bot): +def setup(bot: Bot) -> None: + """Clean cog load.""" bot.add_cog(Clean(bot)) log.info("Cog loaded: Clean") diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 5bef52c0a..e6fa92927 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -16,9 +16,7 @@ KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] class Cogs: - """ - Cog management commands - """ + """Cog management commands.""" def __init__(self, bot: Bot): self.bot = bot @@ -38,21 +36,19 @@ class Cogs: @group(name='cogs', aliases=('c',), invoke_without_command=True) @with_role(*MODERATION_ROLES, Roles.devops) - async def cogs_group(self, ctx: Context): + async def cogs_group(self, ctx: Context) -> None: """Load, unload, reload, and list active cogs.""" - await ctx.invoke(self.bot.get_command("help"), "cogs") @cogs_group.command(name='load', aliases=('l',)) @with_role(*MODERATION_ROLES, Roles.devops) - async def load_command(self, ctx: Context, cog: str): + async def load_command(self, ctx: Context, cog: str) -> None: """ - Load up an unloaded cog, given the module containing it + Load up an unloaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. """ - cog = cog.lower() embed = Embed() @@ -98,14 +94,13 @@ class Cogs: @cogs_group.command(name='unload', aliases=('ul',)) @with_role(*MODERATION_ROLES, Roles.devops) - async def unload_command(self, ctx: Context, cog: str): + async def unload_command(self, ctx: Context, cog: str) -> None: """ - Unload an already-loaded cog, given the module containing it + Unload an already-loaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. """ - cog = cog.lower() embed = Embed() @@ -150,9 +145,9 @@ class Cogs: @cogs_group.command(name='reload', aliases=('r',)) @with_role(*MODERATION_ROLES, Roles.devops) - async def reload_command(self, ctx: Context, cog: str): + async def reload_command(self, ctx: Context, cog: str) -> None: """ - Reload an unloaded cog, given the module containing it + Reload an unloaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. @@ -160,7 +155,6 @@ class Cogs: If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the bot/cogs directory will be loaded. """ - cog = cog.lower() embed = Embed() @@ -255,13 +249,12 @@ class Cogs: @cogs_group.command(name='list', aliases=('all',)) @with_role(*MODERATION_ROLES, Roles.devops) - async def list_command(self, ctx: Context): + async def list_command(self, ctx: Context) -> None: """ Get a list of all cogs, including their loaded status. Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. """ - embed = Embed() lines = [] cogs = {} @@ -301,6 +294,7 @@ class Cogs: await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) -def setup(bot): +def setup(bot: Bot) -> None: + """Cogs cog load.""" bot.add_cog(Cogs(bot)) log.info("Cog loaded: Cogs") diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py index e71e07c2f..e8e8ba677 100644 --- a/bot/cogs/deployment.py +++ b/bot/cogs/deployment.py @@ -10,27 +10,21 @@ log = logging.getLogger(__name__) class Deployment: - """ - Bot information commands - """ + """Bot information commands.""" def __init__(self, bot: Bot): self.bot = bot @group(name='redeploy', invoke_without_command=True) @with_role(*MODERATION_ROLES) - async def redeploy_group(self, ctx: Context): + async def redeploy_group(self, ctx: Context) -> None: """Redeploy the bot or the site.""" - await ctx.invoke(self.bot.get_command("help"), "redeploy") @redeploy_group.command(name='bot') @with_role(Roles.admin, Roles.owner, Roles.devops) - async def bot_command(self, ctx: Context): - """ - Trigger bot deployment on the server - will only redeploy if there were changes to deploy - """ - + async def bot_command(self, ctx: Context) -> None: + """Trigger bot deployment on the server - will only redeploy if there were changes to deploy.""" response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) result = await response.text() @@ -43,11 +37,8 @@ class Deployment: @redeploy_group.command(name='site') @with_role(Roles.admin, Roles.owner, Roles.devops) - async def site_command(self, ctx: Context): - """ - Trigger website deployment on the server - will only redeploy if there were changes to deploy - """ - + async def site_command(self, ctx: Context) -> None: + """Trigger website deployment on the server - will only redeploy if there were changes to deploy.""" response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) result = await response.text() @@ -60,11 +51,8 @@ class Deployment: @command(name='uptimes') @with_role(Roles.admin, Roles.owner, Roles.devops) - async def uptimes_command(self, ctx: Context): - """ - Check the various deployment uptimes for each service - """ - + async def uptimes_command(self, ctx: Context) -> None: + """Check the various deployment uptimes for each service.""" log.debug(f"{ctx.author} requested service uptimes.") response = await self.bot.http_session.get(URLs.status) data = await response.json() @@ -85,6 +73,7 @@ class Deployment: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Deployment cog load.""" bot.add_cog(Deployment(bot)) log.info("Cog loaded: Deployment") diff --git a/bot/cogs/information.py b/bot/cogs/information.py index a2585f395..3495f6181 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -18,11 +18,7 @@ log = logging.getLogger(__name__) class Information: - """ - A cog with commands for generating embeds with - server information, such as server statistics - and user information. - """ + """A cog with commands for generating embeds with server info, such as server stats and user info.""" def __init__(self, bot: Bot): self.bot = bot @@ -30,12 +26,8 @@ class Information: @with_role(*MODERATION_ROLES) @command(name="roles") - async def roles_info(self, ctx: Context): - """ - Returns a list of all roles and their - corresponding IDs. - """ - + async def roles_info(self, ctx: Context) -> None: + """Returns a list of all roles and their corresponding IDs.""" # Sort the roles alphabetically and remove the @everyone role roles = sorted(ctx.guild.roles, key=lambda role: role.name) roles = [role for role in roles if role.name != "@everyone"] @@ -57,12 +49,8 @@ class Information: await ctx.send(embed=embed) @command(name="server", aliases=["server_info", "guild", "guild_info"]) - async def server_info(self, ctx: Context): - """ - Returns an embed full of - server information. - """ - + async def server_info(self, ctx: Context) -> None: + """Returns an embed full of server information.""" created = time_since(ctx.guild.created_at, precision="days") features = ", ".join(ctx.guild.features) region = ctx.guild.region @@ -126,11 +114,8 @@ class Information: await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False): - """ - Returns info about a user. - """ - + async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + """Returns info about a user.""" # Do a role check if this is being executed on # someone other than the caller if user and user != ctx.author: @@ -210,7 +195,8 @@ class Information: await ctx.send(embed=embed) @user_info.error - async def user_info_command_error(self, ctx: Context, error: CommandError): + async def user_info_command_error(self, ctx: Context, error: CommandError) -> None: + """Info commands error handler.""" embed = Embed(colour=Colour.red()) if isinstance(error, BadArgument): @@ -227,6 +213,7 @@ class Information: log.exception(f"Unhandled error: {error}") -def setup(bot): +def setup(bot: Bot) -> None: + """Information cog load.""" bot.add_cog(Information(bot)) log.info("Cog loaded: Information") diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 9f0c88424..d3ff406d8 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -29,9 +29,7 @@ ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") class ModLog: - """ - Logging for server events and staff actions - """ + """Logging for server events and staff actions.""" def __init__(self, bot: Bot): self.bot = bot @@ -42,14 +40,12 @@ class ModLog: async def upload_log(self, messages: List[Message], actor_id: int) -> Optional[str]: """ - Uploads the log data to the database via - an API endpoint for uploading logs. + Uploads the log data to the database via an API endpoint for uploading logs. Used in several mod log embeds. Returns a URL that can be used to view the log. """ - response = await self.bot.api_client.post( 'bot/deleted-messages', json={ @@ -70,7 +66,8 @@ class ModLog: return f"{URLs.site_logs_view}/{response['id']}" - def ignore(self, event: Event, *items: int): + def ignore(self, event: Event, *items: int) -> None: + """Add event to ignored events to suppress log emitting.""" for item in items: if item not in self._ignored[event]: self._ignored[event].append(item) @@ -90,7 +87,8 @@ class ModLog: additional_embeds_msg: Optional[str] = None, timestamp_override: Optional[datetime] = None, footer: Optional[str] = None, - ): + ) -> None: + """Generate log embed and send to logging channel.""" embed = Embed(description=text) if title and icon_url: @@ -122,7 +120,8 @@ class ModLog: return await self.bot.get_context(log_message) # Optionally return for use with antispam - async def on_guild_channel_create(self, channel: GUILD_CHANNEL): + async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: + """Log channel create event to mod log.""" if channel.guild.id != GuildConstant.id: return @@ -146,7 +145,8 @@ class ModLog: await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) - async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): + async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: + """Log channel delete event to mod log.""" if channel.guild.id != GuildConstant.id: return @@ -167,7 +167,8 @@ class ModLog: title, message ) - async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel): + async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None: + """Log channel update event to mod log.""" if before.guild.id != GuildConstant.id: return @@ -225,7 +226,8 @@ class ModLog: "Channel updated", message ) - async def on_guild_role_create(self, role: Role): + async def on_guild_role_create(self, role: Role) -> None: + """Log role create event to mod log.""" if role.guild.id != GuildConstant.id: return @@ -234,7 +236,8 @@ class ModLog: "Role created", f"`{role.id}`" ) - async def on_guild_role_delete(self, role: Role): + async def on_guild_role_delete(self, role: Role) -> None: + """Log role delete event to mod log.""" if role.guild.id != GuildConstant.id: return @@ -243,7 +246,8 @@ class ModLog: "Role removed", f"{role.name} (`{role.id}`)" ) - async def on_guild_role_update(self, before: Role, after: Role): + async def on_guild_role_update(self, before: Role, after: Role) -> None: + """Log role update event to mod log.""" if before.guild.id != GuildConstant.id: return @@ -294,7 +298,8 @@ class ModLog: "Role updated", message ) - async def on_guild_update(self, before: Guild, after: Guild): + async def on_guild_update(self, before: Guild, after: Guild) -> None: + """Log guild update event to mod log.""" if before.id != GuildConstant.id: return @@ -343,7 +348,8 @@ class ModLog: thumbnail=after.icon_url_as(format="png") ) - async def on_member_ban(self, guild: Guild, member: Union[Member, User]): + async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None: + """Log ban event to mod log.""" if guild.id != GuildConstant.id: return @@ -358,7 +364,8 @@ class ModLog: channel_id=Channels.modlog ) - async def on_member_join(self, member: Member): + async def on_member_join(self, member: Member) -> None: + """Log member join event to user log.""" if member.guild.id != GuildConstant.id: return @@ -378,7 +385,8 @@ class ModLog: channel_id=Channels.userlog ) - async def on_member_remove(self, member: Member): + async def on_member_remove(self, member: Member) -> None: + """Log member leave event to user log.""" if member.guild.id != GuildConstant.id: return @@ -393,7 +401,8 @@ class ModLog: channel_id=Channels.userlog ) - async def on_member_unban(self, guild: Guild, member: User): + async def on_member_unban(self, guild: Guild, member: User) -> None: + """Log member unban event to mod log.""" if guild.id != GuildConstant.id: return @@ -408,7 +417,8 @@ class ModLog: channel_id=Channels.modlog ) - async def on_member_update(self, before: Member, after: Member): + async def on_member_update(self, before: Member, after: Member) -> None: + """Log member update event to user log.""" if before.guild.id != GuildConstant.id: return @@ -497,7 +507,8 @@ class ModLog: channel_id=Channels.userlog ) - async def on_message_delete(self, message: Message): + async def on_message_delete(self, message: Message) -> None: + """Log message delete event to message change log.""" channel = message.channel author = message.author @@ -548,7 +559,8 @@ class ModLog: channel_id=Channels.message_log ) - async def on_raw_message_delete(self, event: RawMessageDeleteEvent): + async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: + """Log raw message delete event to message change log.""" if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: return @@ -587,7 +599,8 @@ class ModLog: channel_id=Channels.message_log ) - async def on_message_edit(self, before: Message, after: Message): + async def on_message_edit(self, before: Message, after: Message) -> None: + """Log message edit event to message change log.""" if ( not before.guild or before.guild.id != GuildConstant.id @@ -660,7 +673,8 @@ class ModLog: channel_id=Channels.message_log, timestamp_override=after.edited_at ) - async def on_raw_message_edit(self, event: RawMessageUpdateEvent): + async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None: + """Log raw message edit event to message change log.""" try: channel = self.bot.get_channel(int(event.data["channel_id"])) message = await channel.get_message(event.message_id) @@ -729,6 +743,7 @@ class ModLog: ) -def setup(bot): +def setup(bot: Bot) -> None: + """Mod log cog load.""" bot.add_cog(ModLog(bot)) log.info("Cog loaded: ModLog") diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 56fcd63eb..2cc372afe 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -29,18 +29,18 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! class Verification: - """ - User verification and role self-management - """ + """User verification and role self-management.""" def __init__(self, bot: Bot): self.bot = bot @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, message: Message): + async def on_message(self, message: Message) -> None: + """Check new message event for messages to the checkpoint channel & process.""" if message.author.bot: return # They're a bot, ignore @@ -74,11 +74,8 @@ class Verification: @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(Roles.verified) @in_channel(Channels.verification) - async def accept_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Accept our rules and gain access to the rest of the server - """ - + async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules") try: @@ -97,11 +94,8 @@ class Verification: @command(name='subscribe') @in_channel(Channels.bot) - async def subscribe_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Subscribe to announcement notifications by assigning yourself the role - """ - + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False for role in ctx.author.roles: @@ -125,11 +119,8 @@ class Verification: @command(name='unsubscribe') @in_channel(Channels.bot) - async def unsubscribe_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Unsubscribe from announcement notifications by removing the role from yourself - """ - + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False for role in ctx.author.roles: @@ -152,17 +143,15 @@ class Verification: ) @staticmethod - def __global_check(ctx: Context): - """ - Block any command within the verification channel that is not !accept. - """ - + def __global_check(ctx: Context) -> None: + """Block any command within the verification channel that is not !accept.""" if ctx.channel.id == Channels.verification: return ctx.command.name == "accept" else: return True -def setup(bot): +def setup(bot: Bot) -> None: + """Verification cog load.""" bot.add_cog(Verification(bot)) log.info("Cog loaded: Verification") -- cgit v1.2.3 From d20905ef77c86cd348cb8a86014b68012861d3b0 Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 17:57:42 -0400 Subject: Docstring linting chunk 3 --- bot/cogs/filtering.py | 65 ++++------- bot/cogs/free.py | 9 +- bot/cogs/moderation.py | 258 +++++++++++++++----------------------------- bot/cogs/off_topic_names.py | 28 ++--- bot/cogs/reminders.py | 98 ++++++----------- bot/cogs/security.py | 13 +-- bot/cogs/snekbox.py | 12 +-- 7 files changed, 170 insertions(+), 313 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 418297fc4..b924ac265 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -30,10 +30,7 @@ ZALGO_RE = r"[\u0300-\u036F\u0489]" class Filtering: - """ - Filtering out invites, blacklisting domains, - and warning us of certain regular expressions - """ + """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" def __init__(self, bot: Bot): self.bot = bot @@ -94,26 +91,27 @@ class Filtering: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, msg: Message): + async def on_message(self, msg: Message) -> None: + """Invoke message filter for new messages.""" await self._filter_message(msg) - async def on_message_edit(self, before: Message, after: Message): + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Invoke message filter for message edits. + + If there have been multiple edits, calculate the time delta from the previous edit + """ if not before.edited_at: delta = relativedelta(after.edited_at, before.created_at).microseconds else: delta = None await self._filter_message(after, delta) - async def _filter_message(self, msg: Message, delta: Optional[int] = None): - """ - Whenever a message is sent or edited, - run it through our filters to see if it - violates any of our rules, and then respond - accordingly. - """ - + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: + """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" # Should we filter this message? role_whitelisted = False @@ -224,14 +222,10 @@ class Filtering: @staticmethod async def _has_watchlist_words(text: str) -> bool: """ - Returns True if the text contains - one of the regular expressions from the - word_watchlist in our filter config. + Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. - Only matches words with boundaries before - and after the expression. + Only matches words with boundaries before and after the expression. """ - for expression in Filter.word_watchlist: if re.search(fr"\b{expression}\b", text, re.IGNORECASE): return True @@ -241,14 +235,10 @@ class Filtering: @staticmethod async def _has_watchlist_tokens(text: str) -> bool: """ - Returns True if the text contains - one of the regular expressions from the - token_watchlist in our filter config. + Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. - This will match the expression even if it - does not have boundaries before and after + This will match the expression even if it does not have boundaries before and after. """ - for expression in Filter.token_watchlist: if re.search(fr"{expression}", text, re.IGNORECASE): @@ -260,11 +250,7 @@ class Filtering: @staticmethod async def _has_urls(text: str) -> bool: - """ - Returns True if the text contains one of - the blacklisted URLs from the config file. - """ - + """Returns True if the text contains one of the blacklisted URLs from the config file.""" if not re.search(URL_RE, text, re.IGNORECASE): return False @@ -283,7 +269,6 @@ class Filtering: Zalgo range is \u0300 – \u036F and \u0489. """ - return bool(re.search(ZALGO_RE, text)) async def _has_invites(self, text: str) -> Union[dict, bool]: @@ -295,7 +280,6 @@ class Filtering: Attempts to catch some of common ways to try to cheat the system. """ - # Remove backslashes to prevent escape character aroundfuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") @@ -336,30 +320,27 @@ class Filtering: return invite_data if invite_data else False @staticmethod - async def _has_rich_embed(msg: Message): - """ - Returns True if any of the embeds in the message are of type 'rich', but are not twitter - embeds. Returns False otherwise. - """ + async def _has_rich_embed(msg: Message) -> bool: + """Returns True if any of the embeds in the message are of type 'rich', but are not twitter embeds.""" if msg.embeds: for embed in msg.embeds: if embed.type == "rich" and (not embed.url or "twitter.com" not in embed.url): return True return False - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel): + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: """ - Notify filtered_member about a moderation action with the reason str + Notify filtered_member about a moderation action with the reason str. First attempts to DM the user, fall back to in-channel notification if user has DMs disabled """ - try: await filtered_member.send(reason) except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Filtering cog load.""" bot.add_cog(Filtering(bot)) log.info("Cog loaded: Filtering") diff --git a/bot/cogs/free.py b/bot/cogs/free.py index fd6009bb8..ccc722e66 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -2,7 +2,7 @@ import logging from datetime import datetime from discord import Colour, Embed, Member, utils -from discord.ext.commands import Context, command +from discord.ext.commands import Bot, Context, command from bot.constants import Categories, Channels, Free, STAFF_ROLES from bot.decorators import redirect_output @@ -22,11 +22,9 @@ class Free: @command(name="free", aliases=('f',)) @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def free(self, ctx: Context, user: Member = None, seek: int = 2): + async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: """ Lists free help channels by likeliness of availability. - :param user: accepts user mention, ID, etc. - :param seek: How far back to check the last active message. seek is used only when this command is invoked in a help channel. You cannot override seek without mentioning a user first. @@ -101,6 +99,7 @@ class Free: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Free cog load.""" bot.add_cog(Free()) log.info("Cog loaded: Free") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 1dc2c70d6..28956e636 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -33,6 +33,7 @@ APPEALABLE_INFRACTIONS = ("Ban", "Mute") def proxy_user(user_id: str) -> Object: + """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" try: user_id = int(user_id) except ValueError: @@ -47,9 +48,7 @@ UserTypes = Union[Member, User, proxy_user] class Moderation(Scheduler): - """ - Server moderation tools. - """ + """Server moderation tools.""" def __init__(self, bot: Bot): self.bot = bot @@ -58,10 +57,11 @@ class Moderation(Scheduler): @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_ready(self): - # Schedule expiration for previous infractions + async def on_ready(self) -> None: + """Schedule expiration for previous infractions.""" infractions = await self.bot.api_client.get( 'bot/infractions', params={'active': 'true'} ) @@ -73,14 +73,13 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ Create a warning infraction in the database for a user. **`user`:** Accepts user mention, ID, etc. **`reason`:** The reason for the warning. """ - response_object = await post_infraction(ctx, user, type="warning", reason=reason) if response_object is None: return @@ -123,14 +122,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def kick(self, ctx: Context, user: Member, *, reason: str = None): - """ - Kicks a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the kick. - """ - + async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Kicks a user with the provided reason.""" if not await self.respect_role_hierarchy(ctx, user, 'kick'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -183,14 +176,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None): - """ - Create a permanent ban infraction in the database for a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the ban. - """ - + async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + """Create a permanent ban infraction for a user with the provided reason.""" if not await self.respect_role_hierarchy(ctx, user, 'ban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -260,14 +247,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def mute(self, ctx: Context, user: Member, *, reason: str = None): - """ - Create a permanent mute infraction in the database for a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the mute. - """ - + async def mute(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Create a permanent mute infraction for a user with the provided reason.""" active_mutes = await self.bot.api_client.get( 'bot/infractions', params={ @@ -334,15 +315,12 @@ class Moderation(Scheduler): async def tempmute( self, ctx: Context, user: Member, expiration: ExpirationDate, *, reason: str = None - ): + ) -> None: """ - Create a temporary mute infraction in the database for a user. + Create a temporary mute infraction for a user with the provided expiration and reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary mute infraction - **`reason`:** The reason for the temporary mute. + Duration strings are parsed per: http://strftime.org/ """ - active_mutes = await self.bot.api_client.get( 'bot/infractions', params={ @@ -416,15 +394,12 @@ class Moderation(Scheduler): @command() async def tempban( self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None - ): + ) -> None: """ - Create a temporary ban infraction in the database for a user. + Create a temporary ban infraction for a user with the provided expiration and reason. - **`user`:** Accepts user mention, ID, etc. - **`expiry`:** The duration for the temporary ban infraction - **`reason`:** The reason for the temporary ban. + Duration strings are parsed per: http://strftime.org/ """ - if not await self.respect_role_hierarchy(ctx, user, 'tempban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -510,14 +485,12 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn']) - async def note(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def note(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ - Create a private infraction note in the database for a user. + Create a private infraction note in the database for a user with the provided reason. - **`user`:** accepts user mention, ID, etc. - **`reason`:** The reason for the warning. + This does not send the user a notification """ - response_object = await post_infraction( ctx, user, type="warning", reason=reason, hidden=True ) @@ -545,14 +518,12 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ - Kicks a user. + Kick a user for the provided reason. - **`user`:** accepts user mention, ID, etc. - **`reason`:** The reason for the kick. + This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowkick'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -598,14 +569,12 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ - Create a permanent ban infraction in the database for a user. + Create a permanent ban infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the ban. + This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -652,14 +621,12 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowmute', 'smute']) - async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ - Create a permanent mute infraction in the database for a user. + Create a permanent mute infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the mute. + This does not send the user a notification. """ - response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) if response_object is None: return @@ -692,15 +659,14 @@ class Moderation(Scheduler): @command(hidden=True, aliases=["shadowtempmute, stempmute"]) async def shadow_tempmute( self, ctx: Context, user: Member, duration: str, *, reason: str = None - ): + ) -> None: """ - Create a temporary mute infraction in the database for a user. + Create a temporary mute infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary mute infraction - **`reason`:** The reason for the temporary mute. - """ + Duration strings are parsed per: http://strftime.org/ + This does not send the user a notification. + """ response_object = await post_infraction( ctx, user, type="mute", reason=reason, duration=duration, hidden=True ) @@ -741,15 +707,14 @@ class Moderation(Scheduler): @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None - ): + ) -> None: """ - Create a temporary ban infraction in the database for a user. + Create a temporary ban infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary ban infraction - **`reason`:** The reason for the temporary ban. - """ + Duration strings are parsed per: http://strftime.org/ + This does not send the user a notification. + """ if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method @@ -811,13 +776,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: Member): - """ - Deactivates the active mute infraction for a user. - - **`user`:** Accepts user mention, ID, etc. - """ - + async def unmute(self, ctx: Context, user: Member) -> None: + """Deactivates the active mute infraction for a user.""" try: # check the current active infraction response = await self.bot.api_client.get( @@ -881,13 +841,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unban(self, ctx: Context, user: UserTypes): - """ - Deactivates the active ban infraction for a user. - - **`user`:** Accepts user mention, ID, etc. - """ - + async def unban(self, ctx: Context, user: UserTypes) -> None: + """Deactivates the active ban infraction for a user.""" try: # check the current active infraction response = await self.bot.api_client.get( @@ -938,16 +893,14 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context): + async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") @with_role(*MODERATION_ROLES) @infraction_group.group(name='edit', invoke_without_command=True) - async def infraction_edit_group(self, ctx: Context): + async def infraction_edit_group(self, ctx: Context) -> None: """Infraction editing commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction", "edit") @with_role(*MODERATION_ROLES) @@ -955,15 +908,12 @@ class Moderation(Scheduler): async def edit_duration( self, ctx: Context, infraction_id: int, expires_at: Union[ExpirationDate, str] - ): + ) -> None: """ Sets the duration of the given infraction, relative to the time of updating. - **`infraction_id`:** the id of the infraction - **`expires_at`:** the new expiration date of the infraction. - Use "permanent" to mark the infraction as permanent. + Duration strings are parsed per: http://strftime.org/, use "permanent" to mark the infraction as permanent. """ - if isinstance(expires_at, str) and expires_at != 'permanent': raise BadArgument( "If `expires_at` is given as a non-datetime, " @@ -1043,13 +993,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str): - """ - Sets the reason of the given infraction. - **`infraction_id`:** the id of the infraction - **`reason`:** The new reason of the infraction - """ - + async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str) -> None: + """Edit the reason of the given infraction.""" try: old_infraction = await self.bot.api_client.get( 'bot/infractions/' + str(infraction_id) @@ -1099,11 +1044,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery): - """ - Searches for infractions in the database. - """ - + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" if isinstance(query, User): await ctx.invoke(self.search_user, query) @@ -1112,11 +1054,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: Union[User, proxy_user]): - """ - Search for infractions by member. - """ - + async def search_user(self, ctx: Context, user: Union[User, proxy_user]) -> None: + """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions', params={'user__id': str(user.id)} @@ -1129,11 +1068,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str): - """ - Search for infractions by their reason. Use Re2 for matching. - """ - + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" infraction_list = await self.bot.api_client.get( 'bot/infractions', params={'search': reason} ) @@ -1146,8 +1082,8 @@ class Moderation(Scheduler): # endregion # region: Utility functions - async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list): - + async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list) -> None: + """Send a paginated embed of infractions for the specified user.""" if not infractions: await ctx.send(f":warning: No infractions could be found for that query.") return @@ -1169,14 +1105,8 @@ class Moderation(Scheduler): # endregion # region: Utility functions - def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): - """ - Schedules a task to expire a temporary infraction. - - :param loop: the asyncio event loop - :param infraction_object: the infraction object to expire at the end of the task - """ - + def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict) -> None: + """Schedules a task to expire a temporary infraction.""" infraction_id = infraction_object["id"] if infraction_id in self.scheduled_tasks: return @@ -1185,12 +1115,8 @@ class Moderation(Scheduler): self.scheduled_tasks[infraction_id] = task - def cancel_expiration(self, infraction_id: str): - """ - Un-schedules a task set to expire a temporary infraction. - :param infraction_id: the ID of the infraction in question - """ - + def cancel_expiration(self, infraction_id: str) -> None: + """Un-schedules a task set to expire a temporary infraction.""" task = self.scheduled_tasks.get(infraction_id) if task is None: log.warning(f"Failed to unschedule {infraction_id}: no task found.") @@ -1199,15 +1125,13 @@ class Moderation(Scheduler): log.debug(f"Unscheduled {infraction_id}.") del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction_object: dict): + async def _scheduled_task(self, infraction_object: dict) -> None: """ - A co-routine which marks an infraction as expired after the delay from the time of - scheduling to the time of expiration. At the time of expiration, the infraction is - marked as inactive on the website, and the expiration task is cancelled. + Marks an infraction expired after the delay from time of scheduling to time of expiration. - :param infraction_object: the infraction in question + At the time of expiration, the infraction is marked as inactive on the website, and the + expiration task is cancelled. The user is then notified via DM. """ - infraction_id = infraction_object["id"] # transform expiration to delay in seconds @@ -1229,14 +1153,12 @@ class Moderation(Scheduler): icon_url=Icons.user_unmute ) - async def _deactivate_infraction(self, infraction_object): + async def _deactivate_infraction(self, infraction_object: dict) -> None: """ A co-routine which marks an infraction as inactive on the website. - This co-routine does not cancel or un-schedule an expiration task. - :param infraction_object: the infraction in question + This co-routine does not cancel or un-schedule an expiration task. """ - guild: Guild = self.bot.get_guild(constants.Guild.id) user_id = infraction_object["user"] infraction_type = infraction_object["type"] @@ -1258,7 +1180,8 @@ class Moderation(Scheduler): json={"active": False} ) - def _infraction_to_string(self, infraction_object): + def _infraction_to_string(self, infraction_object: dict) -> str: + """Convert the infraction object to a string representation.""" actor_id = infraction_object["actor"] guild: Guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) @@ -1285,16 +1208,12 @@ class Moderation(Scheduler): async def notify_infraction( self, user: Union[User, Member], infr_type: str, expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided." - ): + ) -> bool: """ - Notify a user of their fresh infraction :) + Attempt to notify a user, via DM, of their fresh infraction. - :param user: The user to send the message to. - :param infr_type: The type of infraction, as a string. - :param duration: The duration of the infraction. - :param reason: The reason for the infraction. + Optionally returns a boolean indicator of whether the DM was successful. """ - if isinstance(expires_at, datetime): expires_at = expires_at.strftime('%c') @@ -1320,16 +1239,12 @@ class Moderation(Scheduler): async def notify_pardon( self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified - ): + ) -> bool: """ - Notify a user that an infraction has been lifted. + Attempt to notify a user, via DM, of their expired infraction. - :param user: The user to send the message to. - :param title: The title of the embed. - :param content: The content of the embed. - :param icon_url: URL for the title icon. + Optionally returns a boolean indicator of whether the DM was successful. """ - embed = Embed( description=content, colour=Colour(Colours.soft_green) @@ -1339,14 +1254,12 @@ class Moderation(Scheduler): return await self.send_private_embed(user, embed) - async def send_private_embed(self, user: Union[User, Member], embed: Embed): + async def send_private_embed(self, user: Union[User, Member], embed: Embed) -> bool: """ A helper method for sending an embed to a user's DMs. - :param user: The user to send the embed to. - :param embed: The embed to send. + Returns a boolean indicator of DM success. """ - # sometimes `user` is a `discord.Object`, so let's make it a proper user. user = await self.bot.get_user_info(user.id) @@ -1360,7 +1273,8 @@ class Moderation(Scheduler): ) return False - async def log_notify_failure(self, target: str, actor: Member, infraction_type: str): + async def log_notify_failure(self, target: str, actor: Member, infraction_type: str) -> None: + """Send a mod log entry if an attempt to DM the target user has failed.""" await self.mod_log.send_log_message( icon_url=Icons.token_removed, content=actor.mention, @@ -1374,7 +1288,8 @@ class Moderation(Scheduler): # endregion - async def __error(self, ctx, error): + async def __error(self, ctx: Context, error: Exception) -> None: + """Send a notification to the invoking context on a Union failure.""" if isinstance(error, BadUnionArgument): if User in error.converters: await ctx.send(str(error.errors[0])) @@ -1382,15 +1297,11 @@ class Moderation(Scheduler): async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool: """ Check if the highest role of the invoking member is greater than that of the target member. + If this check fails, a warning is sent to the invoking ctx. Returns True always if target is not a discord.Member instance. - - :param ctx: The command context when invoked. - :param target: The target of the infraction. - :param infr_type: The type of infraction. """ - if not isinstance(target, Member): return True @@ -1409,6 +1320,7 @@ class Moderation(Scheduler): return target_is_lower -def setup(bot): +def setup(bot: Bot) -> None: + """Moderation cog load.""" bot.add_cog(Moderation(bot)) log.info("Cog loaded: Moderation") diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index c0d2e5dc5..3849d3d59 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -18,7 +18,8 @@ class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" @staticmethod - async def convert(ctx: Context, argument: str): + async def convert(ctx: Context, argument: str) -> None: + """Attempt to replace any invalid characters with their approximate unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" if not (2 <= len(argument) <= 96): @@ -37,7 +38,7 @@ class OffTopicName(Converter): return argument.translate(table) -async def update_names(bot: Bot, headers: dict): +async def update_names(bot: Bot, headers: dict) -> None: """ The background updater task that performs a channel name update daily. @@ -46,7 +47,6 @@ async def update_names(bot: Bot, headers: dict): The running bot instance, used for fetching data from the website via the bot's `api_client`. """ - while True: # Since we truncate the compute timedelta to seconds, we add one second to ensure # we go past midnight in the `seconds_to_sleep` set below. @@ -77,27 +77,27 @@ class OffTopicNames: self.headers = {"X-API-KEY": Keys.site_api} self.updater_task = None - def __cleanup(self): + def __cleanup(self) -> None: + """Cancel any leftover running updater task.""" if self.updater_task is not None: self.updater_task.cancel() - async def on_ready(self): + async def on_ready(self) -> None: + """Start off-topic channel updating event loop if it hasn't already started.""" if self.updater_task is None: coro = update_names(self.bot, self.headers) self.updater_task = self.bot.loop.create_task(coro) @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) @with_role(*MODERATION_ROLES) - async def otname_group(self, ctx): + async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.invoke(self.bot.get_command("help"), "otname") @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) - async def add_command(self, ctx, name: OffTopicName): + async def add_command(self, ctx: Context, name: OffTopicName) -> None: """Adds a new off-topic name to the rotation.""" - await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) log.info( f"{ctx.author.name}#{ctx.author.discriminator}" @@ -107,9 +107,8 @@ class OffTopicNames: @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx, name: OffTopicName): + async def delete_command(self, ctx: Context, name: OffTopicName) -> None: """Removes a off-topic name from the rotation.""" - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') log.info( f"{ctx.author.name}#{ctx.author.discriminator}" @@ -119,12 +118,12 @@ class OffTopicNames: @otname_group.command(name='list', aliases=('l',)) @with_role(*MODERATION_ROLES) - async def list_command(self, ctx): + async def list_command(self, ctx: Context) -> None: """ Lists all currently known off-topic channel names in a paginator. + Restricted to Moderator and above to not spoil the surprise. """ - result = await self.bot.api_client.get('bot/off-topic-channel-names') lines = sorted(f"• {name}" for name in result) embed = Embed( @@ -138,6 +137,7 @@ class OffTopicNames: await ctx.send(embed=embed) -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Off topic names cog load.""" bot.add_cog(OffTopicNames(bot)) log.info("Cog loaded: OffTopicNames") diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 03ea00de8..d9f6f6536 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -23,13 +23,14 @@ MAXIMUM_REMINDERS = 5 class Reminders(Scheduler): + """Provide in-channel reminder functionality.""" def __init__(self, bot: Bot): self.bot = bot super().__init__() - async def on_ready(self): - # Get all the current reminders for re-scheduling + async def on_ready(self) -> None: + """Reschedule all current reminders.""" response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} @@ -50,25 +51,16 @@ class Reminders(Scheduler): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, on_success: str): - """ - Send an embed confirming the change was made successfully. - """ - + async def _send_confirmation(ctx: Context, on_success: str) -> None: + """Send an embed confirming the reminder change was made successfully.""" embed = Embed() embed.colour = Colour.green() embed.title = random.choice(POSITIVE_REPLIES) embed.description = on_success await ctx.send(embed=embed) - async def _scheduled_task(self, reminder: dict): - """ - A coroutine which sends the reminder once the time is reached. - - :param reminder: the data of the reminder. - :return: - """ - + async def _scheduled_task(self, reminder: dict) -> None: + """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1]) @@ -82,38 +74,22 @@ class Reminders(Scheduler): # Now we can begone with it from our schedule list. self.cancel_task(reminder_id) - async def _delete_reminder(self, reminder_id: str): - """ - Delete a reminder from the database, given its ID. - - :param reminder_id: The ID of the reminder. - """ - + async def _delete_reminder(self, reminder_id: str) -> None: + """Delete a reminder from the database, given its ID, and cancels the running task.""" await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) # Now we can remove it from the schedule list self.cancel_task(reminder_id) - async def _reschedule_reminder(self, reminder): - """ - Reschedule a reminder object. - - :param reminder: The reminder to be rescheduled. - """ - + async def _reschedule_reminder(self, reminder: dict) -> None: + """Reschedule a reminder object.""" loop = asyncio.get_event_loop() self.cancel_task(reminder["id"]) self.schedule_task(loop, reminder["id"], reminder) - async def send_reminder(self, reminder, late: relativedelta = None): - """ - Send the reminder. - - :param reminder: The data about the reminder. - :param late: How late the reminder is (if at all) - """ - + async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + """Send the reminder.""" channel = self.bot.get_channel(reminder["channel_id"]) user = self.bot.get_user(reminder["author"]) @@ -140,19 +116,17 @@ class Reminders(Scheduler): await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) - async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str): - """ - Commands for managing your reminders. - """ - + async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None: + """Commands for managing your reminders.""" await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str): + async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None: """ Set yourself a simple reminder. - """ + Expiration is parsed per: http://strftime.org/ + """ embed = Embed() # If the user is not staff, we need to verify whether or not to make a reminder at all. @@ -203,11 +177,8 @@ class Reminders(Scheduler): self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context): - """ - View a paginated embed of all reminders for your user. - """ - + async def list_reminders(self, ctx: Context) -> None: + """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( 'bot/reminders', @@ -259,19 +230,17 @@ class Reminders(Scheduler): ) @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context): - """ - Commands for modifying your current reminders. - """ - + async def edit_reminder_group(self, ctx: Context) -> None: + """Commands for modifying your current reminders.""" await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate): + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate) -> None: """ Edit one of your reminders' expiration. - """ + Expiration is parsed per: http://strftime.org/ + """ # Send the request to update the reminder in the database reminder = await self.bot.api_client.patch( 'bot/reminders/' + str(id_), @@ -286,11 +255,8 @@ class Reminders(Scheduler): await self._reschedule_reminder(reminder) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str): - """ - Edit one of your reminders' content. - """ - + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + """Edit one of your reminders' content.""" # Send the request to update the reminder in the database reminder = await self.bot.api_client.patch( 'bot/reminders/' + str(id_), @@ -304,17 +270,15 @@ class Reminders(Scheduler): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove",)) - async def delete_reminder(self, ctx: Context, id_: int): - """ - Delete one of your active reminders. - """ - + async def delete_reminder(self, ctx: Context, id_: int) -> None: + """Delete one of your active reminders.""" await self._delete_reminder(id_) await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!" ) -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Reminders cog load.""" bot.add_cog(Reminders(bot)) log.info("Cog loaded: Reminders") diff --git a/bot/cogs/security.py b/bot/cogs/security.py index f4a843fbf..7ada9a4f6 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -6,22 +6,23 @@ log = logging.getLogger(__name__) class Security: - """ - Security-related helpers - """ + """Security-related helpers.""" def __init__(self, bot: Bot): self.bot = bot self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM - def check_not_bot(self, ctx: Context): + def check_not_bot(self, ctx: Context) -> bool: + """Check if Context instance author is not a bot.""" return not ctx.author.bot - def check_on_guild(self, ctx: Context): + def check_on_guild(self, ctx: Context) -> bool: + """Check if Context instance has a guild attribute.""" return ctx.guild is not None -def setup(bot): +def setup(bot: Bot) -> None: + """Security cog load.""" bot.add_cog(Security(bot)) log.info("Cog loaded: Security") diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 05834e421..5a5e26655 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -41,9 +41,7 @@ MAX_PASTE_LEN = 1000 class Snekbox: - """ - Safe evaluation of Python code using Snekbox - """ + """Safe evaluation of Python code using Snekbox.""" def __init__(self, bot: Bot): self.bot = bot @@ -173,7 +171,7 @@ class Snekbox: @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None): + async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. @@ -225,7 +223,8 @@ class Snekbox: del self.jobs[ctx.author.id] @eval_command.error - async def eval_command_error(self, ctx: Context, error: CommandError): + async def eval_command_error(self, ctx: Context, error: CommandError) -> None: + """Eval commands error handler.""" embed = Embed(colour=Colour.red()) if isinstance(error, NoPrivateMessage): @@ -246,6 +245,7 @@ class Snekbox: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Snekbox cog load.""" bot.add_cog(Snekbox(bot)) log.info("Cog loaded: Snekbox") -- cgit v1.2.3 From 77c9b0f2194072b19616bad4ce81f72ebcdad27d Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 18:49:30 -0400 Subject: Docstring linting chunk 4 --- bot/cogs/bot.py | 71 ++++++++++--------------- bot/cogs/doc.py | 133 +++++++++++++++++++--------------------------- bot/cogs/error_handler.py | 6 ++- bot/cogs/eval.py | 22 ++++---- bot/cogs/jams.py | 13 ++--- bot/cogs/site.py | 28 ++++------ bot/cogs/utils.py | 28 ++++------ 7 files changed, 125 insertions(+), 176 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 828e2514c..be7922ef9 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -2,6 +2,7 @@ import ast import logging import re import time +from typing import Optional, Tuple, Union from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Context, command, group @@ -17,9 +18,7 @@ log = logging.getLogger(__name__) class Bot: - """ - Bot information commands - """ + """Bot information commands.""" def __init__(self, bot: Bot): self.bot = bot @@ -46,20 +45,14 @@ class Bot: @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) - async def bot_group(self, ctx: Context): - """ - Bot informational commands - """ - + async def bot_group(self, ctx: Context) -> None: + """Bot informational commands.""" await ctx.invoke(self.bot.get_command("help"), "bot") @bot_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) - async def about_command(self, ctx: Context): - """ - Get information about the bot - """ - + async def about_command(self, ctx: Context) -> None: + """Get information about the bot.""" embed = Embed( description="A utility bot designed just for the Python server! Try `!help` for more info.", url="https://gitlab.com/discord-python/projects/bot" @@ -77,24 +70,18 @@ class Bot: @command(name='echo', aliases=('print',)) @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, *, text: str): - """ - Send the input verbatim to the current channel - """ - + async def echo_command(self, ctx: Context, *, text: str) -> None: + """Send the input verbatim to the current channel.""" await ctx.send(text) @command(name='embed') @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, *, text: str): - """ - Send the input within an embed to the current channel - """ - + async def embed_command(self, ctx: Context, *, text: str) -> None: + """Send the input within an embed to the current channel.""" embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool): + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Union[Tuple[Tuple[str, Optional[str]], str], None]: """ Strip msg in order to find Python code. @@ -163,15 +150,10 @@ class Bot: log.trace(f"Returning message.\n\n{content}\n\n") return (content,), repl_code - def fix_indentation(self, msg: str): - """ - Attempts to fix badly indented code. - """ - - def unindent(code, skip_spaces=0): - """ - Unindents all code down to the number of spaces given ins skip_spaces - """ + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" final = "" current = code[0] leading_spaces = 0 @@ -207,11 +189,13 @@ class Bot: msg = f"{first_line}\n{unindent(code, 4)}" return msg - def repl_stripping(self, msg: str): + def repl_stripping(self, msg: str) -> Tuple[str, bool]: """ Strip msg in order to extract Python code out of REPL output. Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns a second boolean output if REPL code was found in the input msg. """ final = "" for line in msg.splitlines(keepends=True): @@ -225,7 +209,8 @@ class Bot: log.trace(f"Found REPL code in \n\n{msg}\n\n") return final.rstrip(), True - def has_bad_ticks(self, msg: Message): + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" not_backticks = [ "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", @@ -234,13 +219,13 @@ class Bot: return msg.content[:3] in not_backticks - async def on_message(self, msg: Message): - """ - Detect poorly formatted Python code and send the user - a helpful message explaining how to do properly - formatted Python syntax highlighting codeblocks. + async def on_message(self, msg: Message) -> None: """ + Detect poorly formatted Python code in new messages. + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ parse_codeblock = ( ( msg.channel.id in self.channel_cooldowns @@ -355,7 +340,8 @@ class Bot: f"The message that was posted was:\n\n{msg.content}\n\n" ) - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" if ( # Checks to see if the message was called out by the bot payload.message_id not in self.codeblock_message_ids @@ -381,6 +367,7 @@ class Bot: log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") -def setup(bot): +def setup(bot: Bot) -> None: + """Bot cog load.""" bot.add_cog(Bot(bot)) log.info("Cog loaded: Bot") diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index aa49b0c25..ef14d1797 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -4,10 +4,11 @@ import logging import re import textwrap from collections import OrderedDict -from typing import Optional, Tuple +from typing import Callable, Optional, Tuple import discord from bs4 import BeautifulSoup +from bs4.element import PageElement from discord.ext import commands from markdownify import MarkdownConverter from requests import ConnectionError @@ -27,24 +28,22 @@ UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶') WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") -def async_cache(max_size=128, arg_offset=0): +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. - :param max_size: - Specifies the maximum size the cache should have. - Once it exceeds the maximum size, keys are deleted in FIFO order. - :param arg_offset: - The offset that should be applied to the coroutine's arguments - when creating the cache key. Defaults to `0`. - """ + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ # Assign the cache to the function itself so we can clear it from outside. async_cache.cache = OrderedDict() - def decorator(function): + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" @functools.wraps(function) - async def wrapper(*args): + async def wrapper(*args) -> OrderedDict: + """Decorator wrapper for the caching logic.""" key = ':'.join(args[arg_offset:]) value = async_cache.cache.get(key) @@ -59,27 +58,25 @@ def async_cache(max_size=128, arg_offset=0): class DocMarkdownConverter(MarkdownConverter): - def convert_code(self, el, text): - """Undo `markdownify`s underscore escaping.""" + """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + def convert_code(self, el: PageElement, text: str) -> str: + """Undo `markdownify`s underscore escaping.""" return f"`{text}`".replace('\\', '') - def convert_pre(self, el, text): + def convert_pre(self, el: PageElement, text: str) -> str: """Wrap any codeblocks in `py` for syntax highlighting.""" - code = ''.join(el.strings) return f"```py\n{code}```" -def markdownify(html): +def markdownify(html: str) -> DocMarkdownConverter: + """Create a DocMarkdownConverter object from the input html.""" return DocMarkdownConverter(bullets='•').convert(html) class DummyObject(object): - """ - A dummy object which supports assigning anything, - which the builtin `object()` does not support normally. - """ + """A dummy object which supports assigning anything, which the builtin `object()` does not support normally.""" class SphinxConfiguration: @@ -94,14 +91,15 @@ class InventoryURL(commands.Converter): """ Represents an Intersphinx inventory URL. - This converter checks whether intersphinx - accepts the given inventory URL, and raises + This converter checks whether intersphinx accepts the given inventory URL, and raises `BadArgument` if that is not the case. + Otherwise, it simply passes through the given URL. """ @staticmethod - async def convert(ctx, url: str): + async def convert(ctx: commands.Context, url: str) -> str: + """Convert url to Intersphinx inventory URL.""" try: intersphinx.fetch_inventory(SphinxConfiguration(), '', url) except AttributeError: @@ -121,30 +119,32 @@ class InventoryURL(commands.Converter): class Doc: - def __init__(self, bot): + """A set of commands for querying & displaying documentation.""" + + def __init__(self, bot: commands.Bot): self.base_urls = {} self.bot = bot self.inventories = {} - async def on_ready(self): + async def on_ready(self) -> None: + """Refresh documentation inventory.""" await self.refresh_inventory() async def update_single( self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration - ): + ) -> None: """ Rebuild the inventory for a single package. - :param package_name: The package name to use, appears in the log. - :param base_url: The root documentation URL for the specified package. - Used to build absolute paths that link to specific symbols. - :param inventory_url: The absolute URL to the intersphinx inventory. - Fetched by running `intersphinx.fetch_inventory` in an - executor on the bot's event loop. - :param config: A `SphinxConfiguration` instance to mock the regular sphinx - project layout. Required for use with intersphinx. + Where: + * `package_name` is the package name to use, appears in the log + * `base_url` is the root documentation URL for the specified package, used to build + absolute paths that link to specific symbols + * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running + `intersphinx.fetch_inventory` in an executor on the bot's event loop + * `config` is a `SphinxConfiguration` instance to mock the regular sphinx + project layout, required for use with intersphinx """ - self.base_urls[package_name] = base_url fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) @@ -158,7 +158,8 @@ class Doc: log.trace(f"Fetched inventory for {package_name}.") - async def refresh_inventory(self): + async def refresh_inventory(self) -> None: + """Refresh internal documentation inventory.""" log.debug("Refreshing documentation inventory...") # Clear the old base URLS and inventories to ensure @@ -185,16 +186,13 @@ class Doc: """ Given a Python symbol, return its signature and description. - :param symbol: The symbol for which HTML data should be returned. - :return: - A tuple in the form (str, str), or `None`. - The first tuple element is the signature of the given - symbol as a markup-free string, and the second tuple - element is the description of the given symbol with HTML - markup included. If the given symbol could not be found, - returns `None`. - """ + Returns a tuple in the form (str, str), or `None`. + The first tuple element is the signature of the given symbol as a markup-free string, and + the second tuple element is the description of the given symbol with HTML markup included. + + If the given symbol could not be found, returns `None`. + """ url = self.inventories.get(symbol) if url is None: return None @@ -222,16 +220,10 @@ class Doc: @async_cache(arg_offset=1) async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: """ - Using `get_symbol_html`, attempt to scrape and - fetch the data for the given `symbol`, and build - a formatted embed out of its contents. - - :param symbol: The symbol for which the embed should be returned - :return: - If the symbol is known, an Embed with documentation about it. - Otherwise, `None`. - """ + Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. + If the symbol is known, an Embed with documentation about it is returned. + """ scraped_html = await self.get_symbol_html(symbol) if scraped_html is None: return None @@ -266,20 +258,16 @@ class Doc: ) @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx, symbol: commands.clean_content = None): + async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command) @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx, symbol: commands.clean_content = None): + async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """ Return a documentation embed for a given symbol. - If no symbol is given, return a list of all available inventories. - :param ctx: Discord message context - :param symbol: The symbol for which documentation should be returned, - or nothing to get a list of all inventories + If no symbol is given, return a list of all available inventories. Examples: !docs @@ -287,7 +275,6 @@ class Doc: !docs aiohttp.ClientSession !docs get aiohttp.ClientSession """ - if symbol is None: inventory_embed = discord.Embed( title=f"All inventories (`{len(self.base_urls)}` total)", @@ -321,18 +308,13 @@ class Doc: @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) async def set_command( - self, ctx, package_name: ValidPythonIdentifier, + self, ctx: commands.Context, package_name: ValidPythonIdentifier, base_url: ValidURL, inventory_url: InventoryURL - ): + ) -> None: """ Adds a new documentation metadata object to the site's database. - The database will update the object, should an existing item - with the specified `package_name` already exist. - :param ctx: Discord message context - :param package_name: The package name, for example `aiohttp`. - :param base_url: The package documentation's root URL, used to build absolute links. - :param inventory_url: The intersphinx inventory URL. + The database will update the object, should an existing item with the specified `package_name` already exist. Example: !docs set \ @@ -340,7 +322,6 @@ class Doc: https://discordpy.readthedocs.io/en/rewrite/ \ https://discordpy.readthedocs.io/en/rewrite/objects.inv """ - body = { 'package': package_name, 'base_url': base_url, @@ -364,17 +345,13 @@ class Doc: @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx, package_name: ValidPythonIdentifier): + async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: """ Removes the specified package from the database. - :param ctx: Discord message context - :param package_name: The package name, for example `aiohttp`. - Examples: !docs delete aiohttp """ - await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') async with ctx.typing(): @@ -384,5 +361,7 @@ class Doc: await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") -def setup(bot): +def setup(bot: commands.Bot) -> None: + """Doc cog load.""" bot.add_cog(Doc(bot)) + log.info("Cog loaded: Doc") diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 2063df09d..f8a27fda8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -24,7 +24,8 @@ class ErrorHandler: def __init__(self, bot: Bot): self.bot = bot - async def on_command_error(self, ctx: Context, e: CommandError): + async def on_command_error(self, ctx: Context, e: CommandError) -> None: + """Provide command error handling.""" command = ctx.command parent = None @@ -87,6 +88,7 @@ class ErrorHandler: raise e -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Error handler cog load.""" bot.add_cog(ErrorHandler(bot)) log.info("Cog loaded: Events") diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 8e97a35a2..5fbd9dca5 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -6,6 +6,7 @@ import re import textwrap import traceback from io import StringIO +from typing import Any, Tuple, Union import discord from discord.ext.commands import Bot, group @@ -18,10 +19,7 @@ log = logging.getLogger(__name__) class CodeEval: - """ - Owner and admin feature that evaluates code - and returns the result to the channel. - """ + """Owner and admin feature that evaluates code and returns the result to the channel.""" def __init__(self, bot: Bot): self.bot = bot @@ -31,7 +29,8 @@ class CodeEval: self.interpreter = Interpreter(bot) - def _format(self, inp, out): # (str, Any) -> (str, discord.Embed) + def _format(self, inp: str, out: Any) -> Tuple[str, Union[discord.embed, None]]: + """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out res = "" @@ -124,7 +123,8 @@ class CodeEval: return res # Return (text, embed) - async def _eval(self, ctx, code): # (discord.Context, str) -> None + async def _eval(self, ctx: discord.Context, code: str) -> None: + """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 if code.startswith("exit"): @@ -174,16 +174,15 @@ async def func(): # (None,) -> Any @group(name='internal', aliases=('int',)) @with_role(Roles.owner, Roles.admin) - async def internal_group(self, ctx): + async def internal_group(self, ctx: discord.Context) -> None: """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin, Roles.owner) - async def eval(self, ctx, *, code: str): - """ Run eval in a REPL-like format. """ + async def eval(self, ctx: discord.Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" code = code.strip("`") if re.match('py(thon)?\n', code): code = "\n".join(code.split("\n")[1:]) @@ -197,6 +196,7 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) -def setup(bot): +def setup(bot: Bot) -> None: + """Code eval cog load.""" bot.add_cog(CodeEval(bot)) log.info("Cog loaded: Eval") diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 96b98e559..f7a5896c0 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -10,9 +10,7 @@ log = logging.getLogger(__name__) class CodeJams: - """ - Manages the code-jam related parts of our server - """ + """Manages the code-jam related parts of our server.""" def __init__(self, bot: commands.Bot): self.bot = bot @@ -22,14 +20,12 @@ class CodeJams: async def createteam( self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member] - ): + ) -> None: """ - Create a team channel (both voice and text) in the Code Jams category, assign roles - and then add overwrites for the team. + Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. The first user passed will always be the team leader. """ - # We had a little issue during Code Jam 4 here, the greedy converter did it's job # and ignored anything which wasn't a valid argument which left us with teams of # two members or at some times even 1 member. This fixes that by checking that there @@ -105,6 +101,7 @@ class CodeJams: await ctx.send(f":ok_hand: Team created: {team_channel.mention}") -def setup(bot): +def setup(bot: commands.Bot) -> None: + """Code Jams cog load.""" bot.add_cog(CodeJams(bot)) log.info("Cog loaded: CodeJams") diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 37bf4f4ea..a941f27a7 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -19,15 +19,13 @@ class Site: self.bot = bot @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx): + async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.invoke(self.bot.get_command("help"), "site") @site_group.command(name="home", aliases=("about",)) - async def site_main(self, ctx: Context): + async def site_main(self, ctx: Context) -> None: """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" embed = Embed(title="Python Discord website") @@ -43,9 +41,8 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="resources") - async def site_resources(self, ctx: Context): + async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" - url = f"{INFO_URL}/resources" embed = Embed(title="Resources") @@ -60,9 +57,8 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="help") - async def site_help(self, ctx: Context): + async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" - url = f"{INFO_URL}/help" embed = Embed(title="Getting Help") @@ -77,9 +73,8 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="faq") - async def site_faq(self, ctx: Context): + async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" - url = f"{INFO_URL}/faq" embed = Embed(title="FAQ") @@ -96,14 +91,8 @@ class Site: @site_group.command(aliases=['r', 'rule'], name='rules') @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def site_rules(self, ctx: Context, *rules: int): - """ - Provides a link to the `rules` endpoint of the website, or displays - specific rules, if they are requested. - - **`ctx`:** The Discord message context - **`rules`:** The rules a user wants to get. - """ + async def site_rules(self, ctx: Context, *rules: int) -> None: + """Provides a link to the `rules` endpoint of the website, or displays specific rule(s), if requested.""" rules_embed = Embed(title='Rules', color=Colour.blurple()) rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules" @@ -135,6 +124,7 @@ class Site: await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -def setup(bot): +def setup(bot: Bot) -> None: + """Site cog load.""" bot.add_cog(Site(bot)) log.info("Cog loaded: Site") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 0c6d9d2ba..09e8f70d6 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -6,7 +6,7 @@ from email.parser import HeaderParser from io import StringIO from discord import Colour, Embed -from discord.ext.commands import AutoShardedBot, Context, command +from discord.ext.commands import Bot, CheckFailure, Context, command from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, in_channel @@ -15,22 +15,17 @@ log = logging.getLogger(__name__) class Utils: - """ - A selection of utilities which don't have a clear category. - """ + """A selection of utilities which don't have a clear category.""" - def __init__(self, bot: AutoShardedBot): + def __init__(self, bot: Bot): self.bot = bot self.base_pep_url = "http://www.python.org/dev/peps/pep-" self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str): - """ - Fetches information about a PEP and sends it to the channel. - """ - + async def pep_command(self, ctx: Context, pep_number: str) -> None: + """Fetches information about a PEP and sends it to the channel.""" if pep_number.isdigit(): pep_number = int(pep_number) else: @@ -90,11 +85,8 @@ class Utils: @command() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def charinfo(self, ctx, *, characters: str): - """ - Shows you information on up to 25 unicode characters. - """ - + async def charinfo(self, ctx: Context, *, characters: str) -> None: + """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: embed = Embed( @@ -133,7 +125,8 @@ class Utils: await ctx.send(embed=embed) - async def __error(self, ctx, error): + async def __error(self, ctx: Context, error: CheckFailure) -> None: + """Send Check failure error to invoking context if command is invoked in a blacklisted channel by non-staff.""" embed = Embed(colour=Colour.red()) if isinstance(error, InChannelCheckFailure): embed.title = random.choice(NEGATIVE_REPLIES) @@ -141,6 +134,7 @@ class Utils: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Utils cog load.""" bot.add_cog(Utils(bot)) log.info("Cog loaded: Utils") -- cgit v1.2.3 From e1c6708660451e51472061fe73f97773fcf424ee Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 19:11:44 -0400 Subject: Docstring linting chunk 5 --- bot/cogs/antispam.py | 21 +++++++---- bot/cogs/reddit.py | 75 +++++++++++++-------------------------- bot/cogs/superstarify/__init__.py | 48 +++++++++++-------------- bot/cogs/superstarify/stars.py | 3 +- bot/cogs/sync/__init__.py | 5 ++- bot/cogs/sync/cog.py | 2 -- bot/cogs/sync/syncers.py | 7 +--- 7 files changed, 68 insertions(+), 93 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0c6a02bf9..c7f6503a8 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -33,19 +33,24 @@ RULE_FUNCTION_MAPPING = { class AntiSpam: + """Spam detection & mitigation measures.""" + def __init__(self, bot: Bot): self.bot = bot self._muted_role = Object(Roles.muted) @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_ready(self): + async def on_ready(self) -> None: + """Instantiate punishment role.""" role_id = AntiSpamConfig.punishment['role_id'] self.muted_role = Object(role_id) - async def on_message(self, message: Message): + async def on_message(self, message: Message) -> None: + """Monitor incoming messages & match against spam criteria for possible filtering.""" if ( not message.guild or message.guild.id != GuildConfig.id @@ -97,7 +102,8 @@ class AntiSpam: await self.maybe_delete_messages(message.channel, relevant_messages) break - async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str): + async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str) -> None: + """Deal out punishment for author(s) of messages that meet our spam criteria.""" # Sanity check to ensure we're not lagging behind if self.muted_role not in member.roles: remove_role_after = AntiSpamConfig.punishment['remove_after'] @@ -136,7 +142,8 @@ class AntiSpam: # Run a tempmute await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason) - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """Determine whether flagged messages should be disabled & delete them if so.""" # Is deletion of offending messages actually enabled? if AntiSpamConfig.clean_offending: @@ -153,7 +160,8 @@ class AntiSpam: await messages[0].delete() -def validate_config(): +def validate_config() -> None: + """Validate loaded antispam filter configuration(s).""" for name, config in AntiSpamConfig.rules.items(): if name not in RULE_FUNCTION_MAPPING: raise ValueError( @@ -169,7 +177,8 @@ def validate_config(): ) -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Antispam cog load.""" validate_config() bot.add_cog(AntiSpam(bot)) log.info("Cog loaded: AntiSpam") diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index b5bd26e3d..69d4adf76 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -16,9 +16,7 @@ log = logging.getLogger(__name__) class Reddit: - """ - Track subreddit posts and show detailed statistics about them. - """ + """Track subreddit posts and show detailed statistics about them.""" HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} URL = "https://www.reddit.com" @@ -34,11 +32,8 @@ class Reddit: self.new_posts_task = None self.top_weekly_posts_task = None - async def fetch_posts(self, route: str, *, amount: int = 25, params=None): - """ - A helper method to fetch a certain amount of Reddit posts at a given route. - """ - + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> dict: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. if not 25 >= amount > 0: raise ValueError("Invalid amount of subreddit posts requested.") @@ -57,11 +52,10 @@ class Reddit: return posts[:amount] - async def send_top_posts(self, channel: TextChannel, subreddit: Subreddit, content=None, time="all"): - """ - Create an embed for the top posts, then send it in a given TextChannel. - """ - + async def send_top_posts( + self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" + ) -> None: + """Create an embed for the top posts, then send it in a given TextChannel.""" # Create the new spicy embed. embed = Embed() embed.description = "" @@ -115,11 +109,8 @@ class Reddit: embed=embed ) - async def poll_new_posts(self): - """ - Periodically search for new subreddit posts. - """ - + async def poll_new_posts(self) -> None: + """Periodically search for new subreddit posts.""" while True: await asyncio.sleep(RedditConfig.request_delay) @@ -179,11 +170,8 @@ class Reddit: log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.") - async def poll_top_weekly_posts(self): - """ - Post a summary of the top posts every week. - """ - + async def poll_top_weekly_posts(self) -> None: + """Post a summary of the top posts every week.""" while True: now = datetime.utcnow() @@ -214,19 +202,13 @@ class Reddit: await message.pin() @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context): - """ - View the top posts from various subreddits. - """ - + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" await ctx.invoke(self.bot.get_command("help"), "reddit") @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of all time from a given subreddit. - """ - + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -235,11 +217,8 @@ class Reddit: ) @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of today from a given subreddit. - """ - + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -248,11 +227,8 @@ class Reddit: ) @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of this week from a given subreddit. - """ - + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -262,11 +238,8 @@ class Reddit: @with_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context): - """ - Send a paginated embed of all the subreddits we're relaying. - """ - + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" embed = Embed() embed.title = "Relayed subreddits." embed.colour = Colour.blurple() @@ -279,7 +252,8 @@ class Reddit: max_lines=15 ) - async def on_ready(self): + async def on_ready(self) -> None: + """Initiate reddit post event loop.""" self.reddit_channel = self.bot.get_channel(Channels.reddit) if self.reddit_channel is not None: @@ -291,6 +265,7 @@ class Reddit: log.warning("Couldn't locate a channel for subreddit relaying.") -def setup(bot): +def setup(bot: Bot) -> None: + """Reddit cog load.""" bot.add_cog(Reddit(bot)) log.info("Cog loaded: Reddit") diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 4b26f3f40..bd5211102 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -19,29 +19,29 @@ NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" class Superstarify: - """ - A set of commands to moderate terrible nicknames. - """ + """A set of commands to moderate terrible nicknames.""" def __init__(self, bot: Bot): self.bot = bot @property def moderation(self) -> Moderation: + """Get currently loaded Moderation cog instance.""" return self.bot.get_cog("Moderation") @property def modlog(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_member_update(self, before: Member, after: Member): + async def on_member_update(self, before: Member, after: Member) -> None: """ This event will trigger when someone changes their name. - At this point we will look up the user in our database and check - whether they are allowed to change their names, or if they are in - superstar-prison. If they are not allowed, we will change it back. - """ + At this point we will look up the user in our database and check whether they are allowed to + change their names, or if they are in superstar-prison. If they are not allowed, we will + change it back. + """ if before.display_name == after.display_name: return # User didn't change their nickname. Abort! @@ -91,14 +91,13 @@ class Superstarify: "to DM them, and a discord.errors.Forbidden error was incurred." ) - async def on_member_join(self, member: Member): + async def on_member_join(self, member: Member) -> None: """ This event will trigger when someone (re)joins the server. - At this point we will look up the user in our database and check - whether they are in superstar-prison. If so, we will change their name - back to the forced nickname. - """ + At this point we will look up the user in our database and check whether they are in + superstar-prison. If so, we will change their name back to the forced nickname. + """ active_superstarifies = await self.bot.api_client.get( 'bot/infractions', params={ @@ -153,13 +152,14 @@ class Superstarify: @with_role(*MODERATION_ROLES) async def superstarify( self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None - ): + ) -> None: """ - This command will force a random superstar name (like Taylor Swift) to be the user's - nickname for a specified duration. An optional reason can be provided. + Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. + + An optional reason can be provided. + If no reason is given, the original name will be shown in a generated reason. """ - active_superstarifies = await self.bot.api_client.get( 'bot/infractions', params={ @@ -222,15 +222,8 @@ class Superstarify: @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(*MODERATION_ROLES) - async def unsuperstarify(self, ctx: Context, member: Member): - """ - This command will remove the entry from our database, allowing the user - to once again change their nickname. - - :param ctx: Discord message context - :param member: The member to unsuperstarify - """ - + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """This command will the superstarify entry from our database, allowing the user to change their nickname.""" log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") embed = Embed() @@ -268,6 +261,7 @@ class Superstarify: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Superstarify cog load.""" bot.add_cog(Superstarify(bot)) log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py index 9b49d7175..dbac86770 100644 --- a/bot/cogs/superstarify/stars.py +++ b/bot/cogs/superstarify/stars.py @@ -81,6 +81,7 @@ STAR_NAMES = ( ) -def get_nick(infraction_id, member_id): +def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" rng = random.Random(str(infraction_id) + str(member_id)) return rng.choice(STAR_NAMES) diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py index e4f960620..d4565f848 100644 --- a/bot/cogs/sync/__init__.py +++ b/bot/cogs/sync/__init__.py @@ -1,10 +1,13 @@ import logging +from discord.ext.commands import Bot + from .cog import Sync log = logging.getLogger(__name__) -def setup(bot): +def setup(bot: Bot) -> None: + """Sync cog load.""" bot.add_cog(Sync(bot)) log.info("Cog loaded: Sync") diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 79177b69e..928ffa418 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -170,7 +170,6 @@ class Sync: @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronize the guild's roles with the roles on the site.""" - initial_response = await ctx.send("📊 Synchronizing roles.") total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild) await initial_response.edit( @@ -184,7 +183,6 @@ class Sync: @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronize the guild's users with the users on the site.""" - initial_response = await ctx.send("📊 Synchronizing users.") total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild) await initial_response.edit( diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 414c24adb..689d3736e 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -34,7 +34,6 @@ def get_roles_for_sync( to be deleted on the site, meaning the roles are present on the API but not in the cached guild. """ - guild_role_ids = {role.id for role in guild_roles} api_role_ids = {role.id for role in api_roles} new_role_ids = guild_role_ids - api_role_ids @@ -66,7 +65,6 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: (element `0`) , how many roles were updated (element `1`), and how many roles were deleted (element `2`) on the API. """ - roles = await bot.api_client.get('bot/roles') # Pack API roles and guild roles into one common format, @@ -138,7 +136,6 @@ def get_users_for_sync( guild, but where the attribute of a user on the API is not equal to the attribute of the user on the guild. """ - users_to_create = set() users_to_update = set() @@ -169,8 +166,7 @@ def get_users_for_sync( async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: """ - Synchronize users found on the given - `guild` with the ones on the API. + Synchronize users found on the given `guild` with the ones on the API. Arguments: bot (discord.ext.commands.Bot): @@ -186,7 +182,6 @@ async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: (element `0`) and how many users were updated (element `1`), and `None` to indicate that a user sync never deletes entries from the API. """ - current_users = await bot.api_client.get('bot/users') # Pack API users and guild users into one common format, -- cgit v1.2.3 From d38a08dcbb679df26333d03d71260348d7d485d7 Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 21:17:24 -0400 Subject: Docstring linting chunk 6 --- bot/cogs/help.py | 264 ++++++++------------------------- bot/cogs/watchchannels/__init__.py | 5 +- bot/cogs/watchchannels/bigbrother.py | 4 +- bot/cogs/watchchannels/talentpool.py | 6 +- bot/cogs/watchchannels/watchchannel.py | 9 +- bot/converters.py | 73 ++++++--- bot/decorators.py | 57 ++++--- bot/pagination.py | 126 +++++----------- 8 files changed, 187 insertions(+), 357 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 20ed08f07..68c59d326 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -3,10 +3,11 @@ import inspect import itertools from collections import namedtuple from contextlib import suppress +from typing import Union -from discord import Colour, Embed, HTTPException +from discord import Colour, Embed, HTTPException, Message, Reaction, User from discord.ext import commands -from discord.ext.commands import CheckFailure +from discord.ext.commands import Bot, CheckFailure, Command, Context from fuzzywuzzy import fuzz, process from bot import constants @@ -43,7 +44,7 @@ class HelpQueryNotFound(ValueError): The likeness match scores are the values. """ - def __init__(self, arg, possible_matches=None): + def __init__(self, arg: str, possible_matches: dict = None): super().__init__(arg) self.possible_matches = possible_matches @@ -68,7 +69,10 @@ class HelpSession: Where the help message is to be sent to. """ - def __init__(self, ctx, *command, cleanup=False, only_can_run=True, show_hidden=False, max_lines=15): + def __init__( + self, ctx: Context, *command, cleanup: bool = False, only_can_run: bool = True, + show_hidden: bool = False, max_lines: int = 15 + ): """ Creates an instance of the HelpSession class. @@ -93,7 +97,6 @@ class HelpSession: single page. Defaults to 20. """ - self._ctx = ctx self._bot = ctx.bot self.title = "Command Help" @@ -122,20 +125,8 @@ class HelpSession: self._timeout_task = None self.reset_timeout() - def _get_query(self, query): - """ - Attempts to match the provided query with a valid command or cog. - - Parameters - ---------- - query: str - The joined string representing the session query. - - Returns - ------- - Union[:class:`discord.ext.commands.Command`, :class:`Cog`] - """ - + def _get_query(self, query: str) -> Union[Command, Cog]: + """Attempts to match the provided query with a valid command or cog.""" command = self._bot.get_command(query) if command: return command @@ -150,12 +141,11 @@ class HelpSession: self._handle_not_found(query) - def _handle_not_found(self, query): + def _handle_not_found(self, query: str) -> None: """ Handles when a query does not match a valid command or cog. - Will pass on possible close matches along with the - ``HelpQueryNotFound`` exception. + Will pass on possible close matches along with the ``HelpQueryNotFound`` exception. Parameters ---------- @@ -166,7 +156,6 @@ class HelpSession: ------ HelpQueryNotFound """ - # combine command and cog names choices = list(self._bot.all_commands) + list(self._bot.cogs) @@ -174,7 +163,7 @@ class HelpSession: raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) - async def timeout(self, seconds=30): + async def timeout(self, seconds: int = 30) -> None: """ Waits for a set number of seconds, then stops the help session. @@ -183,15 +172,11 @@ class HelpSession: seconds: int Number of seconds to wait. """ - await asyncio.sleep(seconds) await self.stop() - def reset_timeout(self): - """ - Cancels the original timeout task and sets it again from the start. - """ - + def reset_timeout(self) -> None: + """Cancels the original timeout task and sets it again from the start.""" # cancel original if it exists if self._timeout_task: if not self._timeout_task.cancelled(): @@ -200,7 +185,7 @@ class HelpSession: # recreate the timeout task self._timeout_task = self._bot.loop.create_task(self.timeout()) - async def on_reaction_add(self, reaction, user): + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: """ Event handler for when reactions are added on the help message. @@ -211,7 +196,6 @@ class HelpSession: user: :class:`discord.User` The user who added the reaction. """ - # ensure it was the relevant session message if reaction.message.id != self.message.id: return @@ -237,24 +221,13 @@ class HelpSession: with suppress(HTTPException): await self.message.remove_reaction(reaction, user) - async def on_message_delete(self, message): - """ - Closes the help session when the help message is deleted. - - Parameters - ---------- - message: :class:`discord.Message` - The message that was deleted. - """ - + async def on_message_delete(self, message: Message) -> None: + """Closes the help session when the help message is deleted.""" if message.id == self.message.id: await self.stop() - async def prepare(self): - """ - Sets up the help session pages, events, message and reactions. - """ - + async def prepare(self) -> None: + """Sets up the help session pages, events, message and reactions.""" # create paginated content await self.build_pages() @@ -266,12 +239,8 @@ class HelpSession: await self.update_page() self.add_reactions() - def add_reactions(self): - """ - Adds the relevant reactions to the help message based on if - pagination is required. - """ - + def add_reactions(self) -> None: + """Adds the relevant reactions to the help message based on if pagination is required.""" # if paginating if len(self._pages) > 1: for reaction in REACTIONS: @@ -281,44 +250,22 @@ class HelpSession: else: self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) - def _category_key(self, cmd): + def _category_key(self, cmd: Command) -> str: """ - Returns a cog name of a given command. Used as a key for - ``sorted`` and ``groupby``. - - A zero width space is used as a prefix for results with no cogs - to force them last in ordering. - - Parameters - ---------- - cmd: :class:`discord.ext.commands.Command` - The command object being checked. + Returns a cog name of a given command for use as a key for ``sorted`` and ``groupby``. - Returns - ------- - str + A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ - cog = cmd.cog_name return f'**{cog}**' if cog else f'**\u200bNo Category:**' - def _get_command_params(self, cmd): + def _get_command_params(self, cmd: Command) -> str: """ Returns the command usage signature. - This is a custom implementation of ``command.signature`` in - order to format the command signature without aliases. - - Parameters - ---------- - cmd: :class:`discord.ext.commands.Command` - The command object to get the parameters of. - - Returns - ------- - str + This is a custom implementation of ``command.signature`` in order to format the command + signature without aliases. """ - results = [] for name, param in cmd.clean_params.items(): @@ -346,16 +293,8 @@ class HelpSession: return f"{cmd.name} {' '.join(results)}" - async def build_pages(self): - """ - Builds the list of content pages to be paginated through in the - help message. - - Returns - ------- - list[str] - """ - + async def build_pages(self) -> None: + """Builds the list of content pages to be paginated through in the help message, as a list of str.""" # Use LinePaginator to restrict embed line height paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) @@ -482,20 +421,8 @@ class HelpSession: # save organised pages to session self._pages = paginator.pages - def embed_page(self, page_number=0): - """ - Returns an Embed with the requested page formatted within. - - Parameters - ---------- - page_number: int - The page to be retrieved. Zero indexed. - - Returns - ------- - :class:`discord.Embed` - """ - + def embed_page(self, page_number: int = 0) -> Embed: + """Returns an Embed with the requested page formatted within.""" embed = Embed() # if command or cog, add query to title for pages other than first @@ -514,17 +441,8 @@ class HelpSession: return embed - async def update_page(self, page_number=0): - """ - Sends the intial message, or changes the existing one to the - given page number. - - Parameters - ---------- - page_number: int - The page number to show in the help message. - """ - + async def update_page(self, page_number: int = 0) -> None: + """Sends the intial message, or changes the existing one to the given page number.""" self._current_page = page_number embed_page = self.embed_page(page_number) @@ -534,10 +452,9 @@ class HelpSession: await self.message.edit(embed=embed_page) @classmethod - async def start(cls, ctx, *command, **options): + async def start(cls, ctx: Context, *command, **options) -> "HelpSession": """ - Create and begin a help session based on the given command - context. + Create and begin a help session based on the given command context. Parameters ---------- @@ -558,23 +475,14 @@ class HelpSession: Sets the max number of lines the paginator will add to a single page. Defaults to 20. - - Returns - ------- - :class:`HelpSession` """ - session = cls(ctx, *command, **options) await session.prepare() return session - async def stop(self): - """ - Stops the help session, removes event listeners and attempts to - delete the help message. - """ - + async def stop(self) -> None: + """Stops the help session, removes event listeners and attempts to delete the help message.""" self._bot.remove_listener(self.on_reaction_add) self._bot.remove_listener(self.on_message_delete) @@ -586,80 +494,47 @@ class HelpSession: await self.message.clear_reactions() @property - def is_first_page(self): - """ - A bool reflecting if session is currently showing the first page. - - Returns - ------- - bool - """ - + def is_first_page(self) -> bool: + """Check if session is currently showing the first page.""" return self._current_page == 0 @property - def is_last_page(self): - """ - A bool reflecting if the session is currently showing the last page. - - Returns - ------- - bool - """ - + def is_last_page(self) -> bool: + """Check if the session is currently showing the last page.""" return self._current_page == (len(self._pages)-1) - async def do_first(self): - """ - Event that is called when the user requests the first page. - """ - + async def do_first(self) -> None: + """Event that is called when the user requests the first page.""" if not self.is_first_page: await self.update_page(0) - async def do_back(self): - """ - Event that is called when the user requests the previous page. - """ - + async def do_back(self) -> None: + """Event that is called when the user requests the previous page.""" if not self.is_first_page: await self.update_page(self._current_page-1) - async def do_next(self): - """ - Event that is called when the user requests the next page. - """ - + async def do_next(self) -> None: + """Event that is called when the user requests the next page.""" if not self.is_last_page: await self.update_page(self._current_page+1) - async def do_end(self): - """ - Event that is called when the user requests the last page. - """ - + async def do_end(self) -> None: + """Event that is called when the user requests the last page.""" if not self.is_last_page: await self.update_page(len(self._pages)-1) - async def do_stop(self): - """ - Event that is called when the user requests to stop the help session. - """ - + async def do_stop(self) -> None: + """Event that is called when the user requests to stop the help session.""" await self.message.delete() class Help: - """ - Custom Embed Pagination Help feature - """ + """Custom Embed Pagination Help feature.""" + @commands.command('help') @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def new_help(self, ctx, *commands): - """ - Shows Command Help. - """ - + async def new_help(self, ctx: Context, *commands) -> None: + """Shows Command Help.""" try: await HelpSession.start(ctx, *commands) except HelpQueryNotFound as error: @@ -674,24 +549,17 @@ class Help: await ctx.send(embed=embed) -def unload(bot): +def unload(bot: Bot) -> None: """ Reinstates the original help command. - This is run if the cog raises an exception on load, or if the - extension is unloaded. - - Parameters - ---------- - bot: :class:`discord.ext.commands.Bot` - The discord bot client. + This is run if the cog raises an exception on load, or if the extension is unloaded. """ - bot.remove_command('help') bot.add_command(bot._old_help) -def setup(bot): +def setup(bot: Bot) -> None: """ The setup for the help extension. @@ -703,13 +571,7 @@ def setup(bot): If an exception is raised during the loading of the cog, ``unload`` will be called in order to reinstate the original help command. - - Parameters - ---------- - bot: `discord.ext.commands.Bot` - The discord bot client. """ - bot._old_help = bot.get_command('help') bot.remove_command('help') @@ -720,18 +582,12 @@ def setup(bot): raise -def teardown(bot): +def teardown(bot: Bot) -> None: """ The teardown for the help extension. This is called automatically on `bot.unload_extension` being run. Calls ``unload`` in order to reinstate the original help command. - - Parameters - ---------- - bot: `discord.ext.commands.Bot` - The discord bot client. """ - unload(bot) diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py index ac7713803..86e1050fa 100644 --- a/bot/cogs/watchchannels/__init__.py +++ b/bot/cogs/watchchannels/__init__.py @@ -1,5 +1,7 @@ import logging +from discord.ext.commands import Bot + from .bigbrother import BigBrother from .talentpool import TalentPool @@ -7,7 +9,8 @@ from .talentpool import TalentPool log = logging.getLogger(__name__) -def setup(bot): +def setup(bot: Bot) -> None: + """Monitoring cogs load.""" bot.add_cog(BigBrother(bot)) log.info("Cog loaded: BigBrother") diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e7b3d70bc..a4c95d8ad 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,7 +3,7 @@ from collections import ChainMap from typing import Union from discord import User -from discord.ext.commands import Context, group +from discord.ext.commands import Bot, Context, group from bot.constants import Channels, Roles, Webhooks from bot.decorators import with_role @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) class BigBrother(WatchChannel): """Monitors users by relaying their messages to a watch channel to assist with moderation.""" - def __init__(self, bot) -> None: + def __init__(self, bot: Bot) -> None: super().__init__( bot, destination=Channels.big_brother_logs, diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 47d207d05..bea0a8b0a 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,7 +4,7 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Context, group +from discord.ext.commands import Bot, Context, group from bot.api import ResponseCodeError from bot.constants import Channels, Guild, Roles, Webhooks @@ -19,7 +19,7 @@ STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- I class TalentPool(WatchChannel): """Relays messages of helper candidates to a watch channel to observe them.""" - def __init__(self, bot) -> None: + def __init__(self, bot: Bot) -> None: super().__init__( bot, destination=Channels.talent_pool, @@ -33,7 +33,6 @@ class TalentPool(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool") @nomination_group.command(name='watched', aliases=('all', 'list')) @@ -156,7 +155,6 @@ class TalentPool(WatchChannel): @with_role(Roles.owner, Roles.admin, Roles.moderator) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") @nomination_edit_group.command(name='reason') diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 3a24e3f21..5ca819955 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -42,6 +42,8 @@ def proxy_user(user_id: str) -> Object: @dataclass class MessageHistory: + """Represent the watch channel's message history.""" + last_author: Optional[int] = None last_channel: Optional[int] = None message_count: int = 0 @@ -51,7 +53,10 @@ class WatchChannel(ABC): """ABC with functionality for relaying users' messages to a certain channel.""" @abstractmethod - def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: + def __init__( + self, bot: Bot, destination: int, webhook_id: int, + api_endpoint: str, api_default_params: dict, logger: logging.Logger + ) -> None: self.bot = bot self.destination = destination # E.g., Channels.big_brother_logs @@ -271,7 +276,7 @@ class WatchChannel(ABC): self.message_history.message_count += 1 - async def send_header(self, msg) -> None: + async def send_header(self, msg: Message) -> None: """Sends a header embed with information about the relayed messages to the watch channel.""" user_id = msg.author.id diff --git a/bot/converters.py b/bot/converters.py index 30ea7ca0f..af7ecd107 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,6 +1,7 @@ import logging from datetime import datetime from ssl import CertificateError +from typing import Union import dateparser import discord @@ -15,17 +16,16 @@ class ValidPythonIdentifier(Converter): """ A converter that checks whether the given string is a valid Python identifier. - This is used to have package names - that correspond to how you would use - the package in your code, e.g. - `import package`. Raises `BadArgument` - if the argument is not a valid Python - identifier, and simply passes through + This is used to have package names that correspond to how you would use the package in your + code, e.g. `import package`. + + Raises `BadArgument` if the argument is not a valid Python identifier, and simply passes through the given argument otherwise. """ @staticmethod - async def convert(ctx, argument: str): + async def convert(ctx: Context, argument: str) -> str: + """Checks whether the given string is a valid Python identifier.""" if not argument.isidentifier(): raise BadArgument(f"`{argument}` is not a valid Python identifier") return argument @@ -35,14 +35,15 @@ class ValidURL(Converter): """ Represents a valid webpage URL. - This converter checks whether the given - URL can be reached and requesting it returns - a status code of 200. If not, `BadArgument` - is raised. Otherwise, it simply passes through the given URL. + This converter checks whether the given URL can be reached and requesting it returns a status + code of 200. If not, `BadArgument` is raised. + + Otherwise, it simply passes through the given URL. """ @staticmethod - async def convert(ctx, url: str): + async def convert(ctx: Context, url: str) -> str: + """This converter checks whether the given URL can be reached with a status code of 200.""" try: async with ctx.bot.http_session.get(url) as resp: if resp.status != 200: @@ -63,12 +64,11 @@ class ValidURL(Converter): class InfractionSearchQuery(Converter): - """ - A converter that checks if the argument is a Discord user, and if not, falls back to a string. - """ + """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" @staticmethod - async def convert(ctx, arg): + async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]: + """Check if the argument is a Discord user, and if not, falls back to a string.""" try: maybe_snowflake = arg.strip("<@!>") return await ctx.bot.get_user_info(maybe_snowflake) @@ -77,12 +77,15 @@ class InfractionSearchQuery(Converter): class Subreddit(Converter): - """ - Forces a string to begin with "r/" and checks if it's a valid subreddit. - """ + """Forces a string to begin with "r/" and checks if it's a valid subreddit.""" @staticmethod - async def convert(ctx, sub: str): + async def convert(ctx: Context, sub: str) -> str: + """ + Force sub to begin with "r/" and check if it's a valid subreddit. + + If sub is a valid subreddit, return it prepended with "r/" + """ sub = sub.lower() if not sub.startswith("r/"): @@ -103,9 +106,21 @@ class Subreddit(Converter): class TagNameConverter(Converter): + """ + Ensure that a proposed tag name is valid. + + Valid tag names meet the following conditions: + * All ASCII characters + * Has at least one non-whitespace character + * Not solely numeric + * Shorter than 127 characters + """ + @staticmethod - async def convert(ctx: Context, tag_name: str): - def is_number(value): + async def convert(ctx: Context, tag_name: str) -> str: + """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" + def is_number(value: str) -> bool: + """Check to see if the input string is numeric.""" try: float(value) except ValueError: @@ -142,8 +157,15 @@ class TagNameConverter(Converter): class TagContentConverter(Converter): + """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" + @staticmethod - async def convert(ctx: Context, tag_content: str): + async def convert(ctx: Context, tag_content: str) -> str: + """ + Ensure tag_content is non-empty and contains at least one non-whitespace character. + + If tag_content is valid, return the stripped version. + """ tag_content = tag_content.strip() # The tag contents should not be empty, or filled with whitespace. @@ -156,13 +178,16 @@ class TagContentConverter(Converter): class ExpirationDate(Converter): + """Convert relative expiration date into UTC datetime using dateparser.""" + DATEPARSER_SETTINGS = { 'PREFER_DATES_FROM': 'future', 'TIMEZONE': 'UTC', 'TO_TIMEZONE': 'UTC' } - async def convert(self, ctx, expiration_string: str): + async def convert(self, ctx: Context, expiration_string: str) -> datetime: + """Convert relative expiration date into UTC datetime.""" expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS) if expiry is None: raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`") diff --git a/bot/decorators.py b/bot/decorators.py index 1ba2cd59e..3600be3bb 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,9 +1,9 @@ import logging import random -import typing from asyncio import Lock, sleep from contextlib import suppress from functools import wraps +from typing import Callable, Container, Union from weakref import WeakValueDictionary from discord import Colour, Embed @@ -18,14 +18,15 @@ log = logging.getLogger(__name__) class InChannelCheckFailure(CheckFailure): + """In channel check failure exception.""" + pass -def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): - """ - Checks that the message is in a whitelisted channel or optionally has a bypass role. - """ - def predicate(ctx: Context): +def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable: + """Checks that the message is in a whitelisted channel or optionally has a bypass role.""" + def predicate(ctx: Context) -> bool: + """In-channel checker predicate.""" if ctx.channel.id in channels: log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The command was used in a whitelisted channel.") @@ -49,42 +50,34 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): return commands.check(predicate) -def with_role(*role_ids: int): - """ - Returns True if the user has any one - of the roles in role_ids. - """ - - async def predicate(ctx: Context): +def with_role(*role_ids: int) -> Callable: + """Returns True if the user has any one of the roles in role_ids.""" + async def predicate(ctx: Context) -> bool: + """With role checker predicate.""" return with_role_check(ctx, *role_ids) return commands.check(predicate) -def without_role(*role_ids: int): - """ - Returns True if the user does not have any - of the roles in role_ids. - """ - - async def predicate(ctx: Context): +def without_role(*role_ids: int) -> Callable: + """Returns True if the user does not have any of the roles in role_ids.""" + async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) return commands.check(predicate) -def locked(): +def locked() -> Union[Callable, None]: """ Allows the user to only run one instance of the decorated command at a time. - Subsequent calls to the command from the same author are - ignored until the command has completed invocation. + + Subsequent calls to the command from the same author are ignored until the command has completed invocation. This decorator has to go before (below) the `command` decorator. """ - - def wrap(func): + def wrap(func: Callable) -> Union[Callable, None]: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self, ctx, *args, **kwargs): + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Union[Callable, None]: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() @@ -104,15 +97,15 @@ def locked(): return wrap -def redirect_output(destination_channel: int, bypass_roles: typing.Container[int] = None): - """ - Changes the channel in the context of the command to redirect the output - to a certain channel, unless the author has a role to bypass redirection +def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: """ + Changes the channel in the context of the command to redirect the output to a certain channel. - def wrap(func): + Redirect is bypassed if the author has a role to bypass redirection. + """ + def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self, ctx, *args, **kwargs): + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Callable: if ctx.channel.id == destination_channel: log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting") return await func(self, ctx, *args, **kwargs) diff --git a/bot/pagination.py b/bot/pagination.py index 0ad5b81f1..10ef6c407 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -18,6 +18,8 @@ log = logging.getLogger(__name__) class EmptyPaginatorEmbed(Exception): + """Empty paginator embed exception.""" + pass @@ -37,13 +39,13 @@ class LinePaginator(Paginator): The maximum amount of lines allowed in a page. """ - def __init__(self, prefix='```', suffix='```', - max_size=2000, max_lines=None): + def __init__( + self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None + ) -> None: """ - This function overrides the Paginator.__init__ - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + This function overrides the Paginator.__init__ from inside discord.ext.commands. + + It overrides in order to allow us to configure the maximum number of lines per page. """ self.prefix = prefix self.suffix = suffix @@ -54,28 +56,15 @@ class LinePaginator(Paginator): self._count = len(prefix) + 1 # prefix + newline self._pages = [] - def add_line(self, line='', *, empty=False): - """Adds a line to the current page. - - If the line exceeds the :attr:`max_size` then an exception - is raised. + def add_line(self, line: str = '', *, empty: bool = False) -> None: + """ + Adds a line to the current page. - This function overrides the Paginator.add_line - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + If the line exceeds the `self.max_size` then an exception is raised. - Parameters - ----------- - line: str - The line to add. - empty: bool - Indicates if another empty line should be added. + This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. - Raises - ------ - RuntimeError - The line was too big for the current :attr:`max_size`. + It overrides in order to allow us to configure the maximum number of lines per page. """ if len(line) > self.max_size - len(self.prefix) - 2: raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) @@ -100,39 +89,24 @@ class LinePaginator(Paginator): async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, empty: bool = True, restrict_to_user: User = None, timeout: int = 300, - footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False): + footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False) -> None: """ - Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to - switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of lines. + + The reactions are used to switch page, or to finish with pagination. + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. Pagination will also be removed automatically - if no reaction is added for five minutes (300 seconds). + be used to change page, or to remove pagination from the message. + + Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + + Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await LinePaginator.paginate( - ... (line for line in lines), - ... ctx, embed - ... ) - :param lines: The lines to be paginated - :param ctx: Current context object - :param embed: A pre-configured embed to be used as a template for each page - :param prefix: Text to place before each page - :param suffix: Text to place after each page - :param max_lines: The maximum number of lines on each page - :param max_size: The maximum number of characters on each page - :param empty: Whether to place an empty line between each given line - :param restrict_to_user: A user to lock pagination operations to for this message, if supplied - :param exception_on_empty_embed: Should there be an exception if the embed is empty? - :param url: the url to use for the embed headline - :param timeout: The amount of time in seconds to disable pagination of no reaction is added - :param footer_text: Text to prefix the page number in the footer with + >>> await LinePaginator.paginate((line for line in lines), ctx, embed) """ - - def event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ - + def event_check(reaction_: Reaction, user_: Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( # Pagination is not restricted not restrict_to_user @@ -301,24 +275,20 @@ class LinePaginator(Paginator): class ImagePaginator(Paginator): """ Helper class that paginates images for embeds in messages. + Close resemblance to LinePaginator, except focuses on images over text. Refer to ImagePaginator.paginate for documentation on how to use. """ - def __init__(self, prefix="", suffix=""): + def __init__(self, prefix: str = "", suffix: str = ""): super().__init__(prefix, suffix) self._current_page = [prefix] self.images = [] self._pages = [] def add_line(self, line: str = '', *, empty: bool = False) -> None: - """ - Adds a line to each page, usually just 1 line in this context - :param line: str to be page content / title - :param empty: if there should be new lines between entries - """ - + """Adds a line to each page.""" if line: self._count = len(line) else: @@ -327,11 +297,7 @@ class ImagePaginator(Paginator): self.close_page() def add_image(self, image: str = None) -> None: - """ - Adds an image to a page - :param image: image url to be appended - """ - + """Adds an image to a page.""" self.images.append(image) @classmethod @@ -339,38 +305,22 @@ class ImagePaginator(Paginator): prefix: str = "", suffix: str = "", timeout: int = 300, exception_on_empty_embed: bool = False): """ - Use a paginator and set of reactions to provide - pagination over a set of title/image pairs.The reactions are - used to switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of title/image pairs. + + The reactions are used to switch page, or to finish with pagination. - When used, this will send a message using `ctx.send()` and - apply a set of reactions to it. These reactions may + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may be used to change page, or to remove pagination from the message. - Note: Pagination will be removed automatically - if no reaction is added for five minutes (300 seconds). + Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). + Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await ImagePaginator.paginate(pages, ctx, embed) - - Parameters - ----------- - :param pages: An iterable of tuples with title for page, and img url - :param ctx: ctx for message - :param embed: base embed to modify - :param prefix: prefix of message - :param suffix: suffix of message - :param timeout: timeout for when reactions get auto-removed """ - def check_event(reaction_: Reaction, member: Member) -> bool: - """ - Checks each reaction added, if it matches our conditions pass the wait_for - :param reaction_: reaction added - :param member: reaction added by member - """ - + """Checks each reaction added, if it matches our conditions pass the wait_for.""" return all(( # Reaction is on the same message sent reaction_.message.id == message.id, -- cgit v1.2.3 From 4f2ca226fe61b62e4e560805f3adbc2abd3d5c16 Mon Sep 17 00:00:00 2001 From: sco1 Date: Tue, 10 Sep 2019 21:56:25 -0400 Subject: Docstring linting chunk 7 Whew --- bot/interpreter.py | 14 ++++++++-- bot/rules/attachments.py | 2 +- bot/rules/burst.py | 2 +- bot/rules/burst_shared.py | 2 +- bot/rules/chars.py | 2 +- bot/rules/discord_emojis.py | 2 +- bot/rules/duplicates.py | 2 +- bot/rules/links.py | 2 +- bot/rules/mentions.py | 2 +- bot/rules/newlines.py | 2 +- bot/rules/role_mentions.py | 2 +- bot/utils/__init__.py | 37 ++++++++++++++++---------- bot/utils/checks.py | 18 +++---------- bot/utils/messages.py | 63 ++++++++++++--------------------------------- bot/utils/moderation.py | 8 +++--- bot/utils/scheduling.py | 43 ++++++++++++------------------- bot/utils/time.py | 48 ++++++++-------------------------- 17 files changed, 96 insertions(+), 155 deletions(-) diff --git a/bot/interpreter.py b/bot/interpreter.py index 06343db39..6ea49e026 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -1,5 +1,8 @@ from code import InteractiveInterpreter from io import StringIO +from typing import Any + +from discord.ext.commands import Bot, Context CODE_TEMPLATE = """ async def _func(): @@ -8,13 +11,20 @@ async def _func(): class Interpreter(InteractiveInterpreter): + """ + Subclass InteractiveInterpreter to specify custom run functionality. + + Helper class for internal eval + """ + write_callable = None - def __init__(self, bot): + def __init__(self, bot: Bot): _locals = {"bot": bot} super().__init__(_locals) - async def run(self, code, ctx, io, *args, **kwargs): + async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any: + """Execute the provided source code as the bot & return the output.""" self.locals["_rvalue"] = [] self.locals["ctx"] = ctx self.locals["print"] = lambda x: io.write(f"{x}\n") diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index 47b927101..e71b96183 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply attachment spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/burst.py b/bot/rules/burst.py index 80c79be60..8859f8d51 100644 --- a/bot/rules/burst.py +++ b/bot/rules/burst.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply burst message spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index 2cb7b5200..b8c73ecb4 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply burst repeated message spam filter.""" total_recent = len(recent_messages) if total_recent > config['max']: diff --git a/bot/rules/chars.py b/bot/rules/chars.py index d05e3cd83..ae8ac93ef 100644 --- a/bot/rules/chars.py +++ b/bot/rules/chars.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply excessive character count detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index e4f957ddb..87d129f37 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -14,7 +14,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply emoji spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py index 763fc9983..8648fd955 100644 --- a/bot/rules/duplicates.py +++ b/bot/rules/duplicates.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply duplicate message spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/links.py b/bot/rules/links.py index fa4043fcb..924f092b1 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -14,7 +14,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply link spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 45c47b6ba..3372fd1e1 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply user mention spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py index fdad6ffd3..d04f8c9ed 100644 --- a/bot/rules/newlines.py +++ b/bot/rules/newlines.py @@ -11,7 +11,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply newline spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py index 2177a73b5..a8b819d0d 100644 --- a/bot/rules/role_mentions.py +++ b/bot/rules/role_mentions.py @@ -10,7 +10,7 @@ async def apply( recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Apply role mention spam detection filter.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 4c99d50e8..141559657 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,3 +1,5 @@ +from typing import Any, Generator, Hashable, Iterable + class CaseInsensitiveDict(dict): """ @@ -7,50 +9,59 @@ class CaseInsensitiveDict(dict): """ @classmethod - def _k(cls, key): + def _k(cls, key: Hashable) -> Any: + """Return lowered key if a string-like is passed, otherwise pass key straight through.""" return key.lower() if isinstance(key, str) else key def __init__(self, *args, **kwargs): super(CaseInsensitiveDict, self).__init__(*args, **kwargs) self._convert_keys() - def __getitem__(self, key): + def __getitem__(self, key: Hashable) -> Any: + """Case insensitive __setitem__.""" return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - def __setitem__(self, key, value): + def __setitem__(self, key: Hashable, value: Any): + """Case insensitive __setitem__.""" super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - def __delitem__(self, key): + def __delitem__(self, key: Hashable) -> Any: + """Case insensitive __delitem__.""" return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - def __contains__(self, key): + def __contains__(self, key: Hashable) -> bool: + """Case insensitive __contains__.""" return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - def pop(self, key, *args, **kwargs): + def pop(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive pop.""" return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - def get(self, key, *args, **kwargs): + def get(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive get.""" return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - def setdefault(self, key, *args, **kwargs): + def setdefault(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive setdefault.""" return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - def update(self, E=None, **F): + def update(self, E: Any = None, **F) -> None: + """Case insensitive update.""" super(CaseInsensitiveDict, self).update(self.__class__(E)) super(CaseInsensitiveDict, self).update(self.__class__(**F)) - def _convert_keys(self): + def _convert_keys(self) -> None: + """Helper method to lowercase all existing string-like keys.""" for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) -def chunks(iterable, size): +def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]: """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks + Generator that allows you to iterate over any indexable collection in `size`-length chunks. Found: https://stackoverflow.com/a/312464/4022104 """ - for i in range(0, len(iterable), size): yield iterable[i:i + size] diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 37dc657f7..1f4c1031b 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -6,11 +6,7 @@ log = logging.getLogger(__name__) def with_role_check(ctx: Context, *role_ids: int) -> bool: - """ - Returns True if the user has any one - of the roles in role_ids. - """ - + """Returns True if the user has any one of the roles in role_ids.""" if not ctx.guild: # Return False in a DM log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " "This command is restricted by the with_role decorator. Rejecting request.") @@ -27,11 +23,7 @@ def with_role_check(ctx: Context, *role_ids: int) -> bool: def without_role_check(ctx: Context, *role_ids: int) -> bool: - """ - Returns True if the user does not have any - of the roles in role_ids. - """ - + """Returns True if the user does not have any of the roles in role_ids.""" if not ctx.guild: # Return False in a DM log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " "This command is restricted by the without_role decorator. Rejecting request.") @@ -45,11 +37,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: def in_channel_check(ctx: Context, channel_id: int) -> bool: - """ - Checks if the command was executed - inside of the specified channel. - """ - + """Checks if the command was executed inside of the specified channel.""" check = ctx.channel.id == channel_id log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 94a8b36ed..5058d42fc 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,9 @@ import asyncio import contextlib from io import BytesIO -from typing import Sequence, Union +from typing import Optional, Sequence, Union -from discord import Embed, File, Message, TextChannel, Webhook +from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException @@ -17,42 +17,18 @@ async def wait_for_deletion( user_ids: Sequence[Snowflake], deletion_emojis: Sequence[str] = (Emojis.cross_mark,), timeout: float = 60 * 5, - attach_emojis=True, - client=None -): - """ - Waits for up to `timeout` seconds for a reaction by - any of the specified `user_ids` to delete the message. - - Args: - message (Message): - The message that should be monitored for reactions - and possibly deleted. Must be a message sent on a - guild since access to the bot instance is required. - - user_ids (Sequence[Snowflake]): - A sequence of users that are allowed to delete - this message. - - Kwargs: - deletion_emojis (Sequence[str]): - A sequence of emojis that are considered deletion - emojis. - - timeout (float): - A positive float denoting the maximum amount of - time to wait for a deletion reaction. - - attach_emojis (bool): - Whether to attach the given `deletion_emojis` - to the message in the given `context` - - client (Optional[discord.Client]): - The client instance handling the original command. - If not given, will take the client from the guild - of the message. + attach_emojis: bool = True, + client: Optional[Client] = None +) -> None: """ + Waits for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + + An `attach_emojis` bool may be specified to determine whether to attach the given + `deletion_emojis` to the message in the given `context` + A `client` instance may be optionally specified, otherwise client will be taken from the + guild of the message. + """ if message.guild is None and client is None: raise ValueError("Message must be sent on a guild") @@ -62,7 +38,8 @@ async def wait_for_deletion( for emoji in deletion_emojis: await message.add_reaction(emoji) - def check(reaction, user): + def check(reaction: Reaction, user: Member) -> bool: + """Check that the deletion emoji is reacted by the approprite user.""" return ( reaction.message.id == message.id and reaction.emoji in deletion_emojis @@ -70,25 +47,17 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for( - 'reaction_add', - check=check, - timeout=timeout - ) + await bot.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]): +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None: """ Re-uploads each attachment in a message to the given channel or webhook. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. - - :param message: the message whose attachments to re-upload - :param destination: the channel in which to re-upload the attachments """ - large = [] for attachment in message.attachments: try: diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index c1eb98dd6..9ea2db07c 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -21,8 +21,8 @@ async def post_infraction( expires_at: datetime = None, hidden: bool = False, active: bool = True, -): - +) -> Union[dict, None]: + """Post infraction to the API.""" payload = { "actor": ctx.message.author.id, "hidden": hidden, @@ -35,9 +35,7 @@ async def post_infraction( payload['expires_at'] = expires_at.isoformat() try: - response = await ctx.bot.api_client.post( - 'bot/infractions', json=payload - ) + response = await ctx.bot.api_client.post('bot/infractions', json=payload) except ClientError: log.exception("There was an error adding an infraction.") await ctx.send(":x: There was an error adding the infraction.") diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ded6401b0..9975b04e0 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,12 +2,13 @@ import asyncio import contextlib import logging from abc import ABC, abstractmethod -from typing import Dict +from typing import Coroutine, Dict, Union log = logging.getLogger(__name__) class Scheduler(ABC): + """Task scheduler.""" def __init__(self): @@ -15,24 +16,23 @@ class Scheduler(ABC): self.scheduled_tasks: Dict[str, asyncio.Task] = {} @abstractmethod - async def _scheduled_task(self, task_object: dict): + async def _scheduled_task(self, task_object: dict) -> None: """ - A coroutine which handles the scheduling. This is added to the scheduled tasks, - and should wait the task duration, execute the desired code, and clean up the task. + A coroutine which handles the scheduling. + + This is added to the scheduled tasks, and should wait the task duration, execute the desired + code, then clean up the task. + For example, in Reminders this will wait for the reminder duration, send the reminder, then make a site API request to delete the reminder from the database. - - :param task_object: """ - def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict): + def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict) -> None: """ Schedules a task. - :param loop: the asyncio event loop - :param task_id: the ID of the task. - :param task_data: the data of the task, passed to `Scheduler._scheduled_expiration`. - """ + `task_data` is passed to `Scheduler._scheduled_expiration` + """ if task_id in self.scheduled_tasks: return @@ -40,12 +40,8 @@ class Scheduler(ABC): self.scheduled_tasks[task_id] = task - def cancel_task(self, task_id: str): - """ - Un-schedules a task. - :param task_id: the ID of the infraction in question - """ - + def cancel_task(self, task_id: str) -> None: + """Un-schedules a task.""" task = self.scheduled_tasks.get(task_id) if task is None: @@ -57,14 +53,8 @@ class Scheduler(ABC): del self.scheduled_tasks[task_id] -def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): - """ - Creates an asyncio.Task object from a coroutine or future object. - - :param loop: the asyncio event loop. - :param coro_or_future: the coroutine or future object to be scheduled. - """ - +def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine, asyncio.Future]) -> asyncio.Task: + """Creates an asyncio.Task object from a coroutine or future object.""" task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) # Silently ignore exceptions in a callback (handles the CancelledError nonsense) @@ -72,6 +62,7 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): return task -def _silent_exception(future): +def _silent_exception(future: asyncio.Future) -> None: + """Suppress future exception.""" with contextlib.suppress(Exception): future.exception() diff --git a/bot/utils/time.py b/bot/utils/time.py index a330c9cd8..fe1c4e3ee 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -6,10 +6,9 @@ from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -def _stringify_time_unit(value: int, unit: str): +def _stringify_time_unit(value: int, unit: str) -> str: """ - Returns a string to represent a value and time unit, - ensuring that it uses the right plural form of the unit. + Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. >>> _stringify_time_unit(1, "seconds") "1 second" @@ -18,7 +17,6 @@ def _stringify_time_unit(value: int, unit: str): >>> _stringify_time_unit(0, "minutes") "less than a minute" """ - if value == 1: return f"{value} {unit[:-1]}" elif value == 0: @@ -27,18 +25,8 @@ def _stringify_time_unit(value: int, unit: str): return f"{value} {unit}" -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6): - """ - Returns a human-readable version of the relativedelta. - - :param delta: A dateutil.relativedelta.relativedelta object - :param precision: The smallest unit that should be included. - :param max_units: The maximum number of time-units to return. - - :return: A string like `4 days, 12 hours and 1 second`, - `1 minute`, or `less than a minute`. - """ - +def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: + """Returns a human-readable version of the relativedelta.""" units = ( ("years", delta.years), ("months", delta.months), @@ -73,19 +61,8 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6): - """ - Takes a datetime and returns a human-readable string that - describes how long ago that datetime was. - - :param past_datetime: A datetime.datetime object - :param precision: The smallest unit that should be included. - :param max_units: The maximum number of time-units to return. - - :return: A string like `4 days, 12 hours and 1 second ago`, - `1 minute ago`, or `less than a minute ago`. - """ - +def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: + """Takes a datetime and returns a human-readable string that describes how long ago that datetime was.""" now = datetime.datetime.utcnow() delta = abs(relativedelta(now, past_datetime)) @@ -94,20 +71,17 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max return f"{humanized} ago" -def parse_rfc1123(time_str): +def parse_rfc1123(time_str: str) -> datetime.datetime: + """Parse RFC1123 time string into datetime.""" return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime): - """ - Wait until a given time. - - :param time: A datetime.datetime object to wait until. - """ - +async def wait_until(time: datetime.datetime) -> None: + """Wait until a given time.""" delay = time - datetime.datetime.utcnow() delay_seconds = delay.total_seconds() + # Incorporate a small delay so we don't rapid-fire the event due to time precision errors if delay_seconds > 1.0: await asyncio.sleep(delay_seconds) -- cgit v1.2.3 From 6bf86152b9eefc09e65d79537074f64904e04e04 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 12 Sep 2019 13:32:05 +0200 Subject: Fixed AntiSpam cog reload bug https://github.com/python-discord/bot/issues/411 The AntiSpam code suffered from a bug where the attribute self.muted_role was not defined after reloading the cog. The bug was caused by the cog setting the attribute in on_ready instead of directly in __init__. Fixed by setting the attribute in the __init__. Closes #411 --- bot/cogs/antispam.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0c6a02bf9..6104ec08b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -12,7 +12,7 @@ from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons, - Roles, STAFF_ROLES, + STAFF_ROLES, ) @@ -35,16 +35,13 @@ RULE_FUNCTION_MAPPING = { class AntiSpam: def __init__(self, bot: Bot): self.bot = bot - self._muted_role = Object(Roles.muted) + role_id = AntiSpamConfig.punishment['role_id'] + self.muted_role = Object(role_id) @property def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") - async def on_ready(self): - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) - async def on_message(self, message: Message): if ( not message.guild -- cgit v1.2.3 From e160254e9deaadfbe54a653ac126af928a86bb9b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 12 Sep 2019 13:40:01 +0200 Subject: Fixed incorrect API request field in superstarify https://github.com/python-discord/bot/issues/409 The superstarify cog specified an incorrect infraction type in the API request in the on_member_join event listener. I've fixed it by giving it the correct infraction type, 'superstar'. closes #409 --- bot/cogs/superstarify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 4b26f3f40..cccd91304 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -103,7 +103,7 @@ class Superstarify: 'bot/infractions', params={ 'active': 'true', - 'type': 'superstarify', + 'type': 'superstar', 'user__id': member.id } ) -- cgit v1.2.3 From 874cc1ba111e23973198773a2e4f14392e9c0fff Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 12 Sep 2019 15:18:14 +0200 Subject: Fix AntiSpam incorrectly invoking tempmute. https://github.com/python-discord/bot/issues/400 The AntiSpam punish method incorrectly invoked the tempmute command, as it provided an unconverted duration argument. Since direct invocation of commands bypasses converters, the conversion of the duration string to a datetime object is now done manually. Closes #400 --- bot/cogs/antispam.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 6104ec08b..02d5d64ce 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -14,6 +14,7 @@ from bot.constants import ( Guild as GuildConfig, Icons, STAFF_ROLES, ) +from bot.converters import ExpirationDate log = logging.getLogger(__name__) @@ -37,6 +38,7 @@ class AntiSpam: self.bot = bot role_id = AntiSpamConfig.punishment['role_id'] self.muted_role = Object(role_id) + self.expiration_date_converter = ExpirationDate() @property def mod_log(self) -> ModLog: @@ -130,8 +132,9 @@ class AntiSpam: ping_everyone=AntiSpamConfig.ping_everyone ) - # Run a tempmute - await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason) + # Since we're going to invoke the tempmute command directly, we need to manually call the converter. + dt_remove_role_after = await self.expiration_date_converter.convert(mod_log_ctx, f"{remove_role_after}S") + await mod_log_ctx.invoke(Moderation.tempmute, member, dt_remove_role_after, reason=reason) async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): # Is deletion of offending messages actually enabled? -- cgit v1.2.3 From 00f985875f04c752630e13add232944e46870779 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 12 Sep 2019 16:17:28 +0200 Subject: Add help-6 and help-7 to constants We never added channel IDs for the new help channels to the constants after adding them, so I'm adding them in. --- bot/constants.py | 2 ++ config-default.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index ead26c91d..c2b778b6e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -341,6 +341,8 @@ class Channels(metaclass=YAMLGetter): help_3: int help_4: int help_5: int + help_6: int + help_7: int helpers: int message_log: int mod_alerts: int diff --git a/config-default.yml b/config-default.yml index e8ad1d572..4c9cb72dc 100644 --- a/config-default.yml +++ b/config-default.yml @@ -103,6 +103,8 @@ guild: help_3: 439702951246692352 help_4: 451312046647148554 help_5: 454941769734422538 + help_6: 587375753306570782 + help_7: 587375768556797982 helpers: 385474242440986624 message_log: &MESSAGE_LOG 467752170159079424 mod_alerts: 473092532147060736 -- cgit v1.2.3 From 5303f4c7f708f010450ba187a868bd8ef05fa780 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 12 Sep 2019 16:30:46 +0200 Subject: Update bot cog with recent changes. The bot cog was not updated with recent changes to our community, so I've: - Updated the links in the about embed to GitHub; - Added help-6 and help-7 to the codeblock detection. --- bot/cogs/bot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 828e2514c..4a0f208f4 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -32,6 +32,8 @@ class Bot: Channels.help_3: 0, Channels.help_4: 0, Channels.help_5: 0, + Channels.help_6: 0, + Channels.help_7: 0, Channels.python: 0, } @@ -62,13 +64,13 @@ class Bot: embed = Embed( description="A utility bot designed just for the Python server! Try `!help` for more info.", - url="https://gitlab.com/discord-python/projects/bot" + url="https://github.com/python-discord/bot" ) embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members))) embed.set_author( name="Python Bot", - url="https://gitlab.com/discord-python/projects/bot", + url="https://github.com/python-discord/bot", icon_url=URLs.bot_avatar ) -- cgit v1.2.3 From ccb37f310bdf936223a83707c2541f98e0e61354 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 13 Sep 2019 14:06:30 +0200 Subject: Fix bugs and inconsistencies in moderation cog Recent changes and updates to the moderation cog introduced some inconsistencies that were causing bugs or differences in behavior between very similar commands. I've remedied the problems by: - Consistently making sure we stop if a post_infraction API call fails; - Factoring out the check for active infractions to a utility function; - Updating commands that expected a pre-migration API response format. In addition, I've also added function annotations. --- bot/cogs/moderation.py | 283 ++++++++++++++++++++++-------------------------- bot/utils/moderation.py | 20 ++++ 2 files changed, 149 insertions(+), 154 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 1dc2c70d6..bec2f98c1 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -2,7 +2,7 @@ import asyncio import logging import textwrap from datetime import datetime -from typing import Union +from typing import Dict, Union from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User @@ -17,7 +17,7 @@ from bot.constants import Colours, Event, Icons, MODERATION_ROLES from bot.converters import ExpirationDate, InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator -from bot.utils.moderation import post_infraction +from bot.utils.moderation import already_has_active_infraction, post_infraction from bot.utils.scheduling import Scheduler, create_task from bot.utils.time import wait_until @@ -81,15 +81,11 @@ class Moderation(Scheduler): **`reason`:** The reason for the warning. """ - response_object = await post_infraction(ctx, user, type="warning", reason=reason) - if response_object is None: + infraction = await post_infraction(ctx, user, type="warning", reason=reason) + if infraction is None: return - notified = await self.notify_infraction( - user=user, - infr_type="Warning", - reason=reason - ) + notified = await self.notify_infraction(user=user, infr_type="Warning", reason=reason) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: warned {user.mention}" @@ -118,7 +114,7 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @@ -136,15 +132,11 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return - response_object = await post_infraction(ctx, user, type="kick", reason=reason) - if response_object is None: + infraction = await post_infraction(ctx, user, type="kick", reason=reason) + if infraction is None: return - notified = await self.notify_infraction( - user=user, - infr_type="Kick", - reason=reason - ) + notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason) self.mod_log.ignore(Event.member_remove, user.id) @@ -178,7 +170,7 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @@ -196,22 +188,11 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return - active_bans = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'ban', - 'user__id': str(user.id) - } - ) - if active_bans: - return await ctx.send( - ":x: According to my records, this user is already banned. " - f"See infraction **#{active_bans[0]['id']}**." - ) + if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): + return - response_object = await post_infraction(ctx, user, type="ban", reason=reason) - if response_object is None: + infraction = await post_infraction(ctx, user, type="ban", reason=reason) + if infraction is None: return notified = await self.notify_infraction( @@ -255,7 +236,7 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @@ -268,22 +249,11 @@ class Moderation(Scheduler): **`reason`:** The reason for the mute. """ - active_mutes = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'mute', - 'user__id': str(user.id) - } - ) - if active_mutes: - return await ctx.send( - ":x: According to my records, this user is already muted. " - f"See infraction **#{active_mutes[0]['id']}**." - ) + if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): + return - response_object = await post_infraction(ctx, user, type="mute", reason=reason) - if response_object is None: + infraction = await post_infraction(ctx, user, type="mute", reason=reason) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) @@ -323,7 +293,7 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -331,10 +301,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def tempmute( - self, ctx: Context, user: Member, expiration: ExpirationDate, - *, reason: str = None - ): + async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None: """ Create a temporary mute infraction in the database for a user. @@ -342,26 +309,14 @@ class Moderation(Scheduler): **`duration`:** The duration for the temporary mute infraction **`reason`:** The reason for the temporary mute. """ + expiration = duration - active_mutes = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'mute', - 'user__id': str(user.id) - } - ) - if active_mutes: - return await ctx.send( - ":x: According to my records, this user is already muted. " - f"See infraction **#{active_mutes[0]['id']}**." - ) + if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): + return - infraction = await post_infraction( - ctx, user, - type="mute", reason=reason, - expires_at=expiration - ) + infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration) + if infraction is None: + return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) @@ -414,47 +369,32 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def tempban( - self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None - ): + async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None: """ Create a temporary ban infraction in the database for a user. **`user`:** Accepts user mention, ID, etc. - **`expiry`:** The duration for the temporary ban infraction + **`duration`:** The duration for the temporary ban infraction **`reason`:** The reason for the temporary ban. """ + expiration = duration if not await self.respect_role_hierarchy(ctx, user, 'tempban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method return - active_bans = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'active': 'true', - 'type': 'ban', - 'user__id': str(user.id) - } - ) - if active_bans: - return await ctx.send( - ":x: According to my records, this user is already banned. " - f"See infraction **#{active_bans[0]['id']}**." - ) + if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): + return - infraction = await post_infraction( - ctx, user, type="ban", - reason=reason, expires_at=expiry - ) + infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration) if infraction is None: return notified = await self.notify_infraction( user=user, infr_type="Ban", - expires_at=expiry, + expires_at=expiration, reason=reason ) @@ -510,7 +450,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn']) - async def note(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def note(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ Create a private infraction note in the database for a user. @@ -518,11 +458,8 @@ class Moderation(Scheduler): **`reason`:** The reason for the warning. """ - response_object = await post_infraction( - ctx, user, type="warning", reason=reason, hidden=True - ) - - if response_object is None: + infraction = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) + if infraction is None: return if reason is None: @@ -540,12 +477,12 @@ class Moderation(Scheduler): Actor: {ctx.message.author} Reason: {reason} """), - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ Kicks a user. @@ -558,8 +495,8 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return - response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) - if response_object is None: + infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) + if infraction is None: return self.mod_log.ignore(Event.member_remove, user.id) @@ -593,12 +530,12 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ Create a permanent ban infraction in the database for a user. @@ -611,8 +548,11 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return - response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) - if response_object is None: + if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): + return + + infraction = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) + if infraction is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -647,12 +587,12 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowmute', 'smute']) - async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ Create a permanent mute infraction in the database for a user. @@ -660,8 +600,11 @@ class Moderation(Scheduler): **`reason`:** The reason for the mute. """ - response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) - if response_object is None: + if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): + return + + infraction = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) @@ -682,7 +625,7 @@ class Moderation(Scheduler): Actor: {ctx.message.author} Reason: {reason} """), - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -691,8 +634,13 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempmute, stempmute"]) async def shadow_tempmute( - self, ctx: Context, user: Member, duration: str, *, reason: str = None - ): + self, + ctx: Context, + user: Member, + duration: ExpirationDate, + *, + reason: str = None + ) -> None: """ Create a temporary mute infraction in the database for a user. @@ -700,20 +648,25 @@ class Moderation(Scheduler): **`duration`:** The duration for the temporary mute infraction **`reason`:** The reason for the temporary mute. """ + expiration = duration - response_object = await post_infraction( - ctx, user, type="mute", reason=reason, duration=duration, hidden=True - ) - if response_object is None: + if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): + return + + infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration, hidden=True) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) - self.schedule_expiration(ctx.bot.loop, infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) if reason is None: await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.") @@ -731,17 +684,21 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( - self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None - ): + self, + ctx: Context, + user: UserTypes, + duration: ExpirationDate, + *, + reason: str = None + ) -> None: """ Create a temporary ban infraction in the database for a user. @@ -749,16 +706,18 @@ class Moderation(Scheduler): **`duration`:** The duration for the temporary ban infraction **`reason`:** The reason for the temporary ban. """ + expiration = duration if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'): # Ensure ctx author has a higher top role than the target user # Warning is sent to ctx by the helper method return - response_object = await post_infraction( - ctx, user, type="ban", reason=reason, duration=duration, hidden=True - ) - if response_object is None: + if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): + return + + infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, hidden=True) + if infraction is None: return self.mod_log.ignore(Event.member_ban, user.id) @@ -770,10 +729,13 @@ class Moderation(Scheduler): except Forbidden: action_result = False - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) - self.schedule_expiration(ctx.bot.loop, infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) if reason is None: await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.") @@ -799,11 +761,10 @@ class Moderation(Scheduler): Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -811,7 +772,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: Member): + async def unmute(self, ctx: Context, user: Member) -> None: """ Deactivates the active mute infraction for a user. @@ -833,9 +794,10 @@ class Moderation(Scheduler): if not response: # no active infraction - return await ctx.send( + await ctx.send( f":x: There is no active mute infraction for user {user.mention}." ) + return infraction = response[0] await self._deactivate_infraction(infraction) @@ -881,7 +843,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unban(self, ctx: Context, user: UserTypes): + async def unban(self, ctx: Context, user: UserTypes) -> None: """ Deactivates the active ban infraction for a user. @@ -906,9 +868,10 @@ class Moderation(Scheduler): if not response: # no active infraction - return await ctx.send( + await ctx.send( f":x: There is no active ban infraction for user {user.mention}." ) + return infraction = response[0] await self._deactivate_infraction(infraction) @@ -1043,7 +1006,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str): + async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str) -> None: """ Sets the reason of the given infraction. **`infraction_id`:** the id of the infraction @@ -1063,7 +1026,8 @@ class Moderation(Scheduler): except Exception: log.exception("There was an error updating an infraction.") - return await ctx.send(":x: There was an error updating the infraction.") + await ctx.send(":x: There was an error updating the infraction.") + return # Get information about the infraction's user user_id = updated_infraction['user'] @@ -1169,7 +1133,11 @@ class Moderation(Scheduler): # endregion # region: Utility functions - def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): + def schedule_expiration( + self, + loop: asyncio.AbstractEventLoop, + infraction_object: Dict[str, Union[str, int, bool]] + ) -> None: """ Schedules a task to expire a temporary infraction. @@ -1199,7 +1167,7 @@ class Moderation(Scheduler): log.debug(f"Unscheduled {infraction_id}.") del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction_object: dict): + async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None: """ A co-routine which marks an infraction as expired after the delay from the time of scheduling to the time of expiration. At the time of expiration, the infraction is @@ -1229,7 +1197,7 @@ class Moderation(Scheduler): icon_url=Icons.user_unmute ) - async def _deactivate_infraction(self, infraction_object): + async def _deactivate_infraction(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None: """ A co-routine which marks an infraction as inactive on the website. This co-routine does not cancel or un-schedule an expiration task. @@ -1258,7 +1226,7 @@ class Moderation(Scheduler): json={"active": False} ) - def _infraction_to_string(self, infraction_object): + def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str: actor_id = infraction_object["actor"] guild: Guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) @@ -1283,9 +1251,12 @@ class Moderation(Scheduler): return lines.strip() async def notify_infraction( - self, user: Union[User, Member], infr_type: str, - expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided." - ): + self, + user: Union[User, Member], + infr_type: str, + expires_at: Union[datetime, str] = 'N/A', + reason: str = "No reason provided." + ) -> bool: """ Notify a user of their fresh infraction :) @@ -1318,9 +1289,12 @@ class Moderation(Scheduler): return await self.send_private_embed(user, embed) async def notify_pardon( - self, user: Union[User, Member], title: str, content: str, - icon_url: str = Icons.user_verified - ): + self, + user: Union[User, Member], + title: str, + content: str, + icon_url: str = Icons.user_verified + ) -> bool: """ Notify a user that an infraction has been lifted. @@ -1339,7 +1313,7 @@ class Moderation(Scheduler): return await self.send_private_embed(user, embed) - async def send_private_embed(self, user: Union[User, Member], embed: Embed): + async def send_private_embed(self, user: Union[User, Member], embed: Embed) -> bool: """ A helper method for sending an embed to a user's DMs. @@ -1374,7 +1348,7 @@ class Moderation(Scheduler): # endregion - async def __error(self, ctx, error): + async def __error(self, ctx: Context, error) -> None: if isinstance(error, BadUnionArgument): if User in error.converters: await ctx.send(str(error.errors[0])) @@ -1409,6 +1383,7 @@ class Moderation(Scheduler): return target_is_lower -def setup(bot): +def setup(bot: Bot) -> None: + """Sets up the Moderation cog.""" bot.add_cog(Moderation(bot)) log.info("Cog loaded: Moderation") diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index c1eb98dd6..152f9d538 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -44,3 +44,23 @@ async def post_infraction( return return response + + +async def already_has_active_infraction(ctx: Context, user: Union[Member, Object, User], type: str) -> bool: + """Checks if a user already has an active infraction of the given type.""" + active_infractions = await ctx.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': type, + 'user__id': str(user.id) + } + ) + if active_infractions: + await ctx.send( + f":x: According to my records, this user already has a {type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return True + else: + return False -- cgit v1.2.3 From f287c5a690a78a886b29363634b0cdc2499dafb4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 13 Sep 2019 21:37:16 +0200 Subject: Fix one-off error in the !clean command https://github.com/python-discord/bot/issues/413 The message indexing phase of the `!clean` did not account for the presence of the invocation message, resulting in a one-off error in the amount of messages being indexed. Fixed it by adding one to the amount of messages we index from the message history. Closes #413 --- bot/cogs/clean.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index e7b6bac85..1f3e1caa9 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -133,7 +133,8 @@ class Clean: self.cleaning = True invocation_deleted = False - async for message in ctx.channel.history(limit=amount): + # To account for the invocation message, we index `amount + 1` messages. + async for message in ctx.channel.history(limit=amount + 1): # If at any point the cancel command is invoked, we should stop. if not self.cleaning: -- cgit v1.2.3 From 51832a333ea544df5c94943b7dc000c2dfcd0979 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 13 Sep 2019 14:46:35 -0700 Subject: Add error handlers for more command exceptions MissingPermissions, CheckFailure, DisabledCommand, and CommandOnCooldown will now have a simple message logged. * Log BotMissingPermissions and remove sending missing permissions as a message --- bot/cogs/error_handler.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 2063df09d..d2a67fd76 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -4,9 +4,13 @@ import logging from discord.ext.commands import ( BadArgument, BotMissingPermissions, + CheckFailure, CommandError, CommandInvokeError, CommandNotFound, + CommandOnCooldown, + DisabledCommand, + MissingPermissions, NoPrivateMessage, UserInputError, ) @@ -58,10 +62,12 @@ class ErrorHandler: elif isinstance(e, NoPrivateMessage): await ctx.send("Sorry, this command can't be used in a private message!") elif isinstance(e, BotMissingPermissions): - await ctx.send( - f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" - f"Here's what I'm missing: **{e.missing_perms}**" - ) + await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") + log.warning(f"The bot is missing permissions to execute command {command}: {e.missing_perms}") + elif isinstance(e, MissingPermissions): + log.debug(f"{ctx.message.author} is missing permissions to invoke command {command}: {e.missing_perms}") + elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): + log.debug(f"Command {command} invoked by {ctx.message.author} with error {e.__class__.__name__}: {e}") elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): if e.original.response.status == 404: @@ -77,7 +83,6 @@ class ErrorHandler: "Got an unexpected status code from the " f"API (`{e.original.response.code}`)." ) - else: await ctx.send( f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" -- cgit v1.2.3 From f714fa93d691a264a2f4e2b8cd798aa4276caab0 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 14 Sep 2019 13:02:23 +0200 Subject: Add API response dict to ResponseCodeError The ReponseCodeError held a reference to `aiohttp.ResonseObject` to make sure the response data was available. However, the response data is not actually included in the Response Object, but needs to be awaited. Unfortunately, the ResponseCodeError is usually inspected after the connection has been closed, triggering a ClientConnectionError when the data was retrieved. I solved this by adding the awaited reponse data directly to our custom exception by awaiting the response.json() before raising the exception. --- bot/api.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/bot/api.py b/bot/api.py index cd19896e1..36f9cfcd4 100644 --- a/bot/api.py +++ b/bot/api.py @@ -10,9 +10,14 @@ log = logging.getLogger(__name__) class ResponseCodeError(ValueError): - def __init__(self, response: aiohttp.ClientResponse): + def __init__(self, response: aiohttp.ClientResponse, response_data: dict): + self.status = response.status + self.response_data = response_data self.response = response + def __str__(self): + return f"Status: {self.status} Response: {self.response_data}" + class APIClient: def __init__(self, **kwargs): @@ -31,28 +36,29 @@ class APIClient: def _url_for(endpoint: str): return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" - def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): + async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): if should_raise and response.status >= 400: - raise ResponseCodeError(response=response) + response_data = await response.json() + raise ResponseCodeError(response=response, response_data=response_data) async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: - self.maybe_raise_for_status(resp, raise_for_status) + await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: - self.maybe_raise_for_status(resp, raise_for_status) + await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: - self.maybe_raise_for_status(resp, raise_for_status) + await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: - self.maybe_raise_for_status(resp, raise_for_status) + await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): @@ -60,7 +66,7 @@ class APIClient: if resp.status == 204: return None - self.maybe_raise_for_status(resp, raise_for_status) + await self.maybe_raise_for_status(resp, raise_for_status) return await resp.json() -- cgit v1.2.3 From f983b8bd4766a8bfd4dfe1b7d0c249b039244dc1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 14 Sep 2019 13:10:08 +0200 Subject: Make 'post_infraction' catch the right exception The internal 'api' of our API client has changed: It raises a custom RespondeCodeError instead of an `aiohttp.ClientError` when an API was not successful. I updated this utility function to catch the right exception and added handling for unknown users by notifying the user of that problem directly. --- bot/utils/moderation.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 152f9d538..b295e4649 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,11 +1,11 @@ import logging from datetime import datetime -from typing import Union +from typing import Optional, Union -from aiohttp import ClientError from discord import Member, Object, User from discord.ext.commands import Context +from bot.api import ResponseCodeError from bot.constants import Keys log = logging.getLogger(__name__) @@ -21,8 +21,8 @@ async def post_infraction( expires_at: datetime = None, hidden: bool = False, active: bool = True, -): - +) -> Optional[dict]: + """Posts an infraction to the API.""" payload = { "actor": ctx.message.author.id, "hidden": hidden, @@ -35,13 +35,19 @@ async def post_infraction( payload['expires_at'] = expires_at.isoformat() try: - response = await ctx.bot.api_client.post( - 'bot/infractions', json=payload - ) - except ClientError: - log.exception("There was an error adding an infraction.") - await ctx.send(":x: There was an error adding the infraction.") - return + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + except ResponseCodeError as exp: + if exp.status == 400 and 'user' in exp.response_data: + log.info( + f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " + "but that user id was not found in the database." + ) + await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.") + return + else: + log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") + await ctx.send(":x: There was an error adding the infraction.") + return return response -- cgit v1.2.3 From 279fa85b93d5017a63d68516b2477a86920caa16 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 13:23:44 +0200 Subject: Improvements to the Wolfram cog. This commit adds clearer error messages for all the errors produced by the Wolfram cog if the API key is missing in the config, or if the key isn't valid anymore. It also humanizes the timedelta returned in the error users get when they run out their cooldown. Instead of telling them they need to wait 84000 seconds, it will now tell them they need to wait 23 hours, 59 minutes ... --- bot/cogs/wolfram.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index e8b16b243..7dd613083 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -4,12 +4,14 @@ from typing import List, Optional, Tuple from urllib import parse import discord +from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands from discord.ext.commands import BucketType, Context, check, group from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -79,9 +81,11 @@ def custom_cooldown(*ignore: List[int]) -> check: if user_rate: # Can't use api; cause: member limit + delta = relativedelta(seconds=int(user_rate)) + cooldown = humanize_delta(delta) message = ( "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {int(user_rate)}" + f"Cooldown: {cooldown}" ) await send_embed(ctx, message) return False @@ -121,17 +125,27 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: result = json["queryresult"] - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - if result["error"]: + # API key not set up correctly + if result["error"]["msg"] == "Invalid appid": + message = "Wolfram API key is invalid or missing." + log.warning( + "API key seems to be missing, or invalid when " + f"processing a wolfram request: {url_str}, Response: {json}" + ) + await send_embed(ctx, message) + return + message = "Something went wrong internally with your request, please notify staff!" log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") await send_embed(ctx, message) return + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + if not result["numpods"]: message = "Could not find any results." await send_embed(ctx, message) @@ -191,6 +205,10 @@ class Wolfram: message = "No input found" footer = "" color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red else: message = "" footer = "View original for a bigger picture." @@ -272,10 +290,12 @@ class Wolfram: if status == 501: message = "Failed to get response" color = Colours.soft_red - elif status == 400: message = "No input found" color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red else: message = response_text color = Colours.soft_orange -- cgit v1.2.3 From 042a5fcbd90a99e8f15ee3d891e87d6c867d1b06 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 13:46:27 +0200 Subject: Adds a !nominees alias. This invokes the `!nomination list` command, showing all currently nominated users. --- bot/cogs/alias.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 85d101448..dbdd2ee6a 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -197,6 +197,14 @@ class Alias: await self.invoke(ctx, "nomination end", user, reason=reason) + @command(name="nominees", hidden=True) + async def nominees_alias(self, ctx): + """ + Alias for invoking tp watched. + """ + + await self.invoke(ctx, "talentpool watched") + def setup(bot): bot.add_cog(Alias(bot)) -- cgit v1.2.3 From b103fc0d5fb16122271a77db99f01a6b86175994 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 14:18:39 +0200 Subject: We now also detect bot tokens outside quotes. Previously, the regex to detect tokens would only trigger on messages where the token was inside of single or double quotes. This commit changes this so that we look for bot tokens regardless of context. --- bot/cogs/token_remover.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 05298a2ff..b2c4cd522 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -26,13 +26,11 @@ DELETION_MESSAGE_TEMPLATE = ( DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1) TOKEN_EPOCH = 1_293_840_000 TOKEN_RE = re.compile( - r"(?<=(\"|'))" # Lookbehind: Only match if there's a double or single quote in front r"[^\s\.]+" # Matches token part 1: The user ID string, encoded as base64 r"\." # Matches a literal dot between the token parts r"[^\s\.]+" # Matches token part 2: The creation timestamp, as an integer r"\." # Matches a literal dot between the token parts r"[^\s\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty - r"(?=(\"|'))" # Lookahead: Only match if there's a double or single quote after ) -- cgit v1.2.3 From bbbdfcec41c06eaf0d031d7f2a58f111e8e88a16 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 14 Sep 2019 14:39:58 +0200 Subject: Make ResponseErrorCode handle non-JSON response The previous commit assumed the API respone to always be JSON. This leads to issues when that is not the case, such as when the API is completely unreachable (text/html 404 response). Updated the ResponseErrorCode exception to account for that and updated the moderation util `post_infraction` to reflect that. --- bot/api.py | 22 +++++++++++++++++----- bot/utils/moderation.py | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/bot/api.py b/bot/api.py index 36f9cfcd4..9a0ebaa26 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,5 +1,6 @@ import asyncio import logging +from typing import Optional from urllib.parse import quote as quote_url import aiohttp @@ -10,13 +11,20 @@ log = logging.getLogger(__name__) class ResponseCodeError(ValueError): - def __init__(self, response: aiohttp.ClientResponse, response_data: dict): + def __init__( + self, + response: aiohttp.ClientResponse, + response_json: Optional[dict] = None, + response_text: str = "" + ): self.status = response.status - self.response_data = response_data + self.response_json = response_json or {} + self.response_text = response_text self.response = response def __str__(self): - return f"Status: {self.status} Response: {self.response_data}" + response = self.response_json if self.response_json else self.response_text + return f"Status: {self.status} Response: {response}" class APIClient: @@ -38,8 +46,12 @@ class APIClient: async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): if should_raise and response.status >= 400: - response_data = await response.json() - raise ResponseCodeError(response=response, response_data=response_data) + try: + response_json = await response.json() + raise ResponseCodeError(response=response, response_json=response_json) + except aiohttp.ContentTypeError: + response_text = await response.text() + raise ResponseCodeError(response=response, response_text=response_text) async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index b295e4649..7860f14a1 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -37,7 +37,7 @@ async def post_infraction( try: response = await ctx.bot.api_client.post('bot/infractions', json=payload) except ResponseCodeError as exp: - if exp.status == 400 and 'user' in exp.response_data: + if exp.status == 400 and 'user' in exp.response_json: log.info( f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " "but that user id was not found in the database." -- cgit v1.2.3 From 85d0c1b54e2e70ae17d824c360113ea55985520c Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 14 Sep 2019 20:59:50 +0800 Subject: Add more information to team creation messages --- bot/cogs/jams.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 96b98e559..4cf878791 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -35,9 +35,11 @@ class CodeJams: # two members or at some times even 1 member. This fixes that by checking that there # are always 3 members in the members list. if len(members) < 3: - await ctx.send(":no_entry_sign: One of your arguments was invalid - there must be a " - f"minimum of 3 valid members in your team. Found: {len(members)} " - "members") + await ctx.send( + ":no_entry_sign: One of your arguments was invalid\n" + f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" + " members" + ) return code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") @@ -102,7 +104,11 @@ class CodeJams: for member in members: await member.add_roles(jammer_role) - await ctx.send(f":ok_hand: Team created: {team_channel.mention}") + await ctx.send( + f":ok_hand: Team created: {team_channel.mention}\n" + f"**Team Leader:** {members[0].mention}\n" + f"**Team Members:** {' '.join(member.mention for member in members[1:])}" + ) def setup(bot): -- cgit v1.2.3 From 6521fcc3727e4b5daba0b68487ef1f3df4ceb27f Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 14 Sep 2019 21:00:38 +0800 Subject: Remove duplicate members passed into team creation command --- bot/cogs/jams.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 4cf878791..28a84a0c9 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -2,6 +2,7 @@ import logging from discord import Member, PermissionOverwrite, utils from discord.ext import commands +from more_itertools import unique_everseen from bot.constants import Roles from bot.decorators import with_role @@ -29,6 +30,8 @@ class CodeJams: The first user passed will always be the team leader. """ + # Ignore duplicate members + members = list(unique_everseen(members)) # We had a little issue during Code Jam 4 here, the greedy converter did it's job # and ignored anything which wasn't a valid argument which left us with teams of -- cgit v1.2.3 From 9ae39f343a685a985778797e9f9195d205bc2e43 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 15:05:42 +0200 Subject: Nuking the Roles.developer role. We're not really using this, we're using Roles.verified. This provides superiour readability, and there's no reason we should use two instead of just one. I also added a comment to clarify that this role represents the Developers role on pydis. --- bot/cogs/jams.py | 2 +- bot/constants.py | 3 +-- config-default.yml | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 96b98e559..43b31672b 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -65,7 +65,7 @@ class CodeJams: connect=True ), ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - ctx.guild.get_role(Roles.developer): PermissionOverwrite( + ctx.guild.get_role(Roles.verified): PermissionOverwrite( read_messages=False, connect=False ) diff --git a/bot/constants.py b/bot/constants.py index c2b778b6e..45c332438 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -374,13 +374,12 @@ class Roles(metaclass=YAMLGetter): announcements: int champion: int contributor: int - developer: int devops: int jammer: int moderator: int muted: int owner: int - verified: int + verified: int # This is the Developers role on PyDis, here named verified for readability reasons. helpers: int team_leader: int diff --git a/config-default.yml b/config-default.yml index 4c9cb72dc..fb33caaf6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -127,7 +127,6 @@ guild: announcements: 463658397560995840 champion: 430492892331769857 contributor: 295488872404484098 - developer: 352427296948486144 devops: &DEVOPS_ROLE 409416496733880320 jammer: 423054537079783434 moderator: &MOD_ROLE 267629731250176001 -- cgit v1.2.3 From a7f4d71ab858b810f9e0fe569af8936a1c2c81e3 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 14 Sep 2019 21:53:48 +0800 Subject: Add more_itertools as a dependency --- Pipfile | 1 + Pipfile.lock | 51 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Pipfile b/Pipfile index 2e56a3d7b..739507ac3 100644 --- a/Pipfile +++ b/Pipfile @@ -18,6 +18,7 @@ python-dateutil = "*" deepdiff = "*" requests = "*" dateparser = "*" +more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 6b91ff8aa..f655943b4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "61607e940ea00e1197900c04fe1298e048d1f415db2f1a2a3a157406c6ea2b0c" + "sha256": "987e3fc1840e8050f159daa9c23a2c67bd18d17914d4295eb469a42c778daa10" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:c1424962ef9e28fbda840d76425cdd139605e480a4d68164303cda8d356ba9de", - "sha256:dcfe9c11af2ab9ff6c1c5a366d094c2a7542bab534d98a4aea29518672c9d7ac" + "sha256:31e08189841a8350db5bec70608b4d2fbacb89c0a555a18ec47511716a9bfc41", + "sha256:adfe0acf34356ccd9654a9a1c46f7e8db1dc4497a774c0e54bf2d3af14571bd0" ], "index": "pypi", - "version": "==6.1.0" + "version": "==6.1.1" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:472734ab3cf18001fb8cedb38ee13008292230a461b6482dbdf65590441ce32c", - "sha256:4b6b2b43616b7a6b353ecf9896ae29ac2f74a38c4c53bfe73824ac2807faca5d" + "sha256:0b755b748d87d5ec63b4b7f162102333bf0901caf1f8a2bf29467bbdd54e637d", + "sha256:f8eef1f98bc331a266404d925745fac589dab30412688564d740754d6d643863" ], - "version": "==2.7.4" + "version": "==2.7.5" }, "alabaster": { "hashes": [ @@ -105,10 +105,10 @@ }, "certifi": { "hashes": [ - "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", - "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.6.16" + "version": "==2019.9.11" }, "cffi": { "hashes": [ @@ -293,6 +293,14 @@ ], "version": "==1.1.1" }, + "more-itertools": { + "hashes": [ + "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", + "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + ], + "index": "pypi", + "version": "==7.2.0" + }, "multidict": { "hashes": [ "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", @@ -494,9 +502,9 @@ }, "snowballstemmer": { "hashes": [ - "sha256:9f3b9ffe0809d174f7047e121431acf99c89a7040f0ca84f94ba53a498e6d0c9" + "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" ], - "version": "==1.9.0" + "version": "==1.9.1" }, "soupsieve": { "hashes": [ @@ -637,10 +645,10 @@ }, "certifi": { "hashes": [ - "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", - "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.6.16" + "version": "==2019.9.11" }, "cfgv": { "hashes": [ @@ -747,11 +755,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:9ff1b1c5a354142de080b8a4e9803e5d0d59283c93aed808617c787d16768375", - "sha256:b7143592e374e50584564794fcb8aaf00a23025f9db866627f89a21491847a8d" + "sha256:652234b6ab8f2506ae58e528b6fbcc668831d3cc758e1bc01ef438d328b68cdb", + "sha256:6f264986fb88042bc1f0535fa9a557e6a376cfe5679dc77caac7fe8b5d43d05f" ], "markers": "python_version < '3.8'", - "version": "==0.20" + "version": "==0.22" }, "mccabe": { "hashes": [ @@ -765,6 +773,7 @@ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" ], + "index": "pypi", "version": "==7.2.0" }, "nodeenv": { @@ -782,10 +791,10 @@ }, "pluggy": { "hashes": [ - "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", - "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" + "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", + "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" ], - "version": "==0.12.0" + "version": "==0.13.0" }, "pre-commit": { "hashes": [ -- cgit v1.2.3 From 4bc4dc797258abe9f1b432260b47a9abf7e999c8 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sat, 14 Sep 2019 22:09:35 +0800 Subject: Allow multiple words for !otn a --- bot/cogs/off_topic_names.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index c0d2e5dc5..5a61425be 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -95,27 +95,31 @@ class OffTopicNames: @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) - async def add_command(self, ctx, name: OffTopicName): + async def add_command(self, ctx, *names: OffTopicName): """Adds a new off-topic name to the rotation.""" + # Chain multiple words to a single one + name = "-".join(names) await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) log.info( f"{ctx.author.name}#{ctx.author.discriminator}" f" added the off-topic channel name '{name}" ) - await ctx.send(":ok_hand:") + await ctx.send(f":ok_hand: Added `{name}` to the names list.") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx, name: OffTopicName): + async def delete_command(self, ctx, *names: OffTopicName): """Removes a off-topic name from the rotation.""" + # Chain multiple words to a single one + name = "-".join(names) await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') log.info( f"{ctx.author.name}#{ctx.author.discriminator}" f" deleted the off-topic channel name '{name}" ) - await ctx.send(":ok_hand:") + await ctx.send(f":ok_hand: Removed `{name}` from the names list.") @otname_group.command(name='list', aliases=('l',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From d97e0355818fd994b24a6057a43fda3b5c66cb2f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 16:24:38 +0200 Subject: Don't allow tag invocations in #checkpoint. There was a bug which would permit tag invocations here, because these were triggered by an error handler on CommandNotFound. This commit prevents that from being possible. --- bot/cogs/error_handler.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d2a67fd76..a57cabf1e 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -17,7 +17,7 @@ from discord.ext.commands import ( from discord.ext.commands import Bot, Context from bot.api import ResponseCodeError - +from bot.constants import Channels log = logging.getLogger(__name__) @@ -47,12 +47,13 @@ class ErrorHandler: return if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True + if not ctx.channel.id == Channels.verification: + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True - # Return to not raise the exception - with contextlib.suppress(ResponseCodeError): - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + # Return to not raise the exception + with contextlib.suppress(ResponseCodeError): + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) -- cgit v1.2.3 From 75428c457c527ed7411685c006e5fe700c68b9a3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 17:33:44 +0200 Subject: Fixes all URLs in the Site cog. This changes URLs for stuff like FAQ, rules, and the Asking Good Questions page to fit the Django format. --- bot/cogs/site.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 37bf4f4ea..b5e63fb41 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -9,7 +9,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -INFO_URL = f"{URLs.site_schema}{URLs.site}/info" +PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" class Site: @@ -46,7 +46,7 @@ class Site: async def site_resources(self, ctx: Context): """Info about the site's Resources page.""" - url = f"{INFO_URL}/resources" + url = f"{PAGES_URL}/resources" embed = Embed(title="Resources") embed.set_footer(text=url) @@ -63,9 +63,9 @@ class Site: async def site_help(self, ctx: Context): """Info about the site's Getting Help page.""" - url = f"{INFO_URL}/help" + url = f"{PAGES_URL}/asking-good-questions" - embed = Embed(title="Getting Help") + embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) embed.colour = Colour.blurple() embed.description = ( @@ -80,7 +80,7 @@ class Site: async def site_faq(self, ctx: Context): """Info about the site's FAQ page.""" - url = f"{INFO_URL}/faq" + url = f"{PAGES_URL}/frequently-asked-questions" embed = Embed(title="FAQ") embed.set_footer(text=url) @@ -105,7 +105,7 @@ class Site: **`rules`:** The rules a user wants to get. """ rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules" + rules_embed.url = f"{PAGES_URL}/rules" if not rules: # Rules were not submitted. Return the default description. -- cgit v1.2.3 From b8eee655cbfd7767912134c934f962e2aa76415b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 14 Sep 2019 18:01:53 +0200 Subject: Deal with multiple active infractions in database While there are measures to prevent duplicate active infractions at the side of the bot, there's no formal restriction at the sid of the database. This means that it's possible for a user two get two active ban infractions or two active mute infractions at the same time, for instance after a manual table edit in Admin. This leads to an inconsistent state when unmuting/unbanning that user, as the ban or mute role will be removed on Discord, but only one of the entries in the database would be set to inactive, while the other(s) remain active. This means that an unmuted user will be remuted if they leave and rejoin the Guild. To handle this, I've inserted code that sets all the infractions to inactive and cancels all related infraction tasks (in the case of temporary infractions) when issuing an unban or unmute command. A note will be added to the mod_log embed to notify us as well. --- bot/cogs/moderation.py | 74 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index bec2f98c1..63c0e4417 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import Dict, Union from discord import ( - Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User + Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User ) from discord.ext.commands import ( BadArgument, BadUnionArgument, Bot, Context, command, group @@ -772,7 +772,7 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: Member) -> None: + async def unmute(self, ctx: Context, user: UserTypes) -> None: """ Deactivates the active mute infraction for a user. @@ -799,10 +799,10 @@ class Moderation(Scheduler): ) return - infraction = response[0] - await self._deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) + for infraction in response: + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) notified = await self.notify_pardon( user=user, @@ -822,19 +822,31 @@ class Moderation(Scheduler): await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") + embed_text = textwrap.dedent( + f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + DM: {dm_status} + """ + ) + + if len(response) > 1: + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + title = "Member unmuted" + embed_text += "Note: User had multiple **active** mute infractions in the database." + else: + infraction = response[0] + footer = f"Infraction ID: {infraction['id']}" + title = "Member unmuted" + # Send a log message to the mod log await self.mod_log.send_log_message( icon_url=Icons.user_unmute, colour=Colour(Colours.soft_green), - title="Member unmuted", + title=title, thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Intended expiry: {infraction['expires_at']} - DM: {dm_status} - """), - footer=infraction["id"], + text=embed_text, + footer=footer, content=log_content ) except Exception: @@ -873,10 +885,24 @@ class Moderation(Scheduler): ) return - infraction = response[0] - await self._deactivate_infraction(infraction) - if infraction["expires_at"] is not None: - self.cancel_expiration(infraction["id"]) + for infraction in response: + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) + + embed_text = textwrap.dedent( + f""" + Member: {user.mention} (`{user.id}`) + Actor: {ctx.message.author} + """ + ) + + if len(response) > 1: + footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" + embed_text += "Note: User had multiple **active** ban infractions in the database." + else: + infraction = response[0] + footer = f"Infraction ID: {infraction['id']}" await ctx.send(f":ok_hand: Un-banned {user.mention}.") @@ -886,11 +912,8 @@ class Moderation(Scheduler): colour=Colour(Colours.soft_green), title="Member unbanned", thumbnail=user.avatar_url_as(static_format="png"), - text=textwrap.dedent(f""" - Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author} - Intended expiry: {infraction['expires_at']} - """) + text=embed_text, + footer=footer, ) except Exception: log.exception("There was an error removing an infraction.") @@ -1219,7 +1242,10 @@ class Moderation(Scheduler): log.warning(f"Failed to un-mute user: {user_id} (not found)") elif infraction_type == "ban": user: Object = Object(user_id) - await guild.unban(user) + try: + await guild.unban(user) + except NotFound: + log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.") await self.bot.api_client.patch( 'bot/infractions/' + str(infraction_object['id']), -- cgit v1.2.3 From d538d8fa4e63cb12c79bdc8f32b3f133a2f16c62 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 18:39:03 +0200 Subject: Nuking Roles.devops and the Deployment cog. The Deployment cog does not work in our new server environment, nor are we particularly inclined to make it work (for security reasons). For this reason, I've removed it. I've also removed all usages of Roles.devops, replacing them with Roles.core_developer whenever this made sense to do, such as in the Cogs cog. --- bot/__main__.py | 1 - bot/cogs/cogs.py | 10 +++--- bot/cogs/deployment.py | 90 -------------------------------------------------- bot/constants.py | 2 +- config-default.yml | 2 +- 5 files changed, 7 insertions(+), 98 deletions(-) delete mode 100644 bot/cogs/deployment.py diff --git a/bot/__main__.py b/bot/__main__.py index e12508e6d..b1a6a5fcd 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -53,7 +53,6 @@ if not DEBUG_MODE: # Feature cogs bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") -bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.information") diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 5bef52c0a..eec611824 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -37,14 +37,14 @@ class Cogs: self.cogs.update({v: k for k, v in self.cogs.items()}) @group(name='cogs', aliases=('c',), invoke_without_command=True) - @with_role(*MODERATION_ROLES, Roles.devops) + @with_role(*MODERATION_ROLES, Roles.core_developer) async def cogs_group(self, ctx: Context): """Load, unload, reload, and list active cogs.""" await ctx.invoke(self.bot.get_command("help"), "cogs") @cogs_group.command(name='load', aliases=('l',)) - @with_role(*MODERATION_ROLES, Roles.devops) + @with_role(*MODERATION_ROLES, Roles.core_developer) async def load_command(self, ctx: Context, cog: str): """ Load up an unloaded cog, given the module containing it @@ -97,7 +97,7 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='unload', aliases=('ul',)) - @with_role(*MODERATION_ROLES, Roles.devops) + @with_role(*MODERATION_ROLES, Roles.core_developer) async def unload_command(self, ctx: Context, cog: str): """ Unload an already-loaded cog, given the module containing it @@ -149,7 +149,7 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='reload', aliases=('r',)) - @with_role(*MODERATION_ROLES, Roles.devops) + @with_role(*MODERATION_ROLES, Roles.core_developer) async def reload_command(self, ctx: Context, cog: str): """ Reload an unloaded cog, given the module containing it @@ -254,7 +254,7 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='list', aliases=('all',)) - @with_role(*MODERATION_ROLES, Roles.devops) + @with_role(*MODERATION_ROLES, Roles.core_developer) async def list_command(self, ctx: Context): """ Get a list of all cogs, including their loaded status. diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py deleted file mode 100644 index e71e07c2f..000000000 --- a/bot/cogs/deployment.py +++ /dev/null @@ -1,90 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Bot, Context, command, group - -from bot.constants import Keys, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role - -log = logging.getLogger(__name__) - - -class Deployment: - """ - Bot information commands - """ - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name='redeploy', invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def redeploy_group(self, ctx: Context): - """Redeploy the bot or the site.""" - - await ctx.invoke(self.bot.get_command("help"), "redeploy") - - @redeploy_group.command(name='bot') - @with_role(Roles.admin, Roles.owner, Roles.devops) - async def bot_command(self, ctx: Context): - """ - Trigger bot deployment on the server - will only redeploy if there were changes to deploy - """ - - response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) - result = await response.text() - - if result == "True": - log.debug(f"{ctx.author} triggered deployment for bot. Deployment was started.") - await ctx.send(f"{ctx.author.mention} Bot deployment started.") - else: - log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.") - await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!") - - @redeploy_group.command(name='site') - @with_role(Roles.admin, Roles.owner, Roles.devops) - async def site_command(self, ctx: Context): - """ - Trigger website deployment on the server - will only redeploy if there were changes to deploy - """ - - response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) - result = await response.text() - - if result == "True": - log.debug(f"{ctx.author} triggered deployment for site. Deployment was started.") - await ctx.send(f"{ctx.author.mention} Site deployment started.") - else: - log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.") - await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!") - - @command(name='uptimes') - @with_role(Roles.admin, Roles.owner, Roles.devops) - async def uptimes_command(self, ctx: Context): - """ - Check the various deployment uptimes for each service - """ - - log.debug(f"{ctx.author} requested service uptimes.") - response = await self.bot.http_session.get(URLs.status) - data = await response.json() - - embed = Embed( - title="Service status", - color=Colour.blurple() - ) - - for obj in data: - key, value = list(obj.items())[0] - - embed.add_field( - name=key, value=value, inline=True - ) - - log.debug("Uptimes retrieved and parsed, returning data.") - await ctx.send(embed=embed) - - -def setup(bot): - bot.add_cog(Deployment(bot)) - log.info("Cog loaded: Deployment") diff --git a/bot/constants.py b/bot/constants.py index 45c332438..4e14a85a8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -374,7 +374,7 @@ class Roles(metaclass=YAMLGetter): announcements: int champion: int contributor: int - devops: int + core_developer: int jammer: int moderator: int muted: int diff --git a/config-default.yml b/config-default.yml index fb33caaf6..20897f78b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -127,7 +127,7 @@ guild: announcements: 463658397560995840 champion: 430492892331769857 contributor: 295488872404484098 - devops: &DEVOPS_ROLE 409416496733880320 + core_developer: 587606783669829632 jammer: 423054537079783434 moderator: &MOD_ROLE 267629731250176001 muted: &MUTED_ROLE 277914926603829249 -- cgit v1.2.3 From a00a55257ba57f26716aef4cc632737d890d75fa Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 19:01:47 +0200 Subject: Oops, forgot to remove DEVOPS role alias, that would crash the bot. --- config-default.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 20897f78b..b31f79272 100644 --- a/config-default.yml +++ b/config-default.yml @@ -223,7 +223,6 @@ filter: - *ADMIN_ROLE - *MOD_ROLE - *OWNER_ROLE - - *DEVOPS_ROLE - *ROCKSTARS_ROLE -- cgit v1.2.3 From fd7fb3508ae7be9fcad146ea74be5b07736ba266 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 19:47:30 +0200 Subject: Cogs.reload now has more readable error info. When you run '!cogs reload *', the load failures will now include the Exception type, and has overall more readable formatting. Similarly, trying to do '!cogs load ' on a broken cog will show the Exception type as well. --- bot/cogs/cogs.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index eec611824..ebdbf5ad8 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -84,8 +84,8 @@ class Cogs: except Exception as e: log.error(f"{ctx.author} requested we load the '{cog}' cog, " "but the loading failed with the following error: \n" - f"{e}") - embed.description = f"Failed to load cog: {cog}\n\n```{e}```" + f"**{e.__class__.__name__}: {e}**") + embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}" else: log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!") embed.description = f"Cog loaded: {cog}" @@ -200,7 +200,7 @@ class Cogs: try: self.bot.unload_extension(loaded_cog) except Exception as e: - failed_unloads[loaded_cog] = str(e) + failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}" else: unloaded += 1 @@ -208,7 +208,7 @@ class Cogs: try: self.bot.load_extension(unloaded_cog) except Exception as e: - failed_loads[unloaded_cog] = str(e) + failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}" else: loaded += 1 @@ -221,13 +221,13 @@ class Cogs: lines.append("\n**Unload failures**") for cog, error in failed_unloads: - lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") + lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") if failed_loads: lines.append("\n**Load failures**") - for cog, error in failed_loads: - lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") + for cog, error in failed_loads.items(): + lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`") log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" f"{lines}") -- cgit v1.2.3 From 5ec4a7044f9bbf41dc9460c452335cabcba602f3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 11:14:32 -0700 Subject: Fix tag command invocation in aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- bot/cogs/alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index dbdd2ee6a..a44c47331 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -143,7 +143,7 @@ class Alias: Alias for invoking tags get traceback. """ - await self.invoke(ctx, "tags get traceback") + await self.invoke(ctx, "tags get", tag_name="traceback") @group(name="get", aliases=("show", "g"), @@ -167,7 +167,7 @@ class Alias: tag_name: str - tag to be viewed. """ - await self.invoke(ctx, "tags get", tag_name) + await self.invoke(ctx, "tags get", tag_name=tag_name) @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) async def docs_get_alias( -- cgit v1.2.3 From 6dec487d58c936be9f224b2b2b2e871cc67f34d2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sat, 14 Sep 2019 20:17:43 +0200 Subject: Improve handling of long deleted messsages The `modlog` cog failed on long messages with attachments, since the inclusion of attachment metadata would bring the length of the embed description to go over the character limit of 2048. To fix this, I moved the addition of the attachment metadata to earlier in the code so it is taken into account for the character limit. In addition to this, I changed the truncation behavior. Instead of just truncating the message if it's too long, we now truncate and upload the full message to the `deleted messages` endpoint so the full message is still available. A link to the log will be included in the message-log embed. --- bot/cogs/modlog.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 9f0c88424..808ba667b 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -528,19 +528,22 @@ class ModLog: "\n" ) + if message.attachments: + # Prepend the message metadata with the number of attachments + response = f"**Attachments:** {len(message.attachments)}\n" + response + # Shorten the message content if necessary content = message.clean_content remaining_chars = 2040 - len(response) if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." + botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) + ending = f"\n\nMessage truncated, [full message here]({botlog_url})." + truncation_point = remaining_chars - len(ending) + content = f"{content[:truncation_point]}...{ending}" response += f"{content}" - if message.attachments: - # Prepend the message metadata with the number of attachments - response = f"**Attachments:** {len(message.attachments)}\n" + response - await self.send_log_message( Icons.message_delete, Colours.soft_red, "Message deleted", -- cgit v1.2.3 From 4178973a5bc53a584ea31540ecf6638f0e8307fc Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 20:49:48 +0200 Subject: Fixes a sneaky genexp exhaustion bug in @without_role. This problem made the decorator only check the first role that was passed into it, instead of checking all the roles. In other words, the check would fail on *STAFF_ROLES unless you had the Helpers role. Solved by refactoring the genexp to a listcomp. --- bot/utils/checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 37dc657f7..195edab0f 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -37,7 +37,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: "This command is restricted by the without_role decorator. Rejecting request.") return False - author_roles = (role.id for role in ctx.author.roles) + author_roles = [role.id for role in ctx.author.roles] check = all(role not in author_roles for role in role_ids) log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the without_role check was {check}.") -- cgit v1.2.3 From ea2a30bc7b13b2d5da5a1a997d9279975e0e7e8f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 14 Sep 2019 21:53:57 +0200 Subject: Fixes a bug syncing roles for members who leave. The event that was supposed to handle this was called on_member_leave instead of on_member_remove, so the even was never called when it should have been. This commit renames the method. --- bot/cogs/sync/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 79177b69e..ec6c5f447 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -118,7 +118,7 @@ class Sync: # If we got `404`, the user is new. Create them. await self.bot.api_client.post('bot/users', json=packed) - async def on_member_leave(self, member: Member) -> None: + async def on_member_remove(self, member: Member) -> None: """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( f'bot/users/{member.id}', -- cgit v1.2.3 From e50e05398cbe9084a39fa9afc2e85ac544014913 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 14:16:49 -0700 Subject: Ignore errors from cogs with their own error handlers --- bot/cogs/error_handler.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index a57cabf1e..d65419ae8 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -42,8 +42,8 @@ class ErrorHandler: else: help_command = (self.bot.get_command("help"),) - if hasattr(command, "on_error"): - log.debug(f"Command {command} has a local error handler, ignoring.") + if hasattr(command, "on_error") or hasattr(command.instance, f"_{command.cog_name}__error"): + log.debug(f"Command {command} has a local error handler; ignoring.") return if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): @@ -64,11 +64,19 @@ class ErrorHandler: await ctx.send("Sorry, this command can't be used in a private message!") elif isinstance(e, BotMissingPermissions): await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") - log.warning(f"The bot is missing permissions to execute command {command}: {e.missing_perms}") + log.warning( + f"The bot is missing permissions to execute command {command}: {e.missing_perms}" + ) elif isinstance(e, MissingPermissions): - log.debug(f"{ctx.message.author} is missing permissions to invoke command {command}: {e.missing_perms}") + log.debug( + f"{ctx.message.author} is missing permissions to invoke command {command}: " + f"{e.missing_perms}" + ) elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): - log.debug(f"Command {command} invoked by {ctx.message.author} with error {e.__class__.__name__}: {e}") + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): if e.original.response.status == 404: -- cgit v1.2.3 From 6a6590124217cd51ff17c17d2cec0dca9bea1090 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 15:04:58 -0700 Subject: Display no-DM error message originating from security cog's global check The check will behave like Discord.py's guild_only check by raising the NoPrivateMessage exception. --- bot/cogs/security.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/security.py b/bot/cogs/security.py index f4a843fbf..9523766af 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,6 +1,6 @@ import logging -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Context, NoPrivateMessage log = logging.getLogger(__name__) @@ -19,7 +19,9 @@ class Security: return not ctx.author.bot def check_on_guild(self, ctx: Context): - return ctx.guild is not None + if ctx.guild is None: + raise NoPrivateMessage("This command cannot be used in private messages.") + return True def setup(bot): -- cgit v1.2.3 From 64e8e7423e0d1c765b30552d5d5a390df767c9c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 15:55:06 -0700 Subject: Improve logging of command errors --- bot/cogs/error_handler.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index d65419ae8..59b6c0573 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -35,6 +35,7 @@ class ErrorHandler: if command is not None: parent = command.parent + # Retrieve the help command for the invoked command. if parent and command: help_command = (self.bot.get_command("help"), parent.name, command.name) elif command: @@ -46,6 +47,7 @@ class ErrorHandler: log.debug(f"Command {command} has a local error handler; ignoring.") return + # Try to look for a tag with the command's name if the command isn't found. if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): if not ctx.channel.id == Channels.verification: tags_get_command = self.bot.get_command("tags get") @@ -60,6 +62,10 @@ class ErrorHandler: elif isinstance(e, UserInputError): await ctx.send("Something about your input seems off. Check the arguments:") await ctx.invoke(*help_command) + log.debug( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) elif isinstance(e, NoPrivateMessage): await ctx.send("Sorry, this command can't be used in a private message!") elif isinstance(e, BotMissingPermissions): @@ -79,26 +85,35 @@ class ErrorHandler: ) elif isinstance(e, CommandInvokeError): if isinstance(e.original, ResponseCodeError): - if e.original.response.status == 404: + status = e.original.response.status + + if status == 404: await ctx.send("There does not seem to be anything matching your query.") - elif e.original.response.status == 400: + elif status == 400: content = await e.original.response.json() - log.debug("API gave bad request on command. Response: %r.", content) + log.debug(f"API responded with 400 for command {command}: %r.", content) await ctx.send("According to the API, your request is malformed.") - elif 500 <= e.original.response.status < 600: + elif 500 <= status < 600: await ctx.send("Sorry, there seems to be an internal issue with the API.") + log.warning(f"API responded with {status} for command {command}") else: - await ctx.send( - "Got an unexpected status code from the " - f"API (`{e.original.response.code}`)." - ) + await ctx.send(f"Got an unexpected status code from the API (`{status}`).") + log.warning(f"Unexpected API response for command {command}: {status}") else: - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original + await self.handle_unexpected_error(ctx, e.original) else: - raise e + await self.handle_unexpected_error(ctx, e) + + @staticmethod + async def handle_unexpected_error(ctx: Context, e: CommandError): + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n" + f"```{e.__class__.__name__}: {e}```" + ) + log.error( + f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" + ) + raise e def setup(bot: Bot): -- cgit v1.2.3 From df3f385acfc48e7a97147e32bc03f6f5f69610e3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 16:23:56 -0700 Subject: Fix cog error handler check when command is None --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 59b6c0573..994950c84 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -43,7 +43,7 @@ class ErrorHandler: else: help_command = (self.bot.get_command("help"),) - if hasattr(command, "on_error") or hasattr(command.instance, f"_{command.cog_name}__error"): + if hasattr(command, "on_error") or hasattr(ctx.cog, f"_{command.cog_name}__error"): log.debug(f"Command {command} has a local error handler; ignoring.") return -- cgit v1.2.3 From 14a67c187a38b9748fee375e15cfc9f6aa10fc6f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 15 Sep 2019 01:23:49 +0200 Subject: Fix AntiSpam sending duplicate messages to API https://github.com/python-discord/bot/issues/412 https://github.com/python-discord/bot/issues/410 The AntiSpam cog had a bug that caused it to send the same messages in more than one request to the deleted messages API endpoint. Since the API rejects duplicate messages, all requests containing a duplicate message were rejected, even if the request contained new messages as well. This commit fixes that by gathering up all the messages of a single spam event into a single DeletionContext and sending that instead. This commit also prevents the bot fomr being bricked by a single misconfigured antispam configuration. Instead of raising a bare exception, it will now log the validation error and alert moderation on server that antispam has been disabled. closes #412, closes #410 --- bot/cogs/antispam.py | 192 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 143 insertions(+), 49 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 02d5d64ce..22f9794f3 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,6 +1,9 @@ +import asyncio import logging +from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import List +from operator import itemgetter +from typing import Dict, Iterable, List, Set from discord import Colour, Member, Message, Object, TextChannel from discord.ext.commands import Bot @@ -33,18 +36,102 @@ RULE_FUNCTION_MAPPING = { } +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + channel: TextChannel + members: Dict[int, Member] = field(default_factory=dict) + rules: Set[str] = field(default_factory=set) + messages: Dict[int, Message] = field(default_factory=dict) + + def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + """Adds new rule violation events to the deletion context.""" + self.rules.add(rule_name) + + for member in members: + if member.id not in self.members: + self.members[member.id] = member + + for message in messages: + if message.id not in self.messages: + self.messages[message.id] = message + + async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: + """Method that takes care of uploading the queue and posting modlog alert.""" + triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values()) + + mod_alert_message = ( + f"**Triggered by:** {triggered_by_users}\n" + f"**Channel:** {self.channel.mention}\n" + f"**Rules:** {', '.join(rule for rule in self.rules)}\n" + ) + + # For multiple messages or those with excessive newlines, use the logs API + if len(self.messages) > 1 or 'newlines' in self.rules: + url = await modlog.upload_log(self.messages.values(), actor_id) + mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" + else: + mod_alert_message += "Message:\n" + [message] = self.messages.values() + content = message[0].clean_content + remaining_chars = 2040 - len(mod_alert_message) + + if len(content) > remaining_chars: + content = content[:remaining_chars] + "..." + + mod_alert_message += f"{content}" + + *_, last_message = self.messages.values() + await modlog.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"Spam detected!", + text=mod_alert_message, + thumbnail=last_message.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=AntiSpamConfig.ping_everyone + ) + + class AntiSpam: - def __init__(self, bot: Bot): + """Cog that controls our anti-spam measures.""" + + def __init__(self, bot: Bot, validation_errors: bool) -> None: self.bot = bot + self.validation_errors = validation_errors role_id = AntiSpamConfig.punishment['role_id'] self.muted_role = Object(role_id) self.expiration_date_converter = ExpirationDate() + self.message_deletion_queue = dict() + self.queue_consumption_tasks = dict() + @property def mod_log(self) -> ModLog: + """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") - async def on_message(self, message: Message): + async def on_ready(self): + """Unloads the cog and alerts admins if configuration validation failed.""" + if self.validation_errors: + body = "**The following errors were encountered:**\n" + body += "\n".join(f"- {error}" for error in self.validation_errors.values()) + body += "\n\n**The cog has been unloaded.**" + + await self.mod_log.send_log_message( + title=f"Error: AntiSpam configuration validation failed!", + text=body, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Colour.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + async def on_message(self, message: Message) -> None: + """Applies the antispam rules to each received message.""" if ( not message.guild or message.guild.id != GuildConfig.id @@ -57,7 +144,7 @@ class AntiSpam: # Fetch the rule configuration with the highest rule interval. max_interval_config = max( AntiSpamConfig.rules.values(), - key=lambda config: config['interval'] + key=itemgetter('interval') ) max_interval = max_interval_config['interval'] @@ -65,6 +152,7 @@ class AntiSpam: earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) relevant_messages = [ msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False) + if not msg.author.bot ] for rule_name in AntiSpamConfig.rules: @@ -85,59 +173,48 @@ class AntiSpam: if result is not None: reason, members, relevant_messages = result full_reason = f"`{rule_name}` rule: {reason}" + + # If there's no spam event going on for this channel, start a new Message Deletion Context + if message.channel.id not in self.message_deletion_queue: + log.trace(f"Creating queue for channel `{message.channel.id}`") + self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel) + self.queue_consumption_tasks = self.bot.loop.create_task( + self._process_deletion_context(message.channel.id) + ) + + # Add the relevant of this trigger to the Deletion Context + self.message_deletion_queue[message.channel.id].add( + rule_name=rule_name, + members=members, + messages=relevant_messages + ) + for member in members: # Fire it off as a background task to ensure # that the sleep doesn't block further tasks self.bot.loop.create_task( - self.punish(message, member, full_reason, relevant_messages, rule_name) + self.punish(message, member, full_reason) ) await self.maybe_delete_messages(message.channel, relevant_messages) break - async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str): - # Sanity check to ensure we're not lagging behind - if self.muted_role not in member.roles: + async def punish(self, msg: Message, member: Member, reason: str) -> None: + """Punishes the given member for triggering an antispam rule.""" + if not any(role.id == self.muted_role.id for role in member.roles): remove_role_after = AntiSpamConfig.punishment['remove_after'] - mod_alert_message = ( - f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n" - f"**Channel:** {msg.channel.mention}\n" - f"**Reason:** {reason}\n" - ) - - # For multiple messages or those with excessive newlines, use the logs API - if len(messages) > 1 or rule_name == 'newlines': - url = await self.mod_log.upload_log(messages, msg.guild.me.id) - mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" - else: - mod_alert_message += "Message:\n" - content = messages[0].clean_content - remaining_chars = 2040 - len(mod_alert_message) - - if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." - - mod_alert_message += f"{content}" - - # Return the mod log message Context that we can use to post the infraction - mod_log_ctx = await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"Spam detected!", - text=mod_alert_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone - ) + # We need context, let's get it + context = await self.bot.get_context(msg) # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(mod_log_ctx, f"{remove_role_after}S") - await mod_log_ctx.invoke(Moderation.tempmute, member, dt_remove_role_after, reason=reason) + dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") + await context.invoke(Moderation.tempmute, member, dt_remove_role_after, reason=reason) + + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """Cleans the messages if cleaning is configured.""" - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): - # Is deletion of offending messages actually enabled? if AntiSpamConfig.clean_offending: # If we have more than one message, we can use bulk delete. @@ -152,24 +229,41 @@ class AntiSpam: self.mod_log.ignore(Event.message_delete, messages[0].id) await messages[0].delete() + async def _process_deletion_context(self, context_id: int) -> None: + """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(10) -def validate_config(): + if context_id not in self.message_deletion_queue: + log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") + return + + deletion_context = self.message_deletion_queue.pop(context_id) + await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + + +def validate_config() -> bool: + """Validates the antispam configs.""" + validation_errors = {} for name, config in AntiSpamConfig.rules.items(): if name not in RULE_FUNCTION_MAPPING: - raise ValueError( + log.error( f"Unrecognized antispam rule `{name}`. " f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" ) - + validation_errors[name] = f"`{name}` is not recognized as an antispam rule." for required_key in ('interval', 'max'): if required_key not in config: - raise ValueError( + log.error( f"`{required_key}` is required but was not " f"set in rule `{name}`'s configuration." ) + validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" + return validation_errors -def setup(bot: Bot): - validate_config() - bot.add_cog(AntiSpam(bot)) +def setup(bot: Bot) -> None: + """Setup for the cog.""" + validation_errors = validate_config() + bot.add_cog(AntiSpam(bot, validation_errors)) log.info("Cog loaded: AntiSpam") -- cgit v1.2.3 From 76c9515501c09a8adb19dcff8f1d61e2a6dd188f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 15 Sep 2019 02:00:04 +0200 Subject: Fix deleting already deleted message in antispam Since we're in an async context, it can happen that a message was already deleted before the antispam cog could get to it. To prevent processing from stopping dead because of a NotFound exception, I added a try-except and log message to handle that In addition, corrected a small mistake: trying to indice a single Message object. Corrected. --- bot/cogs/antispam.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 22f9794f3..69367b40b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from operator import itemgetter from typing import Dict, Iterable, List, Set -from discord import Colour, Member, Message, Object, TextChannel +from discord import Colour, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Bot from bot import rules @@ -74,7 +74,7 @@ class DeletionContext: else: mod_alert_message += "Message:\n" [message] = self.messages.values() - content = message[0].clean_content + content = message.clean_content remaining_chars = 2040 - len(mod_alert_message) if len(content) > remaining_chars: @@ -227,7 +227,10 @@ class AntiSpam: # Delete the message directly instead. else: self.mod_log.ignore(Event.message_delete, messages[0].id) - await messages[0].delete() + try: + await messages[0].delete() + except NotFound: + log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") async def _process_deletion_context(self, context_id: int) -> None: """Processes the Deletion Context queue.""" -- cgit v1.2.3 From d1626f6263d40acca8a80257ba085107c6c8633a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 17:00:28 -0700 Subject: Actually fix cog error handler check when command is None --- bot/cogs/error_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 994950c84..1f0700f28 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -43,7 +43,8 @@ class ErrorHandler: else: help_command = (self.bot.get_command("help"),) - if hasattr(command, "on_error") or hasattr(ctx.cog, f"_{command.cog_name}__error"): + cog_has_handler = command and hasattr(ctx.cog, f"_{command.cog_name}__error") + if hasattr(command, "on_error") or cog_has_handler: log.debug(f"Command {command} has a local error handler; ignoring.") return -- cgit v1.2.3 From 45a4fb55498230177ae8cadb3e308adcfa755443 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 17:41:11 -0700 Subject: Generate InChannelCheckFailure's message inside the exception The exception now expects channel IDs to be passed to it. --- bot/decorators.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 1ba2cd59e..923d21938 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,7 +18,11 @@ log = logging.getLogger(__name__) class InChannelCheckFailure(CheckFailure): - pass + def __init__(self, *channels: int): + self.channels = channels + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + + super().__init__(f"Sorry, but you may only use this command within {channels_str}.") def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): @@ -41,10 +45,7 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The in_channel check failed.") - channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) - raise InChannelCheckFailure( - f"Sorry, but you may only use this command within {channels_str}." - ) + raise InChannelCheckFailure(*channels) return commands.check(predicate) -- cgit v1.2.3 From 4a5ea8cd1fc13de8c419ef48b42dc11f5bba6705 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 18:20:22 -0700 Subject: Ignore handled errors by checking for a "handled" attribute --- bot/cogs/error_handler.py | 5 ++--- bot/cogs/moderation.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 1f0700f28..033a49d39 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -43,9 +43,8 @@ class ErrorHandler: else: help_command = (self.bot.get_command("help"),) - cog_has_handler = command and hasattr(ctx.cog, f"_{command.cog_name}__error") - if hasattr(command, "on_error") or cog_has_handler: - log.debug(f"Command {command} has a local error handler; ignoring.") + if hasattr(e, "handled"): + log.trace(f"Command {command} had its error already handled locally; ignoring.") return # Try to look for a tag with the command's name if the command isn't found. diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 63c0e4417..fb791c933 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1378,6 +1378,7 @@ class Moderation(Scheduler): if isinstance(error, BadUnionArgument): if User in error.converters: await ctx.send(str(error.errors[0])) + error.handled = True async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool: """ -- cgit v1.2.3 From c020004cb75414865583d1c200cc9a6df1a49cbb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Sep 2019 18:42:35 -0700 Subject: Remove most local error handlers & handle InChannelCheckFailure globally * Add error handler to ignore InChannelCheckFailure in the verification cog * Raise InChannelCheckFailure instead of MissingPermissions in !user * Send message instead of raising BadArgument in !user to prevent help message from being shown in such case * Clean up !user command --- bot/cogs/error_handler.py | 3 +++ bot/cogs/information.py | 57 +++++++++++++---------------------------------- bot/cogs/snekbox.py | 31 +++----------------------- bot/cogs/utils.py | 12 ++-------- bot/cogs/verification.py | 8 ++++++- 5 files changed, 30 insertions(+), 81 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 033a49d39..cfcba6f26 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -18,6 +18,7 @@ from discord.ext.commands import Bot, Context from bot.api import ResponseCodeError from bot.constants import Channels +from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) @@ -78,6 +79,8 @@ class ErrorHandler: f"{ctx.message.author} is missing permissions to invoke command {command}: " f"{e.missing_perms}" ) + elif isinstance(e, InChannelCheckFailure): + await ctx.send(e) elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): log.debug( f"Command {command} invoked by {ctx.message.author} with error " diff --git a/bot/cogs/information.py b/bot/cogs/information.py index a2585f395..320750a24 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,16 +1,13 @@ import logging -import random import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import ( - BadArgument, Bot, CommandError, Context, MissingPermissions, command -) +from discord.ext.commands import Bot, Context, command from bot.constants import ( - Channels, Emojis, Keys, MODERATION_ROLES, NEGATIVE_REPLIES, STAFF_ROLES + Channels, Emojis, Keys, MODERATION_ROLES, STAFF_ROLES ) -from bot.decorators import with_role +from bot.decorators import InChannelCheckFailure, with_role from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -131,30 +128,25 @@ class Information: Returns info about a user. """ - # Do a role check if this is being executed on - # someone other than the caller - if user and user != ctx.author: - if not with_role_check(ctx, *MODERATION_ROLES): - raise BadArgument("You do not have permission to use this command on users other than yourself.") + if user is None: + user = ctx.author + + # Do a role check if this is being executed on someone other than the caller + if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): + await ctx.send("You may not use this command on users other than yourself.") + return - # Non-moderators may only do this in #bot-commands and can't see - # hidden infractions. + # Non-moderators may only do this in #bot-commands and can't see hidden infractions. if not with_role_check(ctx, *STAFF_ROLES): if not ctx.channel.id == Channels.bot: - raise MissingPermissions("You can't do that here!") + raise InChannelCheckFailure(Channels.bot) # Hide hidden infractions for users without a moderation role hidden = False - # Validates hidden input - hidden = str(hidden) - - if user is None: - user = ctx.author - # User information created = time_since(user.created_at, max_units=3) - name = f"{user.name}#{user.discriminator}" + name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -162,15 +154,13 @@ class Information: joined = time_since(user.joined_at, precision="days") # You're welcome, Volcyyyyyyyyyyyyyyyy - roles = ", ".join( - role.mention for role in user.roles if role.name != "@everyone" - ) + roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") # Infractions infractions = await self.bot.api_client.get( 'bot/infractions', params={ - 'hidden': hidden, + 'hidden': str(hidden), 'user__id': str(user.id) } ) @@ -209,23 +199,6 @@ class Information: await ctx.send(embed=embed) - @user_info.error - async def user_info_command_error(self, ctx: Context, error: CommandError): - embed = Embed(colour=Colour.red()) - - if isinstance(error, BadArgument): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = str(error) - await ctx.send(embed=embed) - - elif isinstance(error, MissingPermissions): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>." - await ctx.send(embed=embed) - - else: - log.exception(f"Unhandled error: {error}") - def setup(bot): bot.add_cog(Information(bot)) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 05834e421..c8705ac6f 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,18 +1,14 @@ import datetime import logging -import random import re import textwrap from signal import Signals from typing import Optional, Tuple -from discord import Colour, Embed -from discord.ext.commands import ( - Bot, CommandError, Context, NoPrivateMessage, command, guild_only -) +from discord.ext.commands import Bot, Context, command, guild_only -from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs -from bot.decorators import InChannelCheckFailure, in_channel +from bot.constants import Channels, STAFF_ROLES, URLs +from bot.decorators import in_channel from bot.utils.messages import wait_for_deletion @@ -224,27 +220,6 @@ class Snekbox: finally: del self.jobs[ctx.author.id] - @eval_command.error - async def eval_command_error(self, ctx: Context, error: CommandError): - embed = Embed(colour=Colour.red()) - - if isinstance(error, NoPrivateMessage): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = "You're not allowed to use this command in private messages." - await ctx.send(embed=embed) - - elif isinstance(error, InChannelCheckFailure): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = str(error) - await ctx.send(embed=embed) - - else: - original_error = getattr(error, 'original', "no original error") - log.error(f"Unhandled error in snekbox eval: {error} ({original_error})") - embed.title = random.choice(ERROR_REPLIES) - embed.description = "Some unhandled error occurred. Sorry for that!" - await ctx.send(embed=embed) - def setup(bot): bot.add_cog(Snekbox(bot)) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 0c6d9d2ba..98208723a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,5 +1,4 @@ import logging -import random import re import unicodedata from email.parser import HeaderParser @@ -8,8 +7,8 @@ from io import StringIO from discord import Colour, Embed from discord.ext.commands import AutoShardedBot, Context, command -from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, in_channel +from bot.constants import Channels, STAFF_ROLES +from bot.decorators import in_channel log = logging.getLogger(__name__) @@ -133,13 +132,6 @@ class Utils: await ctx.send(embed=embed) - async def __error(self, ctx, error): - embed = Embed(colour=Colour.red()) - if isinstance(error, InChannelCheckFailure): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = str(error) - await ctx.send(embed=embed) - def setup(bot): bot.add_cog(Utils(bot)) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 56fcd63eb..6b42c9213 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -5,7 +5,7 @@ from discord.ext.commands import Bot, Context, command from bot.cogs.modlog import ModLog from bot.constants import Channels, Event, Roles -from bot.decorators import in_channel, without_role +from bot.decorators import InChannelCheckFailure, in_channel, without_role log = logging.getLogger(__name__) @@ -151,6 +151,12 @@ class Verification: f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications." ) + @staticmethod + async def __error(ctx: Context, error): + if isinstance(error, InChannelCheckFailure): + # Do nothing; just ignore this error + error.handled = True + @staticmethod def __global_check(ctx: Context): """ -- cgit v1.2.3 From 33f04eb28b720e69ad9def01961ffca8e55393fb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 11:26:18 +0200 Subject: Tag Django images as `latest`. --- scripts/deploy-azure.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index af69ab46b..9ffe01ab8 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -5,8 +5,8 @@ cd .. # Build and deploy on django branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then echo "Building image" - docker build -t pythondiscord/bot:django . + docker build -t pythondiscord/bot:latest . echo "Pushing image" - docker push pythondiscord/bot:django + docker push pythondiscord/bot:latest fi -- cgit v1.2.3 From d8f3d10a5298095d5b9dffe1f063ad69c8498883 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 12:07:07 +0200 Subject: Validate bot.cogs.antispam configuration on CI. --- bot/cogs/antispam.py | 6 ++++-- tests/cogs/test_antispam.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/cogs/test_antispam.py diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 69367b40b..482965b9b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from operator import itemgetter @@ -245,16 +246,17 @@ class AntiSpam: await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config() -> bool: +def validate_config(rules: Mapping = AntiSpamConfig.rules) -> dict: """Validates the antispam configs.""" validation_errors = {} - for name, config in AntiSpamConfig.rules.items(): + for name, config in rules.items(): if name not in RULE_FUNCTION_MAPPING: log.error( f"Unrecognized antispam rule `{name}`. " f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" ) validation_errors[name] = f"`{name}` is not recognized as an antispam rule." + continue for required_key in ('interval', 'max'): if required_key not in config: log.error( diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..67900b275 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,30 @@ +import pytest + +from bot.cogs import antispam + + +def test_default_antispam_config_is_valid(): + validation_errors = antispam.validate_config() + assert not validation_errors + + +@pytest.mark.parametrize( + ('config', 'expected'), + ( + ( + {'invalid-rule': {}}, + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ), + ( + {'burst': {'interval': 10}}, + {'burst': "Key `max` is required but not set for rule `burst`"} + ), + ( + {'burst': {'max': 10}}, + {'burst': "Key `interval` is required but not set for rule `burst`"} + ) + ) +) +def test_invalid_antispam_config_returns_validation_errors(config, expected): + validation_errors = antispam.validate_config(config) + assert validation_errors == expected -- cgit v1.2.3 From b3279e4251d9e7caa25e32db2bca170275c130ef Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 12:30:58 +0200 Subject: The DockerHub deployment should now run on 'master' --- scripts/deploy-azure.sh | 4 ++-- scripts/deploy.sh | 32 -------------------------------- 2 files changed, 2 insertions(+), 34 deletions(-) delete mode 100644 scripts/deploy.sh diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 9ffe01ab8..ed4b719e2 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -2,8 +2,8 @@ cd .. -# Build and deploy on django branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then +# Build and deploy on master branch, only if not a pull request +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then echo "Building image" docker build -t pythondiscord/bot:latest . diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index 070d0ec26..000000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# Build and deploy on master branch -if [[ $CI_COMMIT_REF_SLUG == 'master' ]]; then - echo "Connecting to docker hub" - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin - - changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) - - if [ $changed_lines != '0' ]; then - echo "base.Dockerfile was changed" - - echo "Building bot base" - docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . - - echo "Pushing image to Docker Hub" - docker push pythondiscord/bot-base:latest - else - echo "base.Dockerfile was not changed, not building" - fi - - echo "Building image" - docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . - - echo "Pushing image" - docker push pythondiscord/bot:latest - - echo "Deploying container" - curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK -else - echo "Skipping deploy" -fi -- cgit v1.2.3 From 82e8ca20bb1f61162d1b55c6e354c68ea4cdfcf1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 12:57:54 +0200 Subject: Add tests for `bot.utils.checks`. --- tests/utils/__init__.py | 0 tests/utils/test_checks.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_checks.py diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py new file mode 100644 index 000000000..915d074b3 --- /dev/null +++ b/tests/utils/test_checks.py @@ -0,0 +1,67 @@ +from unittest.mock import MagicMock + +from bot.utils import checks + + +def test_with_role_check_without_guild(): + context = MagicMock() + context.guild = None + + assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_without_required_role(): + context = MagicMock() + context.guild = True + context.author.roles = [] + + assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_with_required_role(): + context = MagicMock() + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert checks.with_role_check(context, role.id) + + +def test_without_role_check_without_guild(): + context = MagicMock() + context.guild = None + + assert not checks.without_role_check(context) + + +def test_without_role_check_with_unwanted_role(): + context = MagicMock() + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert not checks.without_role_check(context, role.id) + + +def test_without_role_check_without_unwanted_role(): + context = MagicMock() + context.guild = True + role = MagicMock() + role.id = 42 + context.author.roles = (role,) + + assert checks.without_role_check(context, role.id + 10) + + +def test_in_channel_check_for_correct_channel(): + context = MagicMock() + context.channel.id = 42 + assert checks.in_channel_check(context, context.channel.id) + + +def test_in_channel_check_for_incorrect_channel(): + context = MagicMock() + context.channel.id = 42 + assert not checks.in_channel_check(context, context.channel.id + 10) -- cgit v1.2.3 From 3385689dee24c08bb66b8bedba57caa617023f6b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 14:11:56 +0200 Subject: Resolves a breaking bug in the Dockerfile. We were using a pipenv run script to launch the bot, but pipenv run scripts assume that the run command will be run inside of a virtual environment. So, the default behaviour when we try to use a run command and no venv exists is to create a venv. Because we were installing all the packages to the local environment by passing the '--system' flag to our install, this would make the bot fail with ImportErrors. This commit fixes it so that the Dockerfile will run the bot using the system Python instead of the pipenv run script. --- Dockerfile | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 864b4e557..aa6333380 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,15 +14,7 @@ RUN apk add --no-cache \ zlib-dev ENV \ - LIBRARY_PATH=/lib:/usr/lib \ - PIPENV_HIDE_EMOJIS=1 \ - PIPENV_HIDE_EMOJIS=1 \ - PIPENV_IGNORE_VIRTUALENVS=1 \ - PIPENV_IGNORE_VIRTUALENVS=1 \ - PIPENV_NOSPIN=1 \ - PIPENV_NOSPIN=1 \ - PIPENV_VENV_IN_PROJECT=1 \ - PIPENV_VENV_IN_PROJECT=1 + LIBRARY_PATH=/lib:/usr/lib RUN pip install -U pipenv @@ -32,4 +24,4 @@ COPY . . RUN pipenv install --deploy --system ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pipenv", "run", "start"] +CMD ["python3", "-m", "bot"] -- cgit v1.2.3 From 3da45c2a8ac967c9c0ed1525e04686914eb50e7d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 12:43:11 +0200 Subject: Add tests for `bot.converters`. --- tests/test_converters.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_converters.py diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 000000000..3cf774c80 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,93 @@ +import asyncio +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import BadArgument + +from bot.converters import ( + ExpirationDate, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + +@pytest.mark.parametrize( + ('value', 'expected'), + ( + # sorry aliens + ('2199-01-01T00:00:00', datetime(2199, 1, 1)), + ) +) +def test_expiration_date_converter_for_valid(value: str, expected: datetime): + converter = ExpirationDate() + assert asyncio.run(converter.convert(None, value)) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + ( + ('hello', 'hello'), + (' h ello ', 'h ello') + ) +) +def test_tag_content_converter_for_valid(value: str, expected: str): + assert asyncio.run(TagContentConverter.convert(None, value)) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + ( + ('', "Tag contents should not be empty, or filled with whitespace."), + (' ', "Tag contents should not be empty, or filled with whitespace.") + ) +) +def test_tag_content_converter_for_invalid(value: str, expected: str): + context = MagicMock() + context.author = 'bob' + + with pytest.raises(BadArgument, match=expected): + asyncio.run(TagContentConverter.convert(context, value)) + + +@pytest.mark.parametrize( + ('value', 'expected'), + ( + ('tracebacks', 'tracebacks'), + ('Tracebacks', 'tracebacks'), + (' Tracebacks ', 'tracebacks'), + ) +) +def test_tag_name_converter_for_valid(value: str, expected: str): + assert asyncio.run(TagNameConverter.convert(None, value)) == expected + + +@pytest.mark.parametrize( + ('value', 'expected'), + ( + ('👋', "Don't be ridiculous, you can't use that character!"), + ('', "Tag names should not be empty, or filled with whitespace."), + (' ', "Tag names should not be empty, or filled with whitespace."), + ('42', "Tag names can't be numbers."), + # Escape question mark as this is evaluated as regular expression. + ('x' * 128, r"Are you insane\? That's way too long!"), + ) +) +def test_tag_name_converter_for_invalid(value: str, expected: str): + context = MagicMock() + context.author = 'bob' + + with pytest.raises(BadArgument, match=expected): + asyncio.run(TagNameConverter.convert(context, value)) + + +@pytest.mark.parametrize('value', ('foo', 'lemon')) +def test_valid_python_identifier_for_valid(value: str): + assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value + + +@pytest.mark.parametrize('value', ('nested.stuff', '#####')) +def test_valid_python_identifier_for_invalid(value: str): + with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): + asyncio.run(ValidPythonIdentifier.convert(None, value)) -- cgit v1.2.3 From bf9a6c250eb28dd64e8b449a9641f09c9b5bd2d7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 14:29:08 +0200 Subject: Typehint the result of `validate_config`. Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/antispam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 482965b9b..e980de364 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -246,7 +246,7 @@ class AntiSpam: await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config(rules: Mapping = AntiSpamConfig.rules) -> dict: +def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} for name, config in rules.items(): -- cgit v1.2.3 From aaa24e14e27389deaa33f2776766805b195079cc Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 14:39:43 +0200 Subject: Temporarily pointing config at django.pydis.com --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index b31f79272..599fc5093 100644 --- a/config-default.yml +++ b/config-default.yml @@ -234,8 +234,8 @@ keys: urls: # PyDis site vars - site: &DOMAIN "pythondiscord.com" - site_api: &API !JOIN ["api.", *DOMAIN] + site: &DOMAIN "django.pythondiscord.com" + site_api: &API !JOIN ["api", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" -- cgit v1.2.3 From ca1f7dca0413a7b425654d32bdee1ddef6222d3d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 15:03:17 +0200 Subject: Adding the snekbox URL to the default config, fixing typo. --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 599fc5093..bae4b16c0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -235,7 +235,7 @@ keys: urls: # PyDis site vars site: &DOMAIN "django.pythondiscord.com" - site_api: &API !JOIN ["api", *DOMAIN] + site_api: &API !JOIN ["api.", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" @@ -261,7 +261,7 @@ urls: paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox - snekbox_eval_api: "http://localhost:8060/eval" + snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" # Env vars deploy: !ENV "DEPLOY_URL" -- cgit v1.2.3 From ab21ed98d7373b80c52f007c8becb93a5edcd03a Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 15:10:34 +0200 Subject: Use `@pytest.fixture` for creating contexts. --- tests/utils/test_checks.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py index 915d074b3..7121acebd 100644 --- a/tests/utils/test_checks.py +++ b/tests/utils/test_checks.py @@ -1,25 +1,29 @@ from unittest.mock import MagicMock +import pytest + from bot.utils import checks -def test_with_role_check_without_guild(): - context = MagicMock() +@pytest.fixture() +def context(): + return MagicMock() + + +def test_with_role_check_without_guild(context): context.guild = None assert not checks.with_role_check(context) -def test_with_role_check_with_guild_without_required_role(): - context = MagicMock() +def test_with_role_check_with_guild_without_required_role(context): context.guild = True context.author.roles = [] assert not checks.with_role_check(context) -def test_with_role_check_with_guild_with_required_role(): - context = MagicMock() +def test_with_role_check_with_guild_with_required_role(context): context.guild = True role = MagicMock() role.id = 42 @@ -28,15 +32,13 @@ def test_with_role_check_with_guild_with_required_role(): assert checks.with_role_check(context, role.id) -def test_without_role_check_without_guild(): - context = MagicMock() +def test_without_role_check_without_guild(context): context.guild = None assert not checks.without_role_check(context) -def test_without_role_check_with_unwanted_role(): - context = MagicMock() +def test_without_role_check_with_unwanted_role(context): context.guild = True role = MagicMock() role.id = 42 @@ -45,8 +47,7 @@ def test_without_role_check_with_unwanted_role(): assert not checks.without_role_check(context, role.id) -def test_without_role_check_without_unwanted_role(): - context = MagicMock() +def test_without_role_check_without_unwanted_role(context): context.guild = True role = MagicMock() role.id = 42 @@ -55,13 +56,11 @@ def test_without_role_check_without_unwanted_role(): assert checks.without_role_check(context, role.id + 10) -def test_in_channel_check_for_correct_channel(): - context = MagicMock() +def test_in_channel_check_for_correct_channel(context): context.channel.id = 42 assert checks.in_channel_check(context, context.channel.id) -def test_in_channel_check_for_incorrect_channel(): - context = MagicMock() +def test_in_channel_check_for_incorrect_channel(context): context.channel.id = 42 assert not checks.in_channel_check(context, context.channel.id + 10) -- cgit v1.2.3 From 1c8b07bc2262f08af26aec00633de73dac5a4ddb Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 15:19:52 +0200 Subject: Add basic tests for `bot.pagination`. --- tests/test_pagination.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_pagination.py diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..11d6541ae --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,29 @@ +from unittest import TestCase + +import pytest + +from bot import pagination + + +class LinePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with pytest.raises(RuntimeError, match=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] -- cgit v1.2.3 From e7342735d00205ca09771a5c434dba5e1d185ba7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 15:34:22 +0200 Subject: Bot Test Server default config, for testing. --- config-default.yml | 127 ++++++++++--------- config-prod.yml | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 64 deletions(-) create mode 100644 config-prod.yml diff --git a/config-default.yml b/config-default.yml index bae4b16c0..eace9caff 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,5 +1,5 @@ bot: - prefix: "!" + prefix: "." token: !ENV "BOT_TOKEN" cooldowns: @@ -10,7 +10,6 @@ bot: # Maximum number of messages to traverse for clean commands message_limit: 10000 - style: colours: soft_red: 0xcd6d6d @@ -19,8 +18,8 @@ style: emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" - defcon_enabled: "<:defconenabled:470326274213150730>" - defcon_updated: "<:defconsettingsupdated:470326274082996224>" + defcon_enabled: "<:defconenabled:470326274213150730>" + defcon_updated: "<:defconsettingsupdated:470326274082996224>" green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" @@ -42,7 +41,7 @@ style: crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" - defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" + defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" @@ -68,8 +67,8 @@ style: user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" - user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" - user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" @@ -83,74 +82,74 @@ style: questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" guild: - id: 267624335836053506 + id: 476190141161930753 categories: - python_help: 356013061213126657 + python_help: 476196174789869577 channels: - admins: &ADMINS 365960823622991872 - announcements: 354619224620138496 - big_brother_logs: &BBLOGS 468507907357409333 - bot: 267659945086812160 - checkpoint_test: 422077681434099723 - defcon: 464469101889454091 - devlog: &DEVLOG 409308876241108992 - devtest: &DEVTEST 414574275865870337 - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - help_6: 587375753306570782 - help_7: 587375768556797982 - helpers: 385474242440986624 - message_log: &MESSAGE_LOG 467752170159079424 - mod_alerts: 473092532147060736 - modlog: &MODLOG 282638479504965634 - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 - python: 267624335836053506 - reddit: 458224812528238616 - staff_lounge: &STAFF_LOUNGE 464905259261755392 - talent_pool: &TALENT_POOL 534321732593647616 - userlog: 528976905546760203 - user_event_a: &USER_EVENT_A 592000283102674944 - verification: 352442727016693763 + admins: &ADMINS 476196003733569547 + announcements: 476196024512413698 + big_brother_logs: &BBLOGS 476196047631417345 + bot: 476196062214750219 + checkpoint_test: 476196079562653698 + defcon: 476196101284954122 + devlog: &DEVLOG 476196115432210443 + devtest: &DEVTEST 476196128933543937 + help_0: 476196221845897270 + help_1: 476196242926469121 + help_2: 476196266594926593 + help_3: 476196281421660160 + help_4: 476196292398153738 + help_5: 476196300933824532 + help_6: 621711690140221440 + help_7: 621711714811117568 + helpers: 476196888295505940 + message_log: &MESSAGE_LOG 476197264667181057 + mod_alerts: 476197283256336385 + modlog: &MODLOG 476197299169525780 + off_topic_0: 476196547324018688 + off_topic_1: 476196563216105472 + off_topic_2: 476196574343593985 + python: 476190141161930755 + reddit: 476197119762366464 + staff_lounge: &STAFF_LOUNGE 476197226348019712 + talent_pool: &TALENT_POOL 609530835476938757 + userlog: 609531966387388446 + user_event_a: &USER_EVENT_A 609531030164078613 + verification: 476197158928777237 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] roles: - admin: &ADMIN_ROLE 267628507062992896 - announcements: 463658397560995840 - champion: 430492892331769857 - contributor: 295488872404484098 - core_developer: 587606783669829632 - jammer: 423054537079783434 - moderator: &MOD_ROLE 267629731250176001 - muted: &MUTED_ROLE 277914926603829249 - owner: &OWNER_ROLE 267627879762755584 - verified: 352427296948486144 - helpers: 267630620367257601 - rockstars: &ROCKSTARS_ROLE 458226413825294336 - team_leader: 501324292341104650 + admin: &ADMIN_ROLE 476190234653229056 + announcements: 476190253548306433 + champion: 476190284447875086 + contributor: 476190302659543061 + core_developer: 622459804367061015 + jammer: 476190341566038027 + moderator: &MOD_ROLE 476190357927886848 + muted: &MUTED_ROLE 476190376949186560 + owner: &OWNER_ROLE 476190391595433985 + verified: 476190408871772171 + helpers: 476190429960732672 + rockstars: &ROCKSTARS_ROLE 503859559815708682 + team_leader: 609532800139264018 webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 + talent_pool: 609534369178189844 + big_brother: 609535034474496063 filter: # What do we filter? - filter_zalgo: false - filter_invites: true - filter_domains: true + filter_zalgo: true + filter_invites: true + filter_domains: true watch_rich_embeds: true - watch_words: true - watch_tokens: true + watch_words: true + watch_tokens: true # Notify user on filter? # Notifications are not expected for "watchlist" type filters @@ -159,7 +158,7 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? + ping_everyone: false # Ping @everyone when we send a mod-alert? guild_invite_whitelist: - 280033776820813825 # Functional Programming @@ -236,9 +235,9 @@ urls: # PyDis site vars site: &DOMAIN "django.pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] - site_paste: &PASTE !JOIN ["paste.", *DOMAIN] - site_staff: &STAFF !JOIN ["staff.", *DOMAIN] + site_paste: &PASTE "https://paste.pythondiscord.com" site_schema: &SCHEMA "https://" + site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] @@ -258,7 +257,7 @@ urls: site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] - paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] + paste_service: !JOIN [*PASTE, "/{key}"] # Snekbox snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" @@ -278,7 +277,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_everyone: true + ping_everyone: false punishment: role_id: *MUTED_ROLE diff --git a/config-prod.yml b/config-prod.yml new file mode 100644 index 000000000..c9fc3b954 --- /dev/null +++ b/config-prod.yml @@ -0,0 +1,360 @@ +bot: + prefix: "!" + token: !ENV "BOT_TOKEN" + + cooldowns: + # Per channel, per tag. + tags: 60 + + clean: + # Maximum number of messages to traverse for clean commands + message_limit: 10000 + + +style: + colours: + soft_red: 0xcd6d6d + soft_green: 0x68c290 + soft_orange: 0xf9cb54 + + emojis: + defcon_disabled: "<:defcondisabled:470326273952972810>" + defcon_enabled: "<:defconenabled:470326274213150730>" + defcon_updated: "<:defconsettingsupdated:470326274082996224>" + + green_chevron: "<:greenchevron:418104310329769993>" + red_chevron: "<:redchevron:418112778184818698>" + white_chevron: "<:whitechevron:418110396973711363>" + bb_message: "<:bbmessage:476273120999636992>" + + status_online: "<:status_online:470326272351010816>" + status_idle: "<:status_idle:470326266625785866>" + status_dnd: "<:status_dnd:470326272082313216>" + status_offline: "<:status_offline:470326266537705472>" + + bullet: "\u2022" + pencil: "\u270F" + new: "\U0001F195" + cross_mark: "\u274C" + + icons: + crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" + crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" + crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" + + defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" + defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" + defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" + defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" + + filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" + + guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" + + hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" + hash_green: "https://cdn.discordapp.com/emojis/469950144918585344.png" + hash_red: "https://cdn.discordapp.com/emojis/469950145413251072.png" + + message_bulk_delete: "https://cdn.discordapp.com/emojis/469952898994929668.png" + message_delete: "https://cdn.discordapp.com/emojis/472472641320648704.png" + message_edit: "https://cdn.discordapp.com/emojis/472472638976163870.png" + + sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png" + sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png" + + token_removed: "https://cdn.discordapp.com/emojis/470326273298792469.png" + + user_ban: "https://cdn.discordapp.com/emojis/469952898026045441.png" + user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" + user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" + + user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" + + user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" + + pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" + + remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" + remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" + remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" + + questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" + +guild: + id: 267624335836053506 + + categories: + python_help: 356013061213126657 + + channels: + admins: &ADMINS 365960823622991872 + announcements: 354619224620138496 + big_brother_logs: &BBLOGS 468507907357409333 + bot: 267659945086812160 + checkpoint_test: 422077681434099723 + defcon: 464469101889454091 + devlog: &DEVLOG 409308876241108992 + devtest: &DEVTEST 414574275865870337 + help_0: 303906576991780866 + help_1: 303906556754395136 + help_2: 303906514266226689 + help_3: 439702951246692352 + help_4: 451312046647148554 + help_5: 454941769734422538 + help_6: 587375753306570782 + help_7: 587375768556797982 + helpers: 385474242440986624 + message_log: &MESSAGE_LOG 467752170159079424 + mod_alerts: 473092532147060736 + modlog: &MODLOG 282638479504965634 + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 + python: 267624335836053506 + reddit: 458224812528238616 + staff_lounge: &STAFF_LOUNGE 464905259261755392 + talent_pool: &TALENT_POOL 534321732593647616 + userlog: 528976905546760203 + user_event_a: &USER_EVENT_A 592000283102674944 + verification: 352442727016693763 + + ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] + + roles: + admin: &ADMIN_ROLE 267628507062992896 + announcements: 463658397560995840 + champion: 430492892331769857 + contributor: 295488872404484098 + core_developer: 587606783669829632 + jammer: 423054537079783434 + moderator: &MOD_ROLE 267629731250176001 + muted: &MUTED_ROLE 277914926603829249 + owner: &OWNER_ROLE 267627879762755584 + verified: 352427296948486144 + helpers: 267630620367257601 + rockstars: &ROCKSTARS_ROLE 458226413825294336 + team_leader: 501324292341104650 + + webhooks: + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + + +filter: + + # What do we filter? + filter_zalgo: false + filter_invites: true + filter_domains: true + watch_rich_embeds: true + watch_words: true + watch_tokens: true + + # Notify user on filter? + # Notifications are not expected for "watchlist" type filters + notify_user_zalgo: false + notify_user_invites: true + notify_user_domains: false + + # Filter configuration + ping_everyone: true # Ping @everyone when we send a mod-alert? + + guild_invite_whitelist: + - 280033776820813825 # Functional Programming + - 267624335836053506 # Python Discord + - 440186186024222721 # Python Discord: ModLog Emojis + - 273944235143593984 # STEM + - 348658686962696195 # RLBot + - 531221516914917387 # Pallets + - 249111029668249601 # Gentoo + - 327254708534116352 # Adafruit + - 544525886180032552 # kennethreitz.org + - 590806733924859943 # Discord Hack Week + - 423249981340778496 # Kivy + + domain_blacklist: + - pornhub.com + - liveleak.com + + word_watchlist: + - goo+ks* + - ky+s+ + - ki+ke+s* + - beaner+s? + - coo+ns* + - nig+lets* + - slant-eyes* + - towe?l-?head+s* + - chi*n+k+s* + - spick*s* + - kill* +(?:yo)?urself+ + - jew+s* + - suicide + - rape + - (re+)tar+(d+|t+)(ed)? + - ta+r+d+ + - cunts* + - trann*y + - shemale + + token_watchlist: + - fa+g+s* + - 卐 + - 卍 + - cuck(?!oo+) + - nigg+(?:e*r+|a+h*?|u+h+)s? + - fag+o+t+s* + + # Censor doesn't apply to these + channel_whitelist: + - *ADMINS + - *MODLOG + - *MESSAGE_LOG + - *DEVLOG + - *BBLOGS + - *STAFF_LOUNGE + - *DEVTEST + - *TALENT_POOL + - *USER_EVENT_A + + role_whitelist: + - *ADMIN_ROLE + - *MOD_ROLE + - *OWNER_ROLE + - *ROCKSTARS_ROLE + + +keys: + deploy_bot: !ENV "DEPLOY_BOT_KEY" + deploy_site: !ENV "DEPLOY_SITE" + site_api: !ENV "BOT_API_KEY" + + +urls: + # PyDis site vars + site: &DOMAIN "pythondiscord.com" + site_api: &API !JOIN ["api.", *DOMAIN] + site_paste: &PASTE !JOIN ["paste.", *DOMAIN] + site_staff: &STAFF !JOIN ["staff.", *DOMAIN] + site_schema: &SCHEMA "https://" + + site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] + site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] + site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] + site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] + site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] + site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] + site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] + site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] + site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] + site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] + site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] + site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] + site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] + site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] + site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] + site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] + site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] + site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] + paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] + + # Snekbox + snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" + + # Env vars + deploy: !ENV "DEPLOY_URL" + status: !ENV "STATUS_URL" + + # Discord API URLs + discord_api: &DISCORD_API "https://discordapp.com/api/v7/" + discord_invite_api: !JOIN [*DISCORD_API, "invites"] + + # Misc URLs + bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" + gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" + +anti_spam: + # Clean messages that violate a rule. + clean_offending: true + ping_everyone: true + + punishment: + role_id: *MUTED_ROLE + remove_after: 600 + + rules: + attachments: + interval: 10 + max: 3 + + burst: + interval: 10 + max: 7 + + burst_shared: + interval: 10 + max: 20 + + chars: + interval: 5 + max: 3_000 + + duplicates: + interval: 10 + max: 3 + + discord_emojis: + interval: 10 + max: 20 + + links: + interval: 10 + max: 10 + + mentions: + interval: 10 + max: 5 + + newlines: + interval: 10 + max: 100 + max_consecutive: 10 + + role_mentions: + interval: 10 + max: 3 + + +reddit: + request_delay: 60 + subreddits: + - 'r/Python' + + +wolfram: + # Max requests per day. + user_limit_day: 10 + guild_limit_day: 67 + key: !ENV "WOLFRAM_API_KEY" + + +big_brother: + log_delay: 15 + header_message_limit: 15 + + +free: + # Seconds to elapse for a channel + # to be considered inactive. + activity_timeout: 600 + cooldown_rate: 1 + cooldown_per: 60.0 + +redirect_output: + delete_invocation: true + delete_delay: 15 + +config: + required_keys: ['bot.token'] -- cgit v1.2.3 From 1e57d2a9c7d8d8bdf2beeac4dc062c9e7ffe547b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 15:48:56 +0200 Subject: Ship `DEBUG` log messages to the site. --- bot/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/api.py b/bot/api.py index 9a0ebaa26..3acde242e 100644 --- a/bot/api.py +++ b/bot/api.py @@ -124,7 +124,7 @@ class APILoggingHandler(logging.StreamHandler): # 1. Do not log anything below `DEBUG`. This is only applicable # for the monkeypatched `TRACE` logging level, which has a # lower numeric value than `DEBUG`. - record.levelno > logging.DEBUG + record.levelno >= logging.DEBUG # 2. Ignore logging messages which are sent by this logging # handler itself. This is required because if we were to # not ignore messages emitted by this handler, we would -- cgit v1.2.3 From dc13880991442ef5614ef960ea8a90b1386ed955 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 15:53:33 +0200 Subject: Setting the config to work with the pydis server. --- config-default.yml | 125 ++++++++++--------- config-prod.yml | 360 ----------------------------------------------------- 2 files changed, 63 insertions(+), 422 deletions(-) delete mode 100644 config-prod.yml diff --git a/config-default.yml b/config-default.yml index eace9caff..01bdcd1e7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -10,6 +10,7 @@ bot: # Maximum number of messages to traverse for clean commands message_limit: 10000 + style: colours: soft_red: 0xcd6d6d @@ -18,8 +19,8 @@ style: emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" - defcon_enabled: "<:defconenabled:470326274213150730>" - defcon_updated: "<:defconsettingsupdated:470326274082996224>" + defcon_enabled: "<:defconenabled:470326274213150730>" + defcon_updated: "<:defconsettingsupdated:470326274082996224>" green_chevron: "<:greenchevron:418104310329769993>" red_chevron: "<:redchevron:418112778184818698>" @@ -41,7 +42,7 @@ style: crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" - defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" + defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" @@ -67,8 +68,8 @@ style: user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" - user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" - user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" @@ -82,74 +83,74 @@ style: questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" guild: - id: 476190141161930753 + id: 267624335836053506 categories: - python_help: 476196174789869577 + python_help: 356013061213126657 channels: - admins: &ADMINS 476196003733569547 - announcements: 476196024512413698 - big_brother_logs: &BBLOGS 476196047631417345 - bot: 476196062214750219 - checkpoint_test: 476196079562653698 - defcon: 476196101284954122 - devlog: &DEVLOG 476196115432210443 - devtest: &DEVTEST 476196128933543937 - help_0: 476196221845897270 - help_1: 476196242926469121 - help_2: 476196266594926593 - help_3: 476196281421660160 - help_4: 476196292398153738 - help_5: 476196300933824532 - help_6: 621711690140221440 - help_7: 621711714811117568 - helpers: 476196888295505940 - message_log: &MESSAGE_LOG 476197264667181057 - mod_alerts: 476197283256336385 - modlog: &MODLOG 476197299169525780 - off_topic_0: 476196547324018688 - off_topic_1: 476196563216105472 - off_topic_2: 476196574343593985 - python: 476190141161930755 - reddit: 476197119762366464 - staff_lounge: &STAFF_LOUNGE 476197226348019712 - talent_pool: &TALENT_POOL 609530835476938757 - userlog: 609531966387388446 - user_event_a: &USER_EVENT_A 609531030164078613 - verification: 476197158928777237 + admins: &ADMINS 365960823622991872 + announcements: 354619224620138496 + big_brother_logs: &BBLOGS 468507907357409333 + bot: 267659945086812160 + checkpoint_test: 422077681434099723 + defcon: 464469101889454091 + devlog: &DEVLOG 409308876241108992 + devtest: &DEVTEST 414574275865870337 + help_0: 303906576991780866 + help_1: 303906556754395136 + help_2: 303906514266226689 + help_3: 439702951246692352 + help_4: 451312046647148554 + help_5: 454941769734422538 + help_6: 587375753306570782 + help_7: 587375768556797982 + helpers: 385474242440986624 + message_log: &MESSAGE_LOG 467752170159079424 + mod_alerts: 473092532147060736 + modlog: &MODLOG 282638479504965634 + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 + python: 267624335836053506 + reddit: 458224812528238616 + staff_lounge: &STAFF_LOUNGE 464905259261755392 + talent_pool: &TALENT_POOL 534321732593647616 + userlog: 528976905546760203 + user_event_a: &USER_EVENT_A 592000283102674944 + verification: 352442727016693763 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] roles: - admin: &ADMIN_ROLE 476190234653229056 - announcements: 476190253548306433 - champion: 476190284447875086 - contributor: 476190302659543061 - core_developer: 622459804367061015 - jammer: 476190341566038027 - moderator: &MOD_ROLE 476190357927886848 - muted: &MUTED_ROLE 476190376949186560 - owner: &OWNER_ROLE 476190391595433985 - verified: 476190408871772171 - helpers: 476190429960732672 - rockstars: &ROCKSTARS_ROLE 503859559815708682 - team_leader: 609532800139264018 + admin: &ADMIN_ROLE 267628507062992896 + announcements: 463658397560995840 + champion: 430492892331769857 + contributor: 295488872404484098 + core_developer: 587606783669829632 + jammer: 423054537079783434 + moderator: &MOD_ROLE 267629731250176001 + muted: &MUTED_ROLE 277914926603829249 + owner: &OWNER_ROLE 267627879762755584 + verified: 352427296948486144 + helpers: 267630620367257601 + rockstars: &ROCKSTARS_ROLE 458226413825294336 + team_leader: 501324292341104650 webhooks: - talent_pool: 609534369178189844 - big_brother: 609535034474496063 + talent_pool: 569145364800602132 + big_brother: 569133704568373283 filter: # What do we filter? - filter_zalgo: true - filter_invites: true - filter_domains: true + filter_zalgo: false + filter_invites: true + filter_domains: true watch_rich_embeds: true - watch_words: true - watch_tokens: true + watch_words: true + watch_tokens: true # Notify user on filter? # Notifications are not expected for "watchlist" type filters @@ -158,7 +159,7 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: false # Ping @everyone when we send a mod-alert? + ping_everyone: true # Ping @everyone when we send a mod-alert? guild_invite_whitelist: - 280033776820813825 # Functional Programming @@ -235,9 +236,9 @@ urls: # PyDis site vars site: &DOMAIN "django.pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] - site_paste: &PASTE "https://paste.pythondiscord.com" - site_schema: &SCHEMA "https://" + site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_staff: &STAFF !JOIN ["staff.", *DOMAIN] + site_schema: &SCHEMA "https://" site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] @@ -257,7 +258,7 @@ urls: site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] - paste_service: !JOIN [*PASTE, "/{key}"] + paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" @@ -277,7 +278,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_everyone: false + ping_everyone: true punishment: role_id: *MUTED_ROLE diff --git a/config-prod.yml b/config-prod.yml deleted file mode 100644 index c9fc3b954..000000000 --- a/config-prod.yml +++ /dev/null @@ -1,360 +0,0 @@ -bot: - prefix: "!" - token: !ENV "BOT_TOKEN" - - cooldowns: - # Per channel, per tag. - tags: 60 - - clean: - # Maximum number of messages to traverse for clean commands - message_limit: 10000 - - -style: - colours: - soft_red: 0xcd6d6d - soft_green: 0x68c290 - soft_orange: 0xf9cb54 - - emojis: - defcon_disabled: "<:defcondisabled:470326273952972810>" - defcon_enabled: "<:defconenabled:470326274213150730>" - defcon_updated: "<:defconsettingsupdated:470326274082996224>" - - green_chevron: "<:greenchevron:418104310329769993>" - red_chevron: "<:redchevron:418112778184818698>" - white_chevron: "<:whitechevron:418110396973711363>" - bb_message: "<:bbmessage:476273120999636992>" - - status_online: "<:status_online:470326272351010816>" - status_idle: "<:status_idle:470326266625785866>" - status_dnd: "<:status_dnd:470326272082313216>" - status_offline: "<:status_offline:470326266537705472>" - - bullet: "\u2022" - pencil: "\u270F" - new: "\U0001F195" - cross_mark: "\u274C" - - icons: - crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" - crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" - crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" - - defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" - defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png" - defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" - defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" - - filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" - - guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" - - hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" - hash_green: "https://cdn.discordapp.com/emojis/469950144918585344.png" - hash_red: "https://cdn.discordapp.com/emojis/469950145413251072.png" - - message_bulk_delete: "https://cdn.discordapp.com/emojis/469952898994929668.png" - message_delete: "https://cdn.discordapp.com/emojis/472472641320648704.png" - message_edit: "https://cdn.discordapp.com/emojis/472472638976163870.png" - - sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png" - sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png" - - token_removed: "https://cdn.discordapp.com/emojis/470326273298792469.png" - - user_ban: "https://cdn.discordapp.com/emojis/469952898026045441.png" - user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" - user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" - - user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" - user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" - user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" - - user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" - - pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" - - remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" - remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" - remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" - - questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" - -guild: - id: 267624335836053506 - - categories: - python_help: 356013061213126657 - - channels: - admins: &ADMINS 365960823622991872 - announcements: 354619224620138496 - big_brother_logs: &BBLOGS 468507907357409333 - bot: 267659945086812160 - checkpoint_test: 422077681434099723 - defcon: 464469101889454091 - devlog: &DEVLOG 409308876241108992 - devtest: &DEVTEST 414574275865870337 - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - help_6: 587375753306570782 - help_7: 587375768556797982 - helpers: 385474242440986624 - message_log: &MESSAGE_LOG 467752170159079424 - mod_alerts: 473092532147060736 - modlog: &MODLOG 282638479504965634 - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 - python: 267624335836053506 - reddit: 458224812528238616 - staff_lounge: &STAFF_LOUNGE 464905259261755392 - talent_pool: &TALENT_POOL 534321732593647616 - userlog: 528976905546760203 - user_event_a: &USER_EVENT_A 592000283102674944 - verification: 352442727016693763 - - ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] - - roles: - admin: &ADMIN_ROLE 267628507062992896 - announcements: 463658397560995840 - champion: 430492892331769857 - contributor: 295488872404484098 - core_developer: 587606783669829632 - jammer: 423054537079783434 - moderator: &MOD_ROLE 267629731250176001 - muted: &MUTED_ROLE 277914926603829249 - owner: &OWNER_ROLE 267627879762755584 - verified: 352427296948486144 - helpers: 267630620367257601 - rockstars: &ROCKSTARS_ROLE 458226413825294336 - team_leader: 501324292341104650 - - webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - - -filter: - - # What do we filter? - filter_zalgo: false - filter_invites: true - filter_domains: true - watch_rich_embeds: true - watch_words: true - watch_tokens: true - - # Notify user on filter? - # Notifications are not expected for "watchlist" type filters - notify_user_zalgo: false - notify_user_invites: true - notify_user_domains: false - - # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? - - guild_invite_whitelist: - - 280033776820813825 # Functional Programming - - 267624335836053506 # Python Discord - - 440186186024222721 # Python Discord: ModLog Emojis - - 273944235143593984 # STEM - - 348658686962696195 # RLBot - - 531221516914917387 # Pallets - - 249111029668249601 # Gentoo - - 327254708534116352 # Adafruit - - 544525886180032552 # kennethreitz.org - - 590806733924859943 # Discord Hack Week - - 423249981340778496 # Kivy - - domain_blacklist: - - pornhub.com - - liveleak.com - - word_watchlist: - - goo+ks* - - ky+s+ - - ki+ke+s* - - beaner+s? - - coo+ns* - - nig+lets* - - slant-eyes* - - towe?l-?head+s* - - chi*n+k+s* - - spick*s* - - kill* +(?:yo)?urself+ - - jew+s* - - suicide - - rape - - (re+)tar+(d+|t+)(ed)? - - ta+r+d+ - - cunts* - - trann*y - - shemale - - token_watchlist: - - fa+g+s* - - 卐 - - 卍 - - cuck(?!oo+) - - nigg+(?:e*r+|a+h*?|u+h+)s? - - fag+o+t+s* - - # Censor doesn't apply to these - channel_whitelist: - - *ADMINS - - *MODLOG - - *MESSAGE_LOG - - *DEVLOG - - *BBLOGS - - *STAFF_LOUNGE - - *DEVTEST - - *TALENT_POOL - - *USER_EVENT_A - - role_whitelist: - - *ADMIN_ROLE - - *MOD_ROLE - - *OWNER_ROLE - - *ROCKSTARS_ROLE - - -keys: - deploy_bot: !ENV "DEPLOY_BOT_KEY" - deploy_site: !ENV "DEPLOY_SITE" - site_api: !ENV "BOT_API_KEY" - - -urls: - # PyDis site vars - site: &DOMAIN "pythondiscord.com" - site_api: &API !JOIN ["api.", *DOMAIN] - site_paste: &PASTE !JOIN ["paste.", *DOMAIN] - site_staff: &STAFF !JOIN ["staff.", *DOMAIN] - site_schema: &SCHEMA "https://" - - site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] - site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] - site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] - site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] - site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] - site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] - site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] - site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] - site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] - site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] - site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] - site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] - site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] - site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] - site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] - site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] - site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] - paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] - - # Snekbox - snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" - - # Env vars - deploy: !ENV "DEPLOY_URL" - status: !ENV "STATUS_URL" - - # Discord API URLs - discord_api: &DISCORD_API "https://discordapp.com/api/v7/" - discord_invite_api: !JOIN [*DISCORD_API, "invites"] - - # Misc URLs - bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" - gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" - -anti_spam: - # Clean messages that violate a rule. - clean_offending: true - ping_everyone: true - - punishment: - role_id: *MUTED_ROLE - remove_after: 600 - - rules: - attachments: - interval: 10 - max: 3 - - burst: - interval: 10 - max: 7 - - burst_shared: - interval: 10 - max: 20 - - chars: - interval: 5 - max: 3_000 - - duplicates: - interval: 10 - max: 3 - - discord_emojis: - interval: 10 - max: 20 - - links: - interval: 10 - max: 10 - - mentions: - interval: 10 - max: 5 - - newlines: - interval: 10 - max: 100 - max_consecutive: 10 - - role_mentions: - interval: 10 - max: 3 - - -reddit: - request_delay: 60 - subreddits: - - 'r/Python' - - -wolfram: - # Max requests per day. - user_limit_day: 10 - guild_limit_day: 67 - key: !ENV "WOLFRAM_API_KEY" - - -big_brother: - log_delay: 15 - header_message_limit: 15 - - -free: - # Seconds to elapse for a channel - # to be considered inactive. - activity_timeout: 600 - cooldown_rate: 1 - cooldown_per: 60.0 - -redirect_output: - delete_invocation: true - delete_delay: 15 - -config: - required_keys: ['bot.token'] -- cgit v1.2.3 From 6af4044c9a4c12a957e579c82871e7a2917781d9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 17:11:53 +0200 Subject: Changing the prefix and domain back --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 01bdcd1e7..c9fc3b954 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,5 +1,5 @@ bot: - prefix: "." + prefix: "!" token: !ENV "BOT_TOKEN" cooldowns: @@ -234,7 +234,7 @@ keys: urls: # PyDis site vars - site: &DOMAIN "django.pythondiscord.com" + site: &DOMAIN "pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_staff: &STAFF !JOIN ["staff.", *DOMAIN] -- cgit v1.2.3 From 1dd55ae6055bbe320588a7f64de1a2bdd5ebaca3 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 18:07:58 +0200 Subject: Add tests for `bot.cogs.token_remover`. --- tests/cogs/test_token_remover.py | 133 +++++++++++++++++++++++++++++++++++++++ tests/helpers.py | 10 +++ tox.ini | 2 +- 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/cogs/test_token_remover.py create mode 100644 tests/helpers.py diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..9d46b3a05 --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,133 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +@pytest.fixture() +def token_remover(): + bot = MagicMock() + bot.get_cog.return_value = MagicMock() + bot.get_cog.return_value.send_log_message = AsyncMock() + return TokenRemover(bot=bot) + + +@pytest.fixture() +def message(): + message = MagicMock() + message.author.__str__.return_value = 'lemon' + message.author.bot = False + message.author.avatar_url_as.return_value = 'picture-lemon.png' + message.author.id = 42 + message.author.mention = '@lemon' + message.channel.send = AsyncMock() + message.channel.mention = '#lemonade-stand' + message.content = '' + message.delete = AsyncMock() + message.id = 555 + return message + + +@pytest.mark.parametrize( + ('content', 'expected'), + ( + ('MTIz', True), # 123 + ('YWJj', False), # abc + ) +) +def test_is_valid_user_id(content: str, expected: bool): + assert TokenRemover.is_valid_user_id(content) is expected + + +@pytest.mark.parametrize( + ('content', 'expected'), + ( + ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! + ('MTIz', False), # 123 + ) +) +def test_is_valid_timestamp(content: str, expected: bool): + assert TokenRemover.is_valid_timestamp(content) is expected + + +def test_mod_log_property(token_remover): + token_remover.bot.get_cog.return_value = 'lemon' + assert token_remover.mod_log == 'lemon' + token_remover.bot.get_cog.assert_called_once_with('ModLog') + + +def test_ignores_bot_messages(token_remover, message): + message.author.bot = True + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + +@pytest.mark.parametrize('content', ('', 'lemon wins')) +def test_ignores_messages_without_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + +@pytest.mark.parametrize('content', ('foo.bar.baz', 'x.y.')) +def test_ignores_invalid_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + +@pytest.mark.parametrize( + 'content, censored_token', + ( + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) +) +def test_censors_valid_tokens( + token_remover, message, content, censored_token, caplog +): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None # still no rval + + # asyncio logs some stuff about its reactor, discard it + [_, record] = caplog.records + assert record.message == ( + "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + + message.delete.assert_called_once_with() + message.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + token_remover.bot.get_cog.assert_called_with('ModLog') + message.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = token_remover.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=record.message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +def test_setup(caplog): + bot = MagicMock() + setup_cog(bot) + [record] = caplog.records + + bot.add_cog.assert_called_once() + assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..57c6fcc1a --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,10 @@ +from unittest.mock import MagicMock + + +__all__ = ('AsyncMock',) + + +# TODO: Remove me on 3.8 +class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tox.ini b/tox.ini index c84827570..21097cd97 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [flake8] max-line-length=120 -application_import_names=bot +application_import_names=bot,tests exclude=.cache,.venv ignore=B311,W503,E226,S311,T000 import-order-style=pycharm -- cgit v1.2.3 From 4d2e3b1afcb2ad15ff3e091929f42907effa4496 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 17:26:41 +0200 Subject: Validate `bot/resources/stars.json` in tests. --- tests/test_resources.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/test_resources.py diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2b17aea64 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,18 @@ +import json +import mimetypes +from pathlib import Path +from urllib.parse import urlparse + + +def test_stars_valid(): + """Validates that `bot/resources/stars.json` contains valid images.""" + + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + for url in data.values(): + assert urlparse(url).scheme == 'https' + + mimetype, _ = mimetypes.guess_type(url) + assert mimetype in ('image/jpeg', 'image/png') -- cgit v1.2.3 From 8d04b381dc41fde83fb4144cbf83ba3e0664fe82 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 19:28:48 +0200 Subject: Implement `!otn search`. Closes #408. --- bot/cogs/off_topic_names.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 5a61425be..8f5f9c2e5 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -1,4 +1,5 @@ import asyncio +import difflib import logging from datetime import datetime, timedelta @@ -141,6 +142,27 @@ class OffTopicNames: embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + @otname_group.command(name='search', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def search_command(self, ctx, *, query: str): + """ + Search for an off-topic name. + """ + + result = await self.bot.api_client.get('bot/off-topic-channel-names') + matches = difflib.get_close_matches(query, result, n=10, cutoff=0.35) + lines = sorted(f"• {name}" for name in matches) + embed = Embed( + title=f"Query results", + colour=Colour.blue() + ) + + if matches: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Nothing found." + await ctx.send(embed=embed) + def setup(bot: Bot): bot.add_cog(OffTopicNames(bot)) -- cgit v1.2.3 From 80ca36bc8e97ee578584e30be284fc0f033ad8a9 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 15 Sep 2019 20:52:43 +0200 Subject: Update site links to new URL scheme Some links still had the URL scheme of the old Flask website, I updated them to point to the correct pages on the new website. --- bot/cogs/filtering.py | 2 +- bot/cogs/moderation.py | 2 +- bot/cogs/site.py | 15 +++++++++------ bot/cogs/superstarify/__init__.py | 2 +- bot/cogs/verification.py | 4 ++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 418297fc4..77f6eece5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -59,7 +59,7 @@ class Filtering: "user_notification": Filter.notify_user_invites, "notification_msg": ( f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" - r"Our server rules can be found here: " + r"Our server rules can be found here: " ) }, "filter_domains": { diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index fb791c933..532a44f4d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -28,7 +28,7 @@ INFRACTION_ICONS = { "Kick": Icons.sign_out, "Ban": Icons.user_ban } -RULES_URL = "https://pythondiscord.com/about/rules" +RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("Ban", "Mute") diff --git a/bot/cogs/site.py b/bot/cogs/site.py index b5e63fb41..b540827bf 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -46,15 +46,18 @@ class Site: async def site_resources(self, ctx: Context): """Info about the site's Resources page.""" - url = f"{PAGES_URL}/resources" + learning_url = f"{PAGES_URL}/resources" + tools_url = f"{PAGES_URL}/tools" - embed = Embed(title="Resources") - embed.set_footer(text=url) + embed = Embed(title="Resources & Tools") + embed.set_footer(text=f"{learning_url} | {tools_url}") embed.colour = Colour.blurple() embed.description = ( - f"The [Resources page]({url}) on our website contains a " + f"The [Resources page]({learning_url}) on our website contains a " "list of hand-selected goodies that we regularly recommend " - "to both beginners and experts." + f"to both beginners and experts. The [Tools page]({tools_url}) " + "contains a couple of the most popular tools for programming in " + "Python." ) await ctx.send(embed=embed) @@ -111,7 +114,7 @@ class Site: # Rules were not submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" - " our [rules page](https://pythondiscord.com/about/rules). We expect" + f" our [rules page]({PAGES_URL}/rules). We expect" " all members of the community to have read and understood these." ) diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index cccd91304..b2e31db3e 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -15,7 +15,7 @@ from bot.decorators import with_role from bot.utils.moderation import post_infraction log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" class Superstarify: diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 6b42c9213..efbcda166 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -14,8 +14,8 @@ Hello! Welcome to the server, and thanks for verifying yourself! For your records, these are the documents you accepted: -`1)` Our rules, here: -`2)` Our privacy policy, here: - you can find information on how to have \ +`1)` Our rules, here: +`2)` Our privacy policy, here: - you can find information on how to have \ your information removed here as well. Feel free to review them at any point! -- cgit v1.2.3 From 43cc15121482de120dcc1158153a24d5cadf27fa Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 21:11:09 +0200 Subject: Add tests for `bot.cogs.security`. --- tests/cogs/test_security.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/cogs/test_security.py diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..1efb460fe --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,54 @@ +import logging +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +@pytest.fixture() +def cog(): + bot = MagicMock() + return security.Security(bot) + + +@pytest.fixture() +def context(): + return MagicMock() + + +def test_check_additions(cog): + cog.bot.check.assert_any_call(cog.check_on_guild) + cog.bot.check.assert_any_call(cog.check_not_bot) + + +def test_check_not_bot_for_humans(cog, context): + context.author.bot = False + assert cog.check_not_bot(context) + + +def test_check_not_bot_for_robots(cog, context): + context.author.bot = True + assert not cog.check_not_bot(context) + + +def test_check_on_guild_outside_of_guild(cog, context): + context.guild = None + + with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): + cog.check_on_guild(context) + + +def test_check_on_guild_on_guild(cog, context): + context.guild = "lemon's lemonade stand" + assert cog.check_on_guild(context) + + +def test_security_cog_load(caplog): + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() + [record] = caplog.records + assert record.message == "Cog loaded: Security" + assert record.levelno == logging.INFO -- cgit v1.2.3 From 6032466412e6bed5d431968158e40cb5097d3915 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 22:51:08 +0200 Subject: Changing the #dev-logs ID to new channel. We retired the old #dev-logs channel (for security reasons) and have made a new one for public consumption. This commit changes the ID to match the new channel. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index c9fc3b954..be18b9475 100644 --- a/config-default.yml +++ b/config-default.yml @@ -95,7 +95,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: 464469101889454091 - devlog: &DEVLOG 409308876241108992 + devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 help_0: 303906576991780866 help_1: 303906556754395136 -- cgit v1.2.3 From 1af9ffd4185766b8955f65ba92eab5123c41d97e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 15 Sep 2019 23:22:26 +0200 Subject: Replaces all GitLab refs with GitHub. There were some GitLab references in various parts of the code, which were causing a problem with displaying icons in the bot connection embeds and other minor aesthetic issues. This commit replaces all links to GitLab with their GitHub equivalent, resolving these bugs. --- bot/cogs/cogs.py | 8 ++++---- bot/cogs/logging.py | 4 ++-- bot/constants.py | 2 +- config-default.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index ebdbf5ad8..7283aae6d 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -60,7 +60,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -113,7 +113,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -168,7 +168,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -269,7 +269,7 @@ class Cogs: embed.colour = Colour.blurple() embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6b8462f3b..b31db60d9 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -23,8 +23,8 @@ class Logging: embed = Embed(description="Connected!") embed.set_author( name="Python Bot", - url="https://gitlab.com/discord-python/projects/bot", - icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png" + url="https://github.com/python-discord/bot", + icon_url="https://github.com/python-discord/branding/blob/master/logos/logo_circle/logo_circle.png" ) if not DEBUG_MODE: diff --git a/bot/constants.py b/bot/constants.py index 4e14a85a8..d5b73bd1d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -412,7 +412,7 @@ class URLs(metaclass=YAMLGetter): # Misc endpoints bot_avatar: str deploy: str - gitlab_bot_repo: str + github_bot_repo: str status: str # Site endpoints diff --git a/config-default.yml b/config-default.yml index be18b9475..fd83e69a4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -273,7 +273,7 @@ urls: # Misc URLs bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" - gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" + github_bot_repo: "https://github.com/python-discord/bot" anti_spam: # Clean messages that violate a rule. -- cgit v1.2.3 From 70b7ef7ae65888da3e5a36fad76f51726b03fc7e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 16 Sep 2019 16:59:21 +0200 Subject: Update discord.py version to 1.2.3 I have updated the discord.py version to 1.2.3. This includes changes throughout the entire code base, including: - All cogs now inherit from `discord.ext.commands.Cog`; - All of our ABCs now have `bot.utils.CogABCMeta` as a metaclass; - All event handlers in cogs are now decorated with `Cog.listener()`; - Some method names have changes, including: - get_message => fetch_message - get_webhook_info => fetch_webhook - A few occurences of `get_channel` have been replaced by the new coroutine `fetch_channel`; - I've had to patch a few lines of code to account for small differences between the versions, like `on_member_update` attribute names in ModLog and the fact the way we used `Context.invoke` a couple of times has stopped working. In addition, I've added a patch for a bug in discord.py (with the help of @Scragly). This discord.py version has a bug which causes the edited timestamp not to be processed for edited messages. It's already fixed on GitHub, but a bug fix release has not been released to PyPI. In the meantime, I've added a patch in `bot.patches.message_edited_at` and included conditional loading in `__main__`. Finally, I noticed a small bug in `bot.cogs.filtering` that I fixed; I replaces `return` with `continue` to make sure filtering for edited messages doesn't stop after the `rich_embed_filter`. --- Pipfile | 2 +- Pipfile.lock | 96 +++++++++++----------------------- bot/__main__.py | 14 +++-- bot/cogs/alias.py | 4 +- bot/cogs/antispam.py | 16 ++++-- bot/cogs/bot.py | 14 ++--- bot/cogs/clean.py | 4 +- bot/cogs/cogs.py | 4 +- bot/cogs/defcon.py | 14 ++--- bot/cogs/doc.py | 3 +- bot/cogs/error_handler.py | 5 +- bot/cogs/eval.py | 4 +- bot/cogs/filtering.py | 10 ++-- bot/cogs/free.py | 4 +- bot/cogs/help.py | 6 +-- bot/cogs/information.py | 9 ++-- bot/cogs/jams.py | 2 +- bot/cogs/logging.py | 10 ++-- bot/cogs/moderation.py | 7 +-- bot/cogs/modlog.py | 24 +++++++-- bot/cogs/off_topic_names.py | 12 ++--- bot/cogs/reddit.py | 7 +-- bot/cogs/reminders.py | 5 +- bot/cogs/security.py | 4 +- bot/cogs/site.py | 4 +- bot/cogs/snekbox.py | 4 +- bot/cogs/superstarify/__init__.py | 6 ++- bot/cogs/sync/cog.py | 11 +++- bot/cogs/tags.py | 9 ++-- bot/cogs/token_remover.py | 5 +- bot/cogs/utils.py | 4 +- bot/cogs/verification.py | 5 +- bot/cogs/watchchannels/bigbrother.py | 4 +- bot/cogs/watchchannels/talentpool.py | 4 +- bot/cogs/watchchannels/watchchannel.py | 30 +++++------ bot/cogs/wolfram.py | 4 +- bot/patches/__init__.py | 6 +++ bot/patches/message_edited_at.py | 32 ++++++++++++ bot/utils/__init__.py | 9 ++++ bot/utils/scheduling.py | 6 ++- 40 files changed, 240 insertions(+), 183 deletions(-) create mode 100644 bot/patches/__init__.py create mode 100644 bot/patches/message_edited_at.py diff --git a/Pipfile b/Pipfile index 739507ac3..eaef3bd65 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true} +discord-py = "~=1.2" aiodns = "*" logmatic-python = "*" aiohttp = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f655943b4..3c98e2b93 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "987e3fc1840e8050f159daa9c23a2c67bd18d17914d4295eb469a42c778daa10" + "sha256": "c1933af105f88f5f2541b1796b92f91d1fcf7a1a947abfe1d8edb016710a56df" }, "pipfile-spec": 6, "requires": { @@ -34,31 +34,31 @@ }, "aiohttp": { "hashes": [ - "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b", - "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08", - "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd", - "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac", - "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650", - "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa", - "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95", - "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330", - "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc", - "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b", - "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de", - "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4", - "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7", - "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b", - "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8", - "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd", - "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2", - "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698", - "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95", - "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6", - "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0", - "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07" + "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", + "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", + "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", + "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", + "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", + "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", + "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", + "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", + "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", + "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", + "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", + "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", + "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", + "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", + "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", + "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", + "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", + "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", + "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", + "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", + "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", + "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" ], "index": "pypi", - "version": "==3.4.4" + "version": "==3.5.4" }, "aiormq": { "hashes": [ @@ -167,12 +167,11 @@ "version": "==4.0.7" }, "discord-py": { - "editable": true, - "extras": [ - "voice" + "hashes": [ + "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d" ], - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb" + "index": "pypi", + "version": "==1.2.3" }, "docutils": { "hashes": [ @@ -375,7 +374,8 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:d51e69d7e2bda15beda04993a07d49598a09de7651375270ca60e234d10b7343" ], "version": "==2.19" }, @@ -386,42 +386,6 @@ ], "version": "==2.4.2" }, - "pynacl": { - "hashes": [ - "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca", - "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512", - "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6", - "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776", - "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac", - "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b", - "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb", - "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98", - "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd", - "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2", - "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef", - "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f", - "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988", - "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b", - "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415", - "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2", - "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101", - "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0", - "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582", - "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a", - "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975", - "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1", - "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb", - "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45", - "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031", - "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9", - "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752", - "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0", - "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c", - "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053", - "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4" - ], - "version": "==1.2.1" - }, "pyparsing": { "hashes": [ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", diff --git a/bot/__main__.py b/bot/__main__.py index b1a6a5fcd..f25693734 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,10 +2,11 @@ import asyncio import logging import socket +import discord from aiohttp import AsyncResolver, ClientSession, TCPConnector -from discord import Game from discord.ext.commands import Bot, when_mentioned_or +from bot import patches from bot.api import APIClient, APILoggingHandler from bot.constants import Bot as BotConfig, DEBUG_MODE @@ -14,9 +15,9 @@ log = logging.getLogger('bot') bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), - activity=Game(name="Commands: !help"), + activity=discord.Game(name="Commands: !help"), case_insensitive=True, - max_messages=10_000 + max_messages=10_000, ) # Global aiohttp session for all cogs @@ -71,6 +72,11 @@ bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") +# Apply `message_edited_at` patch if discord.py did not yet release a bug fix. +if not hasattr(discord.message.Message, '_handle_edited_timestamp'): + patches.message_edited_at.apply_patch() + bot.run(BotConfig.token) -bot.http_session.close() # Close the aiohttp session when the bot finishes running +# This calls a coroutine, so it doesn't do anything at the moment. +# bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index a44c47331..3d0c9d826 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -4,7 +4,7 @@ from typing import Union from discord import Colour, Embed, Member, User from discord.ext.commands import ( - Command, Context, clean_content, command, group + Cog, Command, Context, clean_content, command, group ) from bot.cogs.watchchannels.watchchannel import proxy_user @@ -14,7 +14,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Alias: +class Alias(Cog): """ Aliases for more used commands """ diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index e980de364..7b97881fd 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -7,10 +7,9 @@ from operator import itemgetter from typing import Dict, Iterable, List, Set from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot import rules -from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, @@ -95,7 +94,7 @@ class DeletionContext: ) -class AntiSpam: +class AntiSpam(Cog): """Cog that controls our anti-spam measures.""" def __init__(self, bot: Bot, validation_errors: bool) -> None: @@ -113,6 +112,7 @@ class AntiSpam: """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): """Unloads the cog and alerts admins if configuration validation failed.""" if self.validation_errors: @@ -131,6 +131,7 @@ class AntiSpam: self.bot.remove_cog(self.__class__.__name__) return + @Cog.listener() async def on_message(self, message: Message) -> None: """Applies the antispam rules to each received message.""" if ( @@ -152,7 +153,7 @@ class AntiSpam: # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False) + msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) if not msg.author.bot ] @@ -211,7 +212,12 @@ class AntiSpam: # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke(Moderation.tempmute, member, dt_remove_role_after, reason=reason) + await context.invoke( + self.bot.get_command('tempmute'), + member, + dt_remove_role_after, + reason=reason + ) async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: """Cleans the messages if cleaning is configured.""" diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 4a0f208f4..e88b1d9b5 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -4,7 +4,7 @@ import re import time from discord import Embed, Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Context, command, group +from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import ( Channels, Guild, MODERATION_ROLES, @@ -16,7 +16,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -class Bot: +class Bot(Cog): """ Bot information commands """ @@ -48,14 +48,14 @@ class Bot: @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) - async def bot_group(self, ctx: Context): + async def botinfo_group(self, ctx: Context): """ Bot informational commands """ await ctx.invoke(self.bot.get_command("help"), "bot") - @bot_group.command(name='about', aliases=('info',), hidden=True) + @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) async def about_command(self, ctx: Context): """ @@ -236,6 +236,7 @@ class Bot: return msg.content[:3] in not_backticks + @Cog.listener() async def on_message(self, msg: Message): """ Detect poorly formatted Python code and send the user @@ -357,6 +358,7 @@ class Bot: f"The message that was posted was:\n\n{msg.content}\n\n" ) + @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): if ( # Checks to see if the message was called out by the bot @@ -370,14 +372,14 @@ class Bot: # Retrieve channel and message objects for use later channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.get_message(payload.message_id) + user_message = await channel.fetch_message(payload.message_id) # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock is None: - bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id]) + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 1f3e1caa9..20c24dafc 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -4,7 +4,7 @@ import re from typing import Optional from discord import Colour, Embed, Message, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.modlog import ModLog from bot.constants import ( @@ -16,7 +16,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class Clean: +class Clean(Cog): """ A cog that allows messages to be deleted in bulk, while applying various filters. diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 7283aae6d..ec497b966 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -2,7 +2,7 @@ import logging import os from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import ( Emojis, MODERATION_ROLES, Roles, URLs @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] -class Cogs: +class Cogs(Cog): """ Cog management commands """ diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c67fa2807..8fab00712 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -2,10 +2,10 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.modlog import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -24,21 +24,23 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class Defcon: +class Defcon(Cog): """Time-sensitive server defense mechanisms""" days = None # type: timedelta enabled = False # type: bool def __init__(self, bot: Bot): self.bot = bot + self.channel = None self.days = timedelta(days=0) - self.headers = {"X-API-KEY": Keys.site_api} @property def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): + self.channel = await self.bot.fetch_channel(Channels.defcon) try: response = await self.bot.api_client.get('bot/bot-settings/defcon') data = response['data'] @@ -62,6 +64,7 @@ class Defcon: await self.update_channel_topic() + @Cog.listener() async def on_member_join(self, member: Member): if self.enabled and self.days.days > 0: now = datetime.utcnow() @@ -278,8 +281,7 @@ class Defcon: new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon) - await defcon_channel.edit(topic=new_topic) + await self.channel.edit(topic=new_topic) def setup(bot: Bot): diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index aa49b0c25..ebf2c1d65 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -120,12 +120,13 @@ class InventoryURL(commands.Converter): return url -class Doc: +class Doc(commands.Cog): def __init__(self, bot): self.base_urls = {} self.bot = bot self.inventories = {} + @commands.Cog.listener() async def on_ready(self): await self.refresh_inventory() diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index cfcba6f26..e2d8c3a8f 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -14,7 +14,7 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError, ) -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context from bot.api import ResponseCodeError from bot.constants import Channels @@ -23,12 +23,13 @@ from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) -class ErrorHandler: +class ErrorHandler(Cog): """Handles errors emitted from commands.""" def __init__(self, bot: Bot): self.bot = bot + @Cog.listener() async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 8e97a35a2..c52c04df1 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -8,7 +8,7 @@ import traceback from io import StringIO import discord -from discord.ext.commands import Bot, group +from discord.ext.commands import Bot, Cog, group from bot.constants import Roles from bot.decorators import with_role @@ -17,7 +17,7 @@ from bot.interpreter import Interpreter log = logging.getLogger(__name__) -class CodeEval: +class CodeEval(Cog): """ Owner and admin feature that evaluates code and returns the result to the channel. diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 77f6eece5..dc4de7ff1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,7 +5,7 @@ from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.cogs.modlog import ModLog from bot.constants import ( @@ -29,7 +29,7 @@ URL_RE = r"(https?://[^\s]+)" ZALGO_RE = r"[\u0300-\u036F\u0489]" -class Filtering: +class Filtering(Cog): """ Filtering out invites, blacklisting domains, and warning us of certain regular expressions @@ -96,14 +96,16 @@ class Filtering: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, msg: Message): await self._filter_message(msg) + @Cog.listener() async def on_message_edit(self, before: Message, after: Message): if not before.edited_at: delta = relativedelta(after.edited_at, before.created_at).microseconds else: - delta = None + delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) async def _filter_message(self, msg: Message, delta: Optional[int] = None): @@ -142,7 +144,7 @@ class Filtering: # If the edit delta is less than 0.001 seconds, then we're probably dealing # with a double filter trigger. if delta is not None and delta < 100: - return + continue # Does the filter only need the message content or the full message? if _filter["content_only"]: diff --git a/bot/cogs/free.py b/bot/cogs/free.py index fd6009bb8..92a9ca041 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -2,7 +2,7 @@ import logging from datetime import datetime from discord import Colour, Embed, Member, utils -from discord.ext.commands import Context, command +from discord.ext.commands import Cog, Context, command from bot.constants import Categories, Channels, Free, STAFF_ROLES from bot.decorators import redirect_output @@ -15,7 +15,7 @@ RATE = Free.cooldown_rate PER = Free.cooldown_per -class Free: +class Free(Cog): """Tries to figure out which help channels are free.""" PYTHON_HELP_ID = Categories.python_help diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 20ed08f07..31e729003 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,7 +6,7 @@ from contextlib import suppress from discord import Colour, Embed, HTTPException from discord.ext import commands -from discord.ext.commands import CheckFailure +from discord.ext.commands import CheckFailure, Cog as DiscordCog from fuzzywuzzy import fuzz, process from bot import constants @@ -107,7 +107,7 @@ class HelpSession: self.query = ctx.bot self.description = self.query.description self.author = ctx.author - self.destination = ctx.author if ctx.bot.pm_help else ctx.channel + self.destination = ctx.channel # set the config for the session self._cleanup = cleanup @@ -649,7 +649,7 @@ class HelpSession: await self.message.delete() -class Help: +class Help(DiscordCog): """ Custom Embed Pagination Help feature """ diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 320750a24..c4aff73b8 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -2,11 +2,9 @@ import logging import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import ( - Channels, Emojis, Keys, MODERATION_ROLES, STAFF_ROLES -) +from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, with_role from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -14,7 +12,7 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) -class Information: +class Information(Cog): """ A cog with commands for generating embeds with server information, such as server statistics @@ -23,7 +21,6 @@ class Information: def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-Key": Keys.site_api} @with_role(*MODERATION_ROLES) @command(name="roles") diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index bca1fb607..dd14111ce 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -10,7 +10,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class CodeJams: +class CodeJams(commands.Cog): """ Manages the code-jam related parts of our server """ diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index b31db60d9..64bbed46e 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -1,7 +1,7 @@ import logging from discord import Embed -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.constants import Channels, DEBUG_MODE @@ -9,7 +9,7 @@ from bot.constants import Channels, DEBUG_MODE log = logging.getLogger(__name__) -class Logging: +class Logging(Cog): """ Debug logging module """ @@ -17,6 +17,7 @@ class Logging: def __init__(self, bot: Bot): self.bot = bot + @Cog.listener() async def on_ready(self): log.info("Bot connected!") @@ -24,7 +25,10 @@ class Logging: embed.set_author( name="Python Bot", url="https://github.com/python-discord/bot", - icon_url="https://github.com/python-discord/branding/blob/master/logos/logo_circle/logo_circle.png" + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) ) if not DEBUG_MODE: diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 532a44f4d..fcbadd235 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -8,7 +8,7 @@ from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User ) from discord.ext.commands import ( - BadArgument, BadUnionArgument, Bot, Context, command, group + BadArgument, BadUnionArgument, Bot, Cog, Context, command, group ) from bot import constants @@ -46,7 +46,7 @@ def proxy_user(user_id: str) -> Object: UserTypes = Union[Member, User, proxy_user] -class Moderation(Scheduler): +class Moderation(Scheduler, Cog): """ Server moderation tools. """ @@ -60,6 +60,7 @@ class Moderation(Scheduler): def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): # Schedule expiration for previous infractions infractions = await self.bot.api_client.get( @@ -1348,7 +1349,7 @@ class Moderation(Scheduler): """ # sometimes `user` is a `discord.Object`, so let's make it a proper user. - user = await self.bot.get_user_info(user.id) + user = await self.bot.fetch_user(user.id) try: await user.send(embed=embed) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 808ba667b..978646f46 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -11,7 +11,7 @@ from discord import ( RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel ) from discord.abc import GuildChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.constants import ( Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs @@ -24,11 +24,11 @@ GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("activity", "status") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -class ModLog: +class ModLog(Cog, name="ModLog"): """ Logging for server events and staff actions """ @@ -122,6 +122,7 @@ class ModLog: return await self.bot.get_context(log_message) # Optionally return for use with antispam + @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -146,6 +147,7 @@ class ModLog: await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) + @Cog.listener() async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -167,6 +169,7 @@ class ModLog: title, message ) + @Cog.listener() async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel): if before.guild.id != GuildConstant.id: return @@ -225,6 +228,7 @@ class ModLog: "Channel updated", message ) + @Cog.listener() async def on_guild_role_create(self, role: Role): if role.guild.id != GuildConstant.id: return @@ -234,6 +238,7 @@ class ModLog: "Role created", f"`{role.id}`" ) + @Cog.listener() async def on_guild_role_delete(self, role: Role): if role.guild.id != GuildConstant.id: return @@ -243,6 +248,7 @@ class ModLog: "Role removed", f"{role.name} (`{role.id}`)" ) + @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role): if before.guild.id != GuildConstant.id: return @@ -294,6 +300,7 @@ class ModLog: "Role updated", message ) + @Cog.listener() async def on_guild_update(self, before: Guild, after: Guild): if before.id != GuildConstant.id: return @@ -343,6 +350,7 @@ class ModLog: thumbnail=after.icon_url_as(format="png") ) + @Cog.listener() async def on_member_ban(self, guild: Guild, member: Union[Member, User]): if guild.id != GuildConstant.id: return @@ -358,6 +366,7 @@ class ModLog: channel_id=Channels.modlog ) + @Cog.listener() async def on_member_join(self, member: Member): if member.guild.id != GuildConstant.id: return @@ -378,6 +387,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_member_remove(self, member: Member): if member.guild.id != GuildConstant.id: return @@ -393,6 +403,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_member_unban(self, guild: Guild, member: User): if guild.id != GuildConstant.id: return @@ -408,6 +419,7 @@ class ModLog: channel_id=Channels.modlog ) + @Cog.listener() async def on_member_update(self, before: Member, after: Member): if before.guild.id != GuildConstant.id: return @@ -497,6 +509,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_message_delete(self, message: Message): channel = message.channel author = message.author @@ -551,6 +564,7 @@ class ModLog: channel_id=Channels.message_log ) + @Cog.listener() async def on_raw_message_delete(self, event: RawMessageDeleteEvent): if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: return @@ -590,6 +604,7 @@ class ModLog: channel_id=Channels.message_log ) + @Cog.listener() async def on_message_edit(self, before: Message, after: Message): if ( not before.guild @@ -663,10 +678,11 @@ class ModLog: channel_id=Channels.message_log, timestamp_override=after.edited_at ) + @Cog.listener() async def on_raw_message_edit(self, event: RawMessageUpdateEvent): try: channel = self.bot.get_channel(int(event.data["channel_id"])) - message = await channel.get_message(event.message_id) + message = await channel.fetch_message(event.message_id) except NotFound: # Was deleted before we got the event return diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 5a61425be..f05ac7fef 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -3,9 +3,9 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Context, Converter, group +from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group -from bot.constants import Channels, Keys, MODERATION_ROLES +from bot.constants import Channels, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator @@ -37,7 +37,7 @@ class OffTopicName(Converter): return argument.translate(table) -async def update_names(bot: Bot, headers: dict): +async def update_names(bot: Bot): """ The background updater task that performs a channel name update daily. @@ -69,21 +69,21 @@ async def update_names(bot: Bot, headers: dict): ) -class OffTopicNames: +class OffTopicNames(Cog): """Commands related to managing the off-topic category channel names.""" def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self.updater_task = None def __cleanup(self): if self.updater_task is not None: self.updater_task.cancel() + @Cog.listener() async def on_ready(self): if self.updater_task is None: - coro = update_names(self.bot, self.headers) + coro = update_names(self.bot) self.updater_task = self.bot.loop.create_task(coro) @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index b5bd26e3d..4c561b7e8 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -5,7 +5,7 @@ import textwrap from datetime import datetime, timedelta from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES from bot.converters import Subreddit @@ -15,7 +15,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Reddit: +class Reddit(Cog): """ Track subreddit posts and show detailed statistics about them. """ @@ -279,8 +279,9 @@ class Reddit: max_lines=15 ) + @Cog.listener() async def on_ready(self): - self.reddit_channel = self.bot.get_channel(Channels.reddit) + self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) if self.reddit_channel is not None: if self.new_posts_task is None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 03ea00de8..c6ae984ea 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -7,7 +7,7 @@ from operator import itemgetter from dateutil.relativedelta import relativedelta from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import ExpirationDate @@ -22,12 +22,13 @@ WHITELISTED_CHANNELS = (Channels.bot,) MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler): +class Reminders(Scheduler, Cog): def __init__(self, bot: Bot): self.bot = bot super().__init__() + @Cog.listener() async def on_ready(self): # Get all the current reminders for re-scheduling response = await self.bot.api_client.get( diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 9523766af..e02e91530 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,11 +1,11 @@ import logging -from discord.ext.commands import Bot, Context, NoPrivateMessage +from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage log = logging.getLogger(__name__) -class Security: +class Security(Cog): """ Security-related helpers """ diff --git a/bot/cogs/site.py b/bot/cogs/site.py index b540827bf..4d5b2e811 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,7 +1,7 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import redirect_output @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" -class Site: +class Site(Cog): """Commands for linking to different parts of the site.""" def __init__(self, bot: Bot): diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index c8705ac6f..d36c0795d 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,7 +5,7 @@ import textwrap from signal import Signals from typing import Optional, Tuple -from discord.ext.commands import Bot, Context, command, guild_only +from discord.ext.commands import Bot, Cog, Context, command, guild_only from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import in_channel @@ -36,7 +36,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 -class Snekbox: +class Snekbox(Cog): """ Safe evaluation of Python code using Snekbox """ diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index b2e31db3e..e9743a2f5 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime from discord import Colour, Embed, Member from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" -class Superstarify: +class Superstarify(Cog): """ A set of commands to moderate terrible nicknames. """ @@ -34,6 +34,7 @@ class Superstarify: def modlog(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_member_update(self, before: Member, after: Member): """ This event will trigger when someone changes their name. @@ -91,6 +92,7 @@ class Superstarify: "to DM them, and a discord.errors.Forbidden error was incurred." ) + @Cog.listener() async def on_member_join(self, member: Member): """ This event will trigger when someone (re)joins the server. diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ec6c5f447..9a3a48bba 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,7 +3,7 @@ from typing import Callable, Iterable from discord import Guild, Member, Role from discord.ext import commands -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context from bot import constants from bot.api import ResponseCodeError @@ -12,7 +12,7 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) -class Sync: +class Sync(Cog): """Captures relevant events and sends them to the site.""" # The server to synchronize events on. @@ -29,6 +29,7 @@ class Sync: def __init__(self, bot: Bot) -> None: self.bot = bot + @Cog.listener() async def on_ready(self) -> None: """Syncs the roles/users of the guild with the database.""" guild = self.bot.get_guild(self.SYNC_SERVER_ID) @@ -47,6 +48,7 @@ class Sync: f"deleted `{total_deleted}`." ) + @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" await self.bot.api_client.post( @@ -60,10 +62,12 @@ class Sync: } ) + @Cog.listener() async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" await self.bot.api_client.delete(f'bot/roles/{role.id}') + @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" if ( @@ -83,6 +87,7 @@ class Sync: } ) + @Cog.listener() async def on_member_join(self, member: Member) -> None: """ Adds a new user or updates existing user to the database when a member joins the guild. @@ -118,6 +123,7 @@ class Sync: # If we got `404`, the user is new. Create them. await self.bot.api_client.post('bot/users', json=packed) + @Cog.listener() async def on_member_remove(self, member: Member) -> None: """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( @@ -132,6 +138,7 @@ class Sync: } ) + @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """Updates the user information if any of relevant attributes have changed.""" if ( diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7b1003148..8e9ba5da3 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,9 +2,9 @@ import logging import time from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles +from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator @@ -19,7 +19,7 @@ TEST_CHANNELS = ( ) -class Tags: +class Tags(Cog): """ Save new tags and fetch existing tags. """ @@ -27,7 +27,6 @@ class Tags: def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self.headers = {"Authorization": f"Token {Keys.site_api}"} @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): @@ -82,7 +81,7 @@ class Tags: "time": time.time(), "channel": ctx.channel.id } - await ctx.send(embed=Embed.from_data(tag['embed'])) + await ctx.send(embed=Embed.from_dict(tag['embed'])) else: tags = await self.bot.api_client.get('bot/tags') diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index b2c4cd522..64bf126d6 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -6,7 +6,7 @@ import struct from datetime import datetime from discord import Colour, Message -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from discord.utils import snowflake_time from bot.cogs.modlog import ModLog @@ -34,7 +34,7 @@ TOKEN_RE = re.compile( ) -class TokenRemover: +class TokenRemover(Cog): """Scans messages for potential discord.py bot tokens and removes them.""" def __init__(self, bot: Bot): @@ -44,6 +44,7 @@ class TokenRemover: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, msg: Message): if msg.author.bot: return diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 98208723a..08e77a24e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -5,7 +5,7 @@ from email.parser import HeaderParser from io import StringIO from discord import Colour, Embed -from discord.ext.commands import AutoShardedBot, Context, command +from discord.ext.commands import AutoShardedBot, Cog, Context, command from bot.constants import Channels, STAFF_ROLES from bot.decorators import in_channel @@ -13,7 +13,7 @@ from bot.decorators import in_channel log = logging.getLogger(__name__) -class Utils: +class Utils(Cog): """ A selection of utilities which don't have a clear category. """ diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index efbcda166..14c3f39e3 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from discord import Message, NotFound, Object -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.modlog import ModLog from bot.constants import Channels, Event, Roles @@ -28,7 +28,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ -class Verification: +class Verification(Cog): """ User verification and role self-management """ @@ -40,6 +40,7 @@ class Verification: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, message: Message): if message.author.bot: return # They're a bot, ignore diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e7b3d70bc..338b6c4ad 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,7 +3,7 @@ from collections import ChainMap from typing import Union from discord import User -from discord.ext.commands import Context, group +from discord.ext.commands import Cog, Context, group from bot.constants import Channels, Roles, Webhooks from bot.decorators import with_role @@ -13,7 +13,7 @@ from .watchchannel import WatchChannel, proxy_user log = logging.getLogger(__name__) -class BigBrother(WatchChannel): +class BigBrother(WatchChannel, Cog, name="Big Brother"): """Monitors users by relaying their messages to a watch channel to assist with moderation.""" def __init__(self, bot) -> None: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 47d207d05..4452d7a59 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,7 +4,7 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Context, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.constants import Channels, Guild, Roles, Webhooks @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? -class TalentPool(WatchChannel): +class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" def __init__(self, bot) -> None: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 3a24e3f21..c34b0d5bb 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -3,20 +3,20 @@ import datetime import logging import re import textwrap -from abc import ABC, abstractmethod +from abc import abstractmethod from collections import defaultdict, deque from dataclasses import dataclass from typing import Optional import discord -from discord import Color, Embed, Message, Object, errors -from discord.ext.commands import BadArgument, Bot, Context +from discord import Color, Embed, HTTPException, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Cog, Context from bot.api import ResponseCodeError from bot.cogs.modlog import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator -from bot.utils import messages +from bot.utils import CogABCMeta, messages from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class MessageHistory: message_count: int = 0 -class WatchChannel(ABC): +class WatchChannel(metaclass=CogABCMeta): """ABC with functionality for relaying users' messages to a certain channel.""" @abstractmethod @@ -98,21 +98,14 @@ class WatchChannel(ABC): """Starts the watch channel by getting the channel, webhook, and user cache ready.""" await self.bot.wait_until_ready() - # After updating d.py, this block can be replaced by `fetch_channel` with a try-except - for attempt in range(1, self.retries+1): - self.channel = self.bot.get_channel(self.destination) - if self.channel is None: - if attempt < self.retries: - await asyncio.sleep(self.retry_delay) - else: - break - else: - self.log.error(f"Failed to retrieve the text channel with id {self.destination}") + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py try: - self.webhook = await self.bot.get_webhook_info(self.webhook_id) - except (discord.HTTPException, discord.NotFound, discord.Forbidden): + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") if self.channel is None or self.webhook is None: @@ -169,6 +162,7 @@ class WatchChannel(ABC): return True + @Cog.listener() async def on_message(self, msg: Message) -> None: """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 7dd613083..e88efa033 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -7,7 +7,7 @@ import discord from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands -from discord.ext.commands import BucketType, Context, check, group +from discord.ext.commands import BucketType, Cog, Context, check, group from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator @@ -163,7 +163,7 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: return pages -class Wolfram: +class Wolfram(Cog): """ Commands for interacting with the Wolfram|Alpha API. """ diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py new file mode 100644 index 000000000..fd38ea8cf --- /dev/null +++ b/bot/patches/__init__.py @@ -0,0 +1,6 @@ +"""Subpackage that contains patches for discord.py""" +from . import message_edited_at + +__all__ = [ + message_edited_at, +] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py new file mode 100644 index 000000000..528373a9b --- /dev/null +++ b/bot/patches/message_edited_at.py @@ -0,0 +1,32 @@ +""" +# message_edited_at patch + +Date: 2019-09-16 +Author: Scragly +Added by: Ves Zappa + +Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of +`discord.Messages` are not being handled correctly. This patch fixes that until a new +release of discord.py is released (and we've updated to it). +""" +import logging + +from discord import message, utils + +log = logging.getLogger(__name__) + + +def _handle_edited_timestamp(self, value): + """Helper function that takes care of parsing the edited timestamp.""" + self._edited_timestamp = utils.parse_time(value) + + +def apply_patch(): + """Applies the `edited_at` patch to the `discord.message.Message` class.""" + message.Message._handle_edited_timestamp = _handle_edited_timestamp + message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp + log.info("Patch applied: message_edited_at") + + +if __name__ == "__main__": + apply_patch() diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 4c99d50e8..d5ae0a7c5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,3 +1,12 @@ +from abc import ABCMeta + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): + """Metaclass for ABCs meant to be implemented as Cogs.""" + pass + class CaseInsensitiveDict(dict): """ diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ded6401b0..f03865013 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,13 +1,15 @@ import asyncio import contextlib import logging -from abc import ABC, abstractmethod +from abc import abstractmethod from typing import Dict +from bot.utils import CogABCMeta + log = logging.getLogger(__name__) -class Scheduler(ABC): +class Scheduler(metaclass=CogABCMeta): def __init__(self): -- cgit v1.2.3 From 230bd00695ac6be5b0dd0907ead776d48e153da2 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 17 Sep 2019 05:05:52 +1000 Subject: Adjust to new cog method names. --- bot/cogs/moderation.py | 6 ++++-- bot/cogs/off_topic_names.py | 2 +- bot/cogs/verification.py | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index fcbadd235..c631dd69d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1375,13 +1375,15 @@ class Moderation(Scheduler, Cog): # endregion - async def __error(self, ctx: Context, error) -> None: + @staticmethod + async def cog_command_error(ctx: Context, error) -> None: if isinstance(error, BadUnionArgument): if User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True - async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool: + @staticmethod + async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool: """ Check if the highest role of the invoking member is greater than that of the target member. If this check fails, a warning is sent to the invoking ctx. diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index f05ac7fef..cadc1bf92 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -76,7 +76,7 @@ class OffTopicNames(Cog): self.bot = bot self.updater_task = None - def __cleanup(self): + def cog_unload(self): if self.updater_task is not None: self.updater_task.cancel() diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 14c3f39e3..c42d4d67e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -153,13 +153,13 @@ class Verification(Cog): ) @staticmethod - async def __error(ctx: Context, error): + async def cog_command_error(ctx: Context, error): if isinstance(error, InChannelCheckFailure): # Do nothing; just ignore this error error.handled = True @staticmethod - def __global_check(ctx: Context): + def bot_check(ctx: Context): """ Block any command within the verification channel that is not !accept. """ -- cgit v1.2.3 From 164a409f232162b7c7c637c175503ba74eedd5a8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 16 Sep 2019 22:30:21 +0200 Subject: Fix InfractionSearchQuery I missed a `get_user_info` in InfractionSearchQuery in bot.converts. This method is now `fetch_user` in Discord.py 1.2.3. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 30ea7ca0f..4bd9aba13 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -71,7 +71,7 @@ class InfractionSearchQuery(Converter): async def convert(ctx, arg): try: maybe_snowflake = arg.strip("<@!>") - return await ctx.bot.get_user_info(maybe_snowflake) + return await ctx.bot.fetch_user(maybe_snowflake) except (discord.NotFound, discord.HTTPException): return arg -- cgit v1.2.3 From 39a28ca7eb2ba7966677b5283bce1ebdf8974ee1 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 23:55:11 +0200 Subject: Add coverage reporting to tests. --- .gitignore | 6 ++++++ Pipfile | 1 + azure-pipelines.yml | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index be4f43c7f..09ca151fb 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,9 @@ log.* # Custom user configuration config.yml + +# JUnit XML reports from pytest +junit.xml + +# Coverage XML artifacts +coverage.xml diff --git a/Pipfile b/Pipfile index eaef3bd65..273db04d2 100644 --- a/Pipfile +++ b/Pipfile @@ -32,6 +32,7 @@ pre-commit = "~=1.18" safety = "*" dodgy = "*" pytest = "*" +pytest-cov = "*" [requires] python_version = "3.7" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 19df35c11..242513ab4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -38,9 +38,23 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_TOKEN=foobar python -m pytest tests + - script: BOT_TOKEN=foobar python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests displayName: Run tests + - task: PublishCodeCoverageResults@1 + displayName: 'Publish Coverage Results' + condition: succeededOrFailed() + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: coverage.xml + + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + condition: succeededOrFailed() + inputs: + testResultsFiles: junit.xml + testRunTitle: 'Bot Test results' + - job: build displayName: 'Build Containers' dependsOn: 'test' -- cgit v1.2.3 From 8346c3663f8a1adb9a8e9d0a97216aa267958a51 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 17 Sep 2019 23:15:47 +1000 Subject: Update lock to new coverage dependancies. --- Pipfile.lock | 68 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 3c98e2b93..e5978198f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c1933af105f88f5f2541b1796b92f91d1fcf7a1a947abfe1d8edb016710a56df" + "sha256": "f21c27a5c4493b65a36a78721c2cb597c3eed7fcbd28f3bf731453f2c3cccb56" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:31e08189841a8350db5bec70608b4d2fbacb89c0a555a18ec47511716a9bfc41", - "sha256:adfe0acf34356ccd9654a9a1c46f7e8db1dc4497a774c0e54bf2d3af14571bd0" + "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", + "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50" ], "index": "pypi", - "version": "==6.1.1" + "version": "==6.1.2" }, "aiodns": { "hashes": [ @@ -152,11 +152,11 @@ }, "dateparser": { "hashes": [ - "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e", - "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09" + "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665", + "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b" ], "index": "pypi", - "version": "==0.7.1" + "version": "==0.7.2" }, "deepdiff": { "hashes": [ @@ -374,8 +374,7 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", - "sha256:d51e69d7e2bda15beda04993a07d49598a09de7651375270ca60e234d10b7343" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" ], "version": "==2.19" }, @@ -635,6 +634,43 @@ ], "version": "==7.0" }, + "coverage": { + "hashes": [ + "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", + "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", + "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", + "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", + "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", + "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", + "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", + "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", + "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", + "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", + "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", + "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", + "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", + "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", + "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", + "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", + "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", + "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", + "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", + "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", + "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", + "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", + "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", + "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", + "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", + "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", + "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", + "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", + "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", + "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", + "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", + "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" + ], + "version": "==4.5.4" + }, "dodgy": { "hashes": [ "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" @@ -719,11 +755,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:652234b6ab8f2506ae58e528b6fbcc668831d3cc758e1bc01ef438d328b68cdb", - "sha256:6f264986fb88042bc1f0535fa9a557e6a376cfe5679dc77caac7fe8b5d43d05f" + "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", + "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], "markers": "python_version < '3.8'", - "version": "==0.22" + "version": "==0.23" }, "mccabe": { "hashes": [ @@ -804,6 +840,14 @@ "index": "pypi", "version": "==5.1.2" }, + "pytest-cov": { + "hashes": [ + "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", + "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" + ], + "index": "pypi", + "version": "==2.7.1" + }, "pyyaml": { "hashes": [ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", -- cgit v1.2.3 From f23cc37bbd4e44c6bf5fa1e25218b2030a41ab46 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Tue, 17 Sep 2019 23:17:35 +1000 Subject: Remove duplicate coverage.xml gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 09ca151fb..cda3aeb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,3 @@ config.yml # JUnit XML reports from pytest junit.xml - -# Coverage XML artifacts -coverage.xml -- cgit v1.2.3 From 0d27d5d0edf17fec789b752700e9e4a753f45df0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 16:48:10 +0200 Subject: Validate configuration against typehints. --- azure-pipelines.yml | 2 +- bot/constants.py | 7 +------ config-default.yml | 6 ------ tests/test_constants.py | 23 +++++++++++++++++++++++ 4 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 tests/test_constants.py diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 242513ab4..4dcad685c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -38,7 +38,7 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_TOKEN=foobar python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests displayName: Run tests - task: PublishCodeCoverageResults@1 diff --git a/bot/constants.py b/bot/constants.py index d5b73bd1d..e1c47889c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -347,9 +347,9 @@ class Channels(metaclass=YAMLGetter): message_log: int mod_alerts: int modlog: int + off_topic_0: int off_topic_1: int off_topic_2: int - off_topic_3: int python: int reddit: int talent_pool: int @@ -394,8 +394,6 @@ class Guild(metaclass=YAMLGetter): class Keys(metaclass=YAMLGetter): section = "keys" - deploy_bot: str - deploy_site: str site_api: str @@ -411,14 +409,11 @@ class URLs(metaclass=YAMLGetter): # Misc endpoints bot_avatar: str - deploy: str github_bot_repo: str - status: str # Site endpoints site: str site_api: str - site_clean_api: str site_superstarify_api: str site_logs_api: str site_logs_view: str diff --git a/config-default.yml b/config-default.yml index fd83e69a4..403de21ad 100644 --- a/config-default.yml +++ b/config-default.yml @@ -227,8 +227,6 @@ filter: keys: - deploy_bot: !ENV "DEPLOY_BOT_KEY" - deploy_site: !ENV "DEPLOY_SITE" site_api: !ENV "BOT_API_KEY" @@ -263,10 +261,6 @@ urls: # Snekbox snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" - # Env vars - deploy: !ENV "DEPLOY_URL" - status: !ENV "STATUS_URL" - # Discord API URLs discord_api: &DISCORD_API "https://discordapp.com/api/v7/" discord_invite_api: !JOIN [*DISCORD_API, "invites"] diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 000000000..e4a29d994 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,23 @@ +import inspect + +import pytest + +from bot import constants + + +@pytest.mark.parametrize( + 'section', + ( + cls + for (name, cls) in inspect.getmembers(constants) + if hasattr(cls, 'section') and isinstance(cls, type) + ) +) +def test_section_configuration_matches_typespec(section): + for (name, annotation) in section.__annotations__.items(): + value = getattr(section, name) + + if getattr(annotation, '_name', None) in ('Dict', 'List'): + pytest.skip("Cannot validate containers yet") + + assert isinstance(value, annotation) -- cgit v1.2.3 From ad711f04a789811c1aade6b49639474c592c044c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 16:04:54 +0200 Subject: Add basic tests for `bot.api`. --- tests/helpers.py | 22 +++++++++++- tests/test_api.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_api.py diff --git a/tests/helpers.py b/tests/helpers.py index 57c6fcc1a..f8fbb5e60 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,10 +1,30 @@ +import asyncio +import functools + from unittest.mock import MagicMock -__all__ = ('AsyncMock',) +__all__ = ('AsyncMock', 'async_test') # TODO: Remove me on 3.8 class AsyncMock(MagicMock): async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) + + +def async_test(wrapped): + """ + Run a test case via asyncio. + + Example: + + >>> @async_test + ... async def lemon_wins(): + ... assert True + """ + + @functools.wraps(wrapped) + def wrapper(*args, **kwargs): + return asyncio.run(wrapped(*args, **kwargs)) + return wrapper diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 000000000..ce69ef187 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,106 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from bot import api +from tests.helpers import async_test + + +def test_loop_is_not_running_by_default(): + assert not api.loop_is_running() + + +@async_test +async def test_loop_is_running_in_async_test(): + assert api.loop_is_running() + + +@pytest.fixture() +def error_api_response(): + response = MagicMock() + response.status = 999 + return response + + +@pytest.fixture() +def api_log_handler(): + return api.APILoggingHandler(None) + + +@pytest.fixture() +def debug_log_record(): + return logging.LogRecord( + name='my.logger', level=logging.DEBUG, + pathname='my/logger.py', lineno=666, + msg="Lemon wins", args=(), + exc_info=None + ) + + +def test_response_code_error_default_initialization(error_api_response): + error = api.ResponseCodeError(response=error_api_response) + assert error.status is error_api_response.status + assert not error.response_json + assert not error.response_text + assert error.response is error_api_response + + +def test_response_code_error_default_representation(error_api_response): + error = api.ResponseCodeError(response=error_api_response) + assert str(error) == f"Status: {error_api_response.status} Response: " + + +def test_response_code_error_representation_with_nonempty_response_json(error_api_response): + error = api.ResponseCodeError( + response=error_api_response, + response_json={'hello': 'world'} + ) + assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" + + +def test_response_code_error_representation_with_nonempty_response_text(error_api_response): + error = api.ResponseCodeError( + response=error_api_response, + response_text='Lemon will eat your soul' + ) + assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" + + +@patch('bot.api.APILoggingHandler.ship_off') +def test_emit_appends_to_queue_with_stopped_event_loop( + ship_off_patch, api_log_handler, debug_log_record +): + # This is a coroutine so returns something we should await, + # but asyncio complains about that. To ease testing, we patch + # `ship_off` to just return a regular value instead. + ship_off_patch.return_value = 42 + api_log_handler.emit(debug_log_record) + + assert api_log_handler.queue == [42] + + +def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): + debug_log_record.levelno = logging.DEBUG - 5 + api_log_handler.emit(debug_log_record) + assert not api_log_handler.queue + + +def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): + api_log_handler.schedule_queued_tasks() + # Logs when tasks are scheduled + assert not caplog.records + + +@patch('asyncio.create_task') +def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): + api_log_handler.queue = [555] + api_log_handler.schedule_queued_tasks() + assert not api_log_handler.queue + create_task_patch.assert_called_once_with(555) + + [record] = caplog.records + assert record.message == "Scheduled 1 pending logging tasks." + assert record.levelno == logging.DEBUG + assert record.name == 'bot.api' + assert record.__dict__['via_handler'] -- cgit v1.2.3 From 3ab9c2f8d26e023dc56541c00073deaa39293592 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Wed, 18 Sep 2019 01:29:42 +1000 Subject: Recombine import groups. --- tests/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index f8fbb5e60..2908294f7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,5 @@ import asyncio import functools - from unittest.mock import MagicMock -- cgit v1.2.3 From cfb6b634f8330f29576a3e7afc9b1d199c7651bf Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 17 Sep 2019 18:33:12 +0200 Subject: Enhance off-topic names search feature https://github.com/python-discord/bot/issues/435 This commit is meant to enhance the search feature in three separate, but related ways: 1. By changing the type annotation of the query to OffTopicName, we will use the same character translation table for the query as we did when storing the off-topic name, leading to better matches. 2. By adding a membership test, `query in name`, we are better able to search for off-topic names using a substring. 3. Given point 1 and point 2, we can increase the cut-off value we use for `difflib.get_close_matches` so we reduce the number of matches that bear little resemblance to the query in our human eyes. This commit closes #435 --- bot/cogs/off_topic_names.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index cb8a03374..1f6ed80b5 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -144,20 +144,21 @@ class OffTopicNames(Cog): @otname_group.command(name='search', aliases=('s',)) @with_role(*MODERATION_ROLES) - async def search_command(self, ctx, *, query: str): + async def search_command(self, ctx, *, query: OffTopicName): """ Search for an off-topic name. """ result = await self.bot.api_client.get('bot/off-topic-channel-names') - matches = difflib.get_close_matches(query, result, n=10, cutoff=0.35) - lines = sorted(f"• {name}" for name in matches) + in_matches = {name for name in result if query in name} + close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) + lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) embed = Embed( title=f"Query results", colour=Colour.blue() ) - if matches: + if lines: await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) else: embed.description = "Nothing found." -- cgit v1.2.3 From 6815bca50816678f6069516ef564ecb4a4d01d83 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 18 Sep 2019 08:55:56 +0200 Subject: Escape markdown in codeblock suggestion embed https://github.com/python-discord/bot/issues/434 If the content we prepare for the codeblock suggestion embed defined in the `bot` cog contains markdown characters (e.g., `__`, `**`), this will cause Discord to apply markdown, since both the codeblock syntax example as well as the codeblock result example will contain the characters, making it a matched formatting pair. This will hide those characters, which often have a function in the code, and break the example of a formatted codeblock. To deal with that, I've added a regex substitution that substitutes markdown characters by an escaped version of that markdown character. Example: `'__hello__'` will become `'\_\_hello\_\_'` I've added this substitution to both paths in the `on_message` event listener, since we can't substitute at an earlier place due to it generating `SyntaxErrors` with the `ast.parse` check. This closes #434 --- bot/cogs/bot.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e88b1d9b5..577865a65 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -6,15 +6,14 @@ import time from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog, Context, command, group -from bot.constants import ( - Channels, Guild, MODERATION_ROLES, - Roles, URLs, -) +from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) +RE_MARKDOWN = re.compile(r'([*_~`|>])') + class Bot(Cog): """ @@ -255,7 +254,7 @@ class Bot(Cog): if parse_codeblock: on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown: + if not on_cooldown or DEBUG_MODE: try: if self.has_bad_ticks(msg): ticks = msg.content[:3] @@ -280,13 +279,14 @@ class Bot(Cog): current_length += len(line) lines_walked += 1 content = content[:current_length] + "#..." - + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) howto = ( "It looks like you are trying to paste code into this channel.\n\n" "You seem to be using the wrong symbols to indicate where the codeblock should start. " f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" f"```python\n{content}\n```" ) @@ -322,13 +322,15 @@ class Bot(Cog): lines_walked += 1 content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) howto += ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " "syntax highlighting. Please use these whenever you paste code, as this " "helps improve the legibility and makes it easier for us to help you.\n\n" f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" f"```python\n{content}\n```" ) -- cgit v1.2.3 From 17d6d80a78b8ab66d2f82060cb8334ccd8c62bca Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 18 Sep 2019 10:54:49 -0500 Subject: Infraction Date Humanization - Changed the date returned on infraction searches to use the `"%c"` strftime format instead of the standard ISO format. Signed-off-by: Daniel Brown --- bot/cogs/moderation.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index c631dd69d..898f053f5 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1260,6 +1260,11 @@ class Moderation(Scheduler, Cog): active = infraction_object["active"] user_id = infraction_object["user"] hidden = infraction_object["hidden"] + created = datetime.fromisoformat(infraction_object["inserted_at"].strftime("%c")) + if not infraction_object["expires_at"]: + expires = "*Permanent*" + else: + expires = datetime.fromisoformat(infraction_object["expires_at"].strftime("%c")) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} @@ -1268,8 +1273,8 @@ class Moderation(Scheduler, Cog): Type: **{infraction_object["type"]}** Shadow: {hidden} Reason: {infraction_object["reason"] or "*None*"} - Created: {infraction_object["inserted_at"]} - Expires: {infraction_object["expires_at"] or "*Permanent*"} + Created: {created} + Expires: {expires} Actor: {actor.mention if actor else actor_id} ID: `{infraction_object["id"]}` {"**===============**" if active else "==============="} -- cgit v1.2.3 From f846416be2cd2fe05d0689f11bb30d3a67b4a571 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 18 Sep 2019 15:02:51 -0500 Subject: Infraction Date Humanization - Changed to use the format `"%Y-%m-%d %H:%M"`, which will turn out looking like `2019-09-18 13:59` Signed-off-by: Daniel Brown --- bot/cogs/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 898f053f5..f16a13a3e 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1260,11 +1260,11 @@ class Moderation(Scheduler, Cog): active = infraction_object["active"] user_id = infraction_object["user"] hidden = infraction_object["hidden"] - created = datetime.fromisoformat(infraction_object["inserted_at"].strftime("%c")) + created = datetime.fromisoformat(infraction_object["inserted_at"].strftime("%Y-%m-%d %H:%M")) if not infraction_object["expires_at"]: expires = "*Permanent*" else: - expires = datetime.fromisoformat(infraction_object["expires_at"].strftime("%c")) + expires = datetime.fromisoformat(infraction_object["expires_at"].strftime("%Y-%m-%d %H:%M")) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From 58f7e94746de7394cef7d7b5a193d43740fbe49c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 22:10:09 +0200 Subject: Add basic tests for `bot.cogs.information`. --- tests/cogs/test_information.py | 163 +++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 32 ++++++++ 2 files changed, 195 insertions(+) create mode 100644 tests/cogs/test_information.py create mode 100644 tests/conftest.py diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py new file mode 100644 index 000000000..85b2d092e --- /dev/null +++ b/tests/cogs/test_information.py @@ -0,0 +1,163 @@ +import asyncio +import logging +import textwrap +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from discord import ( + CategoryChannel, + Colour, + TextChannel, + VoiceChannel, +) + +from bot.cogs import information +from bot.constants import Emojis +from bot.decorators import InChannelCheckFailure +from tests.helpers import AsyncMock + + +@pytest.fixture() +def cog(simple_bot): + return information.Information(simple_bot) + + +def role(name: str, id_: int): + r = MagicMock() + r.name = name + r.id = id_ + r.mention = f'&{name}' + return r + + +def member(status: str): + m = MagicMock() + m.status = status + return m + + +@pytest.fixture() +def ctx(moderator_role, simple_ctx): + simple_ctx.author.roles = [moderator_role] + simple_ctx.guild.created_at = datetime(2001, 1, 1) + simple_ctx.send = AsyncMock() + return simple_ctx + + +def test_roles_info_command(cog, ctx): + everyone_role = MagicMock() + everyone_role.name = '@everyone' # should be excluded in the output + ctx.author.roles.append(everyone_role) + ctx.guild.roles = ctx.author.roles + + cog.roles_info.can_run = AsyncMock() + cog.roles_info.can_run.return_value = True + + coroutine = cog.roles_info.callback(cog, ctx) + + assert asyncio.run(coroutine) is None # no rval + ctx.send.assert_called_once() + _, kwargs = ctx.send.call_args + embed = kwargs.pop('embed') + assert embed.title == "Role information" + assert embed.colour == Colour.blurple() + assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" + assert embed.footer.text == "Total roles: 1" + + +# There is no argument passed in here that we can use to test, +# so the return value would change constantly. +@patch('bot.cogs.information.time_since') +def test_server_info_command(time_since_patch, cog, ctx, moderator_role): + time_since_patch.return_value = '2 days ago' + + ctx.guild.created_at = datetime(2001, 1, 1) + ctx.guild.features = ('lemons', 'apples') + ctx.guild.region = 'The Moon' + ctx.guild.roles = [moderator_role] + ctx.guild.channels = [ + TextChannel( + state={}, + guild=ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + CategoryChannel( + state={}, + guild=ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + VoiceChannel( + state={}, + guild=ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ] + ctx.guild.members = [ + member('online'), member('online'), + member('idle'), + member('dnd'), member('dnd'), member('dnd'), member('dnd'), + member('offline'), member('offline'), member('offline') + ] + ctx.guild.member_count = 1_234 + ctx.guild.icon_url = 'a-lemon.png' + + coroutine = cog.server_info.callback(cog, ctx) + assert asyncio.run(coroutine) is None # no rval + + time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') + _, kwargs = ctx.send.call_args + embed = kwargs.pop('embed') + assert embed.colour == Colour.blurple() + assert embed.description == textwrap.dedent(f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {ctx.guild.region} + Features: {', '.join(ctx.guild.features)} + + **Counts** + Members: {ctx.guild.member_count:,} + Roles: {len(ctx.guild.roles)} + Text: 1 + Voice: 1 + Channel categories: 1 + + **Members** + {Emojis.status_online} 2 + {Emojis.status_idle} 1 + {Emojis.status_dnd} 4 + {Emojis.status_offline} 3 + """) + assert embed.thumbnail.url == 'a-lemon.png' + + +def test_user_info_on_other_users_from_non_moderator(ctx, cog): + ctx.author = MagicMock() + ctx.author.__eq__.return_value = False + ctx.author.roles = [] + coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args + + assert asyncio.run(coroutine) is None # no rval + ctx.send.assert_called_once_with( + "You may not use this command on users other than yourself." + ) + + +def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): + ctx.author = MagicMock() + ctx.author.__eq__.return_value = False + ctx.author.roles = [] + + coroutine = cog.user_info.callback(cog, ctx) + message = 'Sorry, but you may only use this command within <#267659945086812160>.' + with pytest.raises(InChannelCheckFailure, match=message): + assert asyncio.run(coroutine) is None # no rval + + +def test_setup(simple_bot, caplog): + information.setup(simple_bot) + simple_bot.add_cog.assert_called_once() + [record] = caplog.records + + assert record.message == "Cog loaded: Information" + assert record.levelno == logging.INFO diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d3de4484d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock + +import pytest + +from bot.constants import Roles +from tests.helpers import AsyncMock + + +@pytest.fixture() +def moderator_role(): + mock = MagicMock() + mock.id = Roles.moderator + mock.name = 'Moderator' + mock.mention = f'&{mock.name}' + return mock + + +@pytest.fixture() +def simple_bot(): + mock = MagicMock() + mock._before_invoke = AsyncMock() + mock._after_invoke = AsyncMock() + mock.can_run = AsyncMock() + mock.can_run.return_value = True + return mock + + +@pytest.fixture() +def simple_ctx(simple_bot): + mock = MagicMock() + mock.bot = simple_bot + return mock -- cgit v1.2.3 From cb2d892c37f68aa72b3078905d26030ea50368f2 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 18 Sep 2019 15:30:46 -0500 Subject: Infraction Date Humanization - Changed the if statement to use `is None` for code clarity. Signed-off-by: Daniel Brown --- bot/cogs/moderation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index f16a13a3e..2c7253c7b 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1261,7 +1261,7 @@ class Moderation(Scheduler, Cog): user_id = infraction_object["user"] hidden = infraction_object["hidden"] created = datetime.fromisoformat(infraction_object["inserted_at"].strftime("%Y-%m-%d %H:%M")) - if not infraction_object["expires_at"]: + if infraction_object["expires_at"] is None: expires = "*Permanent*" else: expires = datetime.fromisoformat(infraction_object["expires_at"].strftime("%Y-%m-%d %H:%M")) -- cgit v1.2.3 From 5640a23b4b2af23a4a767d506105730ca06f5d0b Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Wed, 18 Sep 2019 16:01:03 -0500 Subject: Infraction Date Humanization - Corrected an error that would have made the code bug out. Moved a closing parentheses to the correct spot. Signed-off-by: Daniel Brown --- bot/cogs/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 2c7253c7b..2d6c40a46 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1260,11 +1260,11 @@ class Moderation(Scheduler, Cog): active = infraction_object["active"] user_id = infraction_object["user"] hidden = infraction_object["hidden"] - created = datetime.fromisoformat(infraction_object["inserted_at"].strftime("%Y-%m-%d %H:%M")) + created = datetime.fromisoformat(infraction_object["inserted_at"]).strftime("%Y-%m-%d %H:%M") if infraction_object["expires_at"] is None: expires = "*Permanent*" else: - expires = datetime.fromisoformat(infraction_object["expires_at"].strftime("%Y-%m-%d %H:%M")) + expires = datetime.fromisoformat(infraction_object["expires_at"]).strftime("%Y-%m-%d %H:%M") lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From 7f70a4eb064458b56892c046a34cce598a2053e2 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 17:18:00 +0200 Subject: Add tests for `bot.rules.attachments`. This also fixes an issue with the `attachments` rule not respecting the most recent message sent by a user. --- bot/rules/attachments.py | 4 ++-- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/rules/__init__.py create mode 100644 tests/rules/test_attachments.py diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index 47b927101..80a15d440 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -11,14 +11,14 @@ async def apply( config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - relevant_messages = tuple( + relevant_messages = [last_message] + [ msg for msg in recent_messages if ( msg.author == last_message.author and len(msg.attachments) > 0 ) - ) + ] total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages) if total_recent_attachments > config['max']: diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..6f025b3cb --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, List + +import pytest + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int): + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +@pytest.mark.parametrize( + 'messages', + ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) +) +def test_allows_messages_without_too_many_attachments(messages): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) is None + + +@pytest.mark.parametrize( + ('messages', 'relevant_messages', 'total'), + ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) +) +def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) == ( + f"sent {total} attachments in 5s", + ('lemon',), + relevant_messages + ) -- cgit v1.2.3 From e70c96248bd7b548412811a4f1ffe88bed41f815 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 19 Sep 2019 12:37:47 +0200 Subject: Fix date formatting bug in infraction search The infraction search feature did not work because of a small bug with the date formatting: `datetime.fromisoformat` does not like the Z at the end of the datestring the database sends back, so we need to chop it off. I've applied the same method for doing that as already in use in other parts of the bot codebase. --- bot/cogs/moderation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 2d6c40a46..fea86c33e 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1260,11 +1260,11 @@ class Moderation(Scheduler, Cog): active = infraction_object["active"] user_id = infraction_object["user"] hidden = infraction_object["hidden"] - created = datetime.fromisoformat(infraction_object["inserted_at"]).strftime("%Y-%m-%d %H:%M") + created = datetime.fromisoformat(infraction_object["inserted_at"][:-1]).strftime("%Y-%m-%d %H:%M") if infraction_object["expires_at"] is None: expires = "*Permanent*" else: - expires = datetime.fromisoformat(infraction_object["expires_at"]).strftime("%Y-%m-%d %H:%M") + expires = datetime.fromisoformat(infraction_object["expires_at"][:-1]).strftime("%Y-%m-%d %H:%M") lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} -- cgit v1.2.3 From 232c7e3eb93bfec5e33058b638cc93ee423a4bec Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 19 Sep 2019 05:45:39 -0700 Subject: Apply suggestions from code review Fix decorator return hints Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/decorators.py | 10 +++++----- bot/pagination.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index ace9346f0..70482bfa4 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,7 +3,7 @@ import random from asyncio import Lock, sleep from contextlib import suppress from functools import wraps -from typing import Callable, Container, Union +from typing import Any, Callable, Container, Optional, Union from weakref import WeakValueDictionary from discord import Colour, Embed @@ -66,7 +66,7 @@ def without_role(*role_ids: int) -> Callable: return commands.check(predicate) -def locked() -> Union[Callable, None]: +def locked() -> Callable: """ Allows the user to only run one instance of the decorated command at a time. @@ -74,11 +74,11 @@ def locked() -> Union[Callable, None]: This decorator has to go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Union[Callable, None]: + def wrap(func: Callable) -> Callable: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Union[Callable, None]: + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Optional[Any]: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() @@ -106,7 +106,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non """ def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Callable: + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> None: if ctx.channel.id == destination_channel: log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting") return await func(self, ctx, *args, **kwargs) diff --git a/bot/pagination.py b/bot/pagination.py index 10ef6c407..afdd6b905 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -303,7 +303,7 @@ class ImagePaginator(Paginator): @classmethod async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, prefix: str = "", suffix: str = "", timeout: int = 300, - exception_on_empty_embed: bool = False): + exception_on_empty_embed: bool = False) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs. -- cgit v1.2.3 From f30dcc476a4233eb4785ab0c6996225f3c440caf Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 19 Sep 2019 05:49:16 -0700 Subject: Update paginator defs for correct Optional return Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/decorators.py | 2 +- bot/pagination.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 70482bfa4..c953264b5 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,7 +3,7 @@ import random from asyncio import Lock, sleep from contextlib import suppress from functools import wraps -from typing import Any, Callable, Container, Optional, Union +from typing import Any, Callable, Container, Optional from weakref import WeakValueDictionary from discord import Colour, Embed diff --git a/bot/pagination.py b/bot/pagination.py index afdd6b905..473158b3f 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -2,7 +2,7 @@ import asyncio import logging from typing import Iterable, List, Optional, Tuple -from discord import Embed, Member, Reaction +from discord import Embed, Member, Message, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator @@ -86,10 +86,12 @@ class LinePaginator(Paginator): self._count += 1 @classmethod - async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, - empty: bool = True, restrict_to_user: User = None, timeout: int = 300, - footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False) -> None: + async def paginate( + cls, lines: Iterable[str], ctx: Context, embed: Embed, + prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, + empty: bool = True, restrict_to_user: User = None, timeout: int = 300, + footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False + ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -301,9 +303,11 @@ class ImagePaginator(Paginator): self.images.append(image) @classmethod - async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", timeout: int = 300, - exception_on_empty_embed: bool = False) -> Optional[Message]: + async def paginate( + cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, + prefix: str = "", suffix: str = "", timeout: int = 300, + exception_on_empty_embed: bool = False + ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of title/image pairs. -- cgit v1.2.3 From 67c84ade355802e00691f6fea712711bf41e5892 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 19 Sep 2019 06:02:07 -0700 Subject: Use Optional typing shorthand rather than Union w/None --- bot/cogs/bot.py | 4 ++-- bot/cogs/eval.py | 4 ++-- bot/cogs/security.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 585be5a42..7be04693c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import Optional, Tuple, Union +from typing import Optional, Tuple from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog, Context, command, group @@ -82,7 +82,7 @@ class Bot(Cog): embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Union[Tuple[Tuple[str, Optional[str]], str], None]: + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, Optional[str]], str]]: """ Strip msg in order to find Python code. diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 578a494e7..2ae543d83 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -6,7 +6,7 @@ import re import textwrap import traceback from io import StringIO -from typing import Any, Tuple, Union +from typing import Any, Optional, Tuple import discord from discord.ext.commands import Bot, Cog, group @@ -29,7 +29,7 @@ class CodeEval(Cog): self.interpreter = Interpreter(bot) - def _format(self, inp: str, out: Any) -> Tuple[str, Union[discord.embed, None]]: + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.embed]]: """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out diff --git a/bot/cogs/security.py b/bot/cogs/security.py index c2c6356d3..a04c18289 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,5 +1,5 @@ import logging -from typing import Union +from typing import Optional from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage @@ -18,7 +18,7 @@ class Security(Cog): """Check if Context instance author is not a bot.""" return not ctx.author.bot - def check_on_guild(self, ctx: Context) -> Union[bool, None]: + def check_on_guild(self, ctx: Context) -> Optional[bool]: """Check if Context instance has a guild attribute.""" if ctx.guild is None: raise NoPrivateMessage("This command cannot be used in private messages.") -- cgit v1.2.3 From ad65e0d36ce24444795fd80202f4b76d7e6946f5 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 19 Sep 2019 06:12:11 -0700 Subject: Replace incorrect None returns with Optional in certain commands --- bot/cogs/cogs.py | 5 +++-- bot/cogs/eval.py | 2 +- bot/cogs/reddit.py | 5 +++-- bot/cogs/reminders.py | 7 ++++--- bot/cogs/snekbox.py | 3 ++- bot/cogs/superstarify/__init__.py | 7 ++++--- bot/cogs/utils.py | 7 ++++--- bot/cogs/verification.py | 5 +++-- 8 files changed, 24 insertions(+), 17 deletions(-) diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index fcb987a07..0caf503a8 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -1,7 +1,8 @@ import logging import os +from typing import Optional -from discord import Colour, Embed +from discord import Colour, Embed, Message from discord.ext.commands import Bot, Cog, Context, group from bot.constants import ( @@ -145,7 +146,7 @@ class Cogs(Cog): @cogs_group.command(name='reload', aliases=('r',)) @with_role(*MODERATION_ROLES, Roles.core_developer) - async def reload_command(self, ctx: Context, cog: str) -> None: + async def reload_command(self, ctx: Context, cog: str) -> Optional[Message]: """ Reload an unloaded cog, given the module containing it. diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 2ae543d83..45638d295 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -123,7 +123,7 @@ class CodeEval(Cog): return res # Return (text, embed) - async def _eval(self, ctx: discord.Context, code: str) -> None: + async def _eval(self, ctx: discord.Context, code: str) -> Optional[discord.Message]: """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index fa66660e2..2bc6fa58e 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -3,8 +3,9 @@ import logging import random import textwrap from datetime import datetime, timedelta +from typing import Optional -from discord import Colour, Embed, TextChannel +from discord import Colour, Embed, Message, TextChannel from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES @@ -54,7 +55,7 @@ class Reddit(Cog): async def send_top_posts( self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" - ) -> None: + ) -> Optional[Message]: """Create an embed for the top posts, then send it in a given TextChannel.""" # Create the new spicy embed. embed = Embed() diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ff0a6eb1a..a96684db3 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,9 +4,10 @@ import random import textwrap from datetime import datetime from operator import itemgetter +from typing import Optional from dateutil.relativedelta import relativedelta -from discord import Colour, Embed +from discord import Colour, Embed, Message from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES @@ -122,7 +123,7 @@ class Reminders(Scheduler, Cog): await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None: + async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> Optional[Message]: """ Set yourself a simple reminder. @@ -178,7 +179,7 @@ class Reminders(Scheduler, Cog): self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> None: + async def list_reminders(self, ctx: Context) -> Optional[Message]: """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 927afe51b..3e6f9299a 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,6 +5,7 @@ import textwrap from signal import Signals from typing import Optional, Tuple +from discord import Message from discord.ext.commands import Bot, Cog, Context, command, guild_only from bot.constants import Channels, STAFF_ROLES, URLs @@ -167,7 +168,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None) -> None: + async def eval_command(self, ctx: Context, *, code: str = None) -> Optional[Message]: """ Run Python code and get the results. diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 7a29acdb8..6278beaa3 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -1,8 +1,9 @@ import logging import random from datetime import datetime +from typing import Optional -from discord import Colour, Embed, Member +from discord import Colour, Embed, Member, Message from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command @@ -154,7 +155,7 @@ class Superstarify(Cog): @with_role(*MODERATION_ROLES) async def superstarify( self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None - ) -> None: + ) -> Optional[Message]: """ Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. @@ -224,7 +225,7 @@ class Superstarify(Cog): @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(*MODERATION_ROLES) - async def unsuperstarify(self, ctx: Context, member: Member) -> None: + async def unsuperstarify(self, ctx: Context, member: Member) -> Optional[Message]: """This command will the superstarify entry from our database, allowing the user to change their nickname.""" log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 1539851ea..e27559efe 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -3,8 +3,9 @@ import re import unicodedata from email.parser import HeaderParser from io import StringIO +from typing import Optional -from discord import Colour, Embed +from discord import Colour, Embed, Message from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, STAFF_ROLES @@ -23,7 +24,7 @@ class Utils(Cog): self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> None: + async def pep_command(self, ctx: Context, pep_number: str) -> Optional[Message]: """Fetches information about a PEP and sends it to the channel.""" if pep_number.isdigit(): pep_number = int(pep_number) @@ -84,7 +85,7 @@ class Utils(Cog): @command() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def charinfo(self, ctx: Context, *, characters: str) -> None: + async def charinfo(self, ctx: Context, *, characters: str) -> Optional[Message]: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 6446dc80b..a437992ee 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from discord import Message, NotFound, Object from discord.ext.commands import Bot, Cog, Context, command @@ -95,7 +96,7 @@ class Verification(Cog): @command(name='subscribe') @in_channel(Channels.bot) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + async def subscribe_command(self, ctx: Context, *_) -> Optional[Message]: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False @@ -120,7 +121,7 @@ class Verification(Cog): @command(name='unsubscribe') @in_channel(Channels.bot) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + async def unsubscribe_command(self, ctx: Context, *_) -> Optional[Message]: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False -- cgit v1.2.3 From 515d304f4a3a4a80630769d90a3ba8d912e8037b Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 19 Sep 2019 08:30:19 -0700 Subject: Apply suggestions from code review Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/doc.py | 4 ++-- bot/cogs/modlog.py | 4 ++-- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reddit.py | 6 +++--- bot/cogs/security.py | 2 +- bot/cogs/utils.py | 2 +- bot/cogs/wolfram.py | 2 +- bot/decorators.py | 2 +- bot/utils/__init__.py | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 54a3172b8..2dcbad6e0 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -4,7 +4,7 @@ import logging import re import textwrap from collections import OrderedDict -from typing import Callable, Optional, Tuple +from typing import Any, Callable, Optional, Tuple import discord from bs4 import BeautifulSoup @@ -42,7 +42,7 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: def decorator(function: Callable) -> Callable: """Define the async_cache decorator.""" @functools.wraps(function) - async def wrapper(*args) -> OrderedDict: + async def wrapper(*args) -> Any: """Decorator wrapper for the caching logic.""" key = ':'.join(args[arg_offset:]) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index d76804c34..2eb7b1eab 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -38,7 +38,7 @@ class ModLog(Cog, name="ModLog"): self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: List[Message], actor_id: int) -> Optional[str]: + async def upload_log(self, messages: List[Message], actor_id: int) -> str: """ Uploads the log data to the database via an API endpoint for uploading logs. @@ -87,7 +87,7 @@ class ModLog(Cog, name="ModLog"): additional_embeds_msg: Optional[str] = None, timestamp_override: Optional[datetime] = None, footer: Optional[str] = None, - ) -> Optional[Message]: + ) -> Context: """Generate log embed and send to logging channel.""" embed = Embed(description=text) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index a81b783d6..53e693b89 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -19,7 +19,7 @@ class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" @staticmethod - async def convert(ctx: Context, argument: str) -> None: + async def convert(ctx: Context, argument: str) -> str: """Attempt to replace any invalid characters with their approximate unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 2bc6fa58e..7bd11fb1b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -3,7 +3,7 @@ import logging import random import textwrap from datetime import datetime, timedelta -from typing import Optional +from typing import List, Optional from discord import Colour, Embed, Message, TextChannel from discord.ext.commands import Bot, Cog, Context, group @@ -33,7 +33,7 @@ class Reddit(Cog): self.new_posts_task = None self.top_weekly_posts_task = None - async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> dict: + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. if not 25 >= amount > 0: @@ -55,7 +55,7 @@ class Reddit(Cog): async def send_top_posts( self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" - ) -> Optional[Message]: + ) -> Message: """Create an embed for the top posts, then send it in a given TextChannel.""" # Create the new spicy embed. embed = Embed() diff --git a/bot/cogs/security.py b/bot/cogs/security.py index a04c18289..7274b8033 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -18,7 +18,7 @@ class Security(Cog): """Check if Context instance author is not a bot.""" return not ctx.author.bot - def check_on_guild(self, ctx: Context) -> Optional[bool]: + def check_on_guild(self, ctx: Context) -> bool: """Check if Context instance has a guild attribute.""" if ctx.guild is None: raise NoPrivateMessage("This command cannot be used in private messages.") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index e27559efe..965d532a0 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -24,7 +24,7 @@ class Utils(Cog): self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> Optional[Message]: + async def pep_command(self, ctx: Context, pep_number: str) -> None: """Fetches information about a PEP and sends it to the channel.""" if pep_number.isdigit(): pep_number = int(pep_number) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 1aa656a4b..ea9307352 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -52,7 +52,7 @@ async def send_embed( await ctx.send(embed=embed, file=f) -def custom_cooldown(*ignore: List[int]) -> check: +def custom_cooldown(*ignore: List[int]) -> Callable: """ Implement per-user and per-guild cooldowns for requests to the Wolfram API. diff --git a/bot/decorators.py b/bot/decorators.py index c953264b5..9a14d8df4 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -106,7 +106,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non """ def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> None: + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: if ctx.channel.id == destination_channel: log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting") return await func(self, ctx, *args, **kwargs) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 618b90748..8184be824 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -18,7 +18,7 @@ class CaseInsensitiveDict(dict): """ @classmethod - def _k(cls, key: Hashable) -> Any: + def _k(cls, key: Hashable) -> Hashable: """Return lowered key if a string-like is passed, otherwise pass key straight through.""" return key.lower() if isinstance(key, str) else key -- cgit v1.2.3 From d43c9c0d309126f0ea391a36b95fb264f1c69aa2 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 19 Sep 2019 08:37:23 -0700 Subject: Update imports for applied code review suggestions --- bot/cogs/modlog.py | 2 +- bot/cogs/reddit.py | 2 +- bot/cogs/security.py | 1 - bot/cogs/wolfram.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 2eb7b1eab..15c293d92 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -11,7 +11,7 @@ from discord import ( RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel ) from discord.abc import GuildChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Bot, Cog, Context from bot.constants import ( Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7bd11fb1b..63a57c5c6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -3,7 +3,7 @@ import logging import random import textwrap from datetime import datetime, timedelta -from typing import List, Optional +from typing import List from discord import Colour, Embed, Message, TextChannel from discord.ext.commands import Bot, Cog, Context, group diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 7274b8033..4960aa896 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index ea9307352..7c218eb8c 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -1,6 +1,6 @@ import logging from io import BytesIO -from typing import List, Optional, Tuple +from typing import Callable, List, Optional, Tuple from urllib import parse import discord -- cgit v1.2.3 From fe071ca9d59e3a92971e7e449d60b3e59f0918e2 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 19 Sep 2019 10:45:29 -0700 Subject: Apply suggestions from code review Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/eval.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 45638d295..017619315 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -9,7 +9,7 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Bot, Cog, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Roles from bot.decorators import with_role @@ -29,7 +29,7 @@ class CodeEval(Cog): self.interpreter = Interpreter(bot) - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.embed]]: + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out @@ -174,14 +174,14 @@ async def func(): # (None,) -> Any @group(name='internal', aliases=('int',)) @with_role(Roles.owner, Roles.admin) - async def internal_group(self, ctx: discord.Context) -> None: + async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin, Roles.owner) - async def eval(self, ctx: discord.Context, *, code: str) -> None: + async def eval(self, ctx: Context, *, code: str) -> None: """Run eval in a REPL-like format.""" code = code.strip("`") if re.match('py(thon)?\n', code): -- cgit v1.2.3 From 39e3e1a717cd19a5ef777409f9cafefc3ab18c63 Mon Sep 17 00:00:00 2001 From: sco1 Date: Thu, 19 Sep 2019 11:09:24 -0700 Subject: Apply suggestions from code review Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/eval.py | 2 +- bot/cogs/help.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 017619315..9ce854f2c 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -123,7 +123,7 @@ class CodeEval(Cog): return res # Return (text, embed) - async def _eval(self, ctx: discord.Context, code: str) -> Optional[discord.Message]: + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 63421c8a7..0f2196e46 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -78,7 +78,7 @@ class HelpSession: Parameters ---------- - ctx: :class:`discord.Context` + ctx: :class:`discord.ext.commands.Context` The context of the invoked help command. *command: str A variable argument of the command being queried. -- cgit v1.2.3 From f48a587b8c889ca64947c36955e12e7ffd963093 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 22 Sep 2019 21:27:36 +0100 Subject: Return the message to send --- bot/cogs/defcon.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index d29f8f40d..cf47697ff 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -254,6 +254,8 @@ class Defcon(Cog): f"```py\n{e}\n```" ) + return msg + async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None: """ Send log message for DEFCON action. -- cgit v1.2.3 From bf7d0585d943fa4bfb7b3720eb601c83fe47f4b4 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 22 Sep 2019 19:10:34 -0400 Subject: Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Leon Sandøy --- bot/cogs/superstarify/__init__.py | 2 +- bot/interpreter.py | 2 +- bot/utils/messages.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index 6278beaa3..71dad0036 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -226,7 +226,7 @@ class Superstarify(Cog): @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(*MODERATION_ROLES) async def unsuperstarify(self, ctx: Context, member: Member) -> Optional[Message]: - """This command will the superstarify entry from our database, allowing the user to change their nickname.""" + """This command will remove the superstarify entry from our database, allowing the user to change their nickname.""" log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") embed = Embed() diff --git a/bot/interpreter.py b/bot/interpreter.py index 6ea49e026..a42b45a2d 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -14,7 +14,7 @@ class Interpreter(InteractiveInterpreter): """ Subclass InteractiveInterpreter to specify custom run functionality. - Helper class for internal eval + Helper class for internal eval. """ write_callable = None diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 5058d42fc..549b33ca6 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -21,7 +21,7 @@ async def wait_for_deletion( client: Optional[Client] = None ) -> None: """ - Waits for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given `deletion_emojis` to the message in the given `context` -- cgit v1.2.3 From f83327bef045713a4cbfd1027ce6739e0ffcf51e Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 22 Sep 2019 19:47:42 -0400 Subject: Apply suggestions from code review Co-Authored-By: Mark --- bot/api.py | 15 ++++++++------- bot/cogs/bot.py | 4 ++-- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/filtering.py | 2 +- bot/cogs/modlog.py | 2 +- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reminders.py | 8 ++++---- bot/cogs/security.py | 4 ++-- bot/cogs/site.py | 2 +- bot/cogs/sync/syncers.py | 2 +- bot/cogs/verification.py | 1 - bot/cogs/watchchannels/watchchannel.py | 2 +- bot/decorators.py | 2 +- bot/pagination.py | 2 +- bot/utils/scheduling.py | 2 +- bot/utils/time.py | 14 ++++++++++++-- 17 files changed, 39 insertions(+), 29 deletions(-) diff --git a/bot/api.py b/bot/api.py index 5ab554052..3fca5db57 100644 --- a/bot/api.py +++ b/bot/api.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) class ResponseCodeError(ValueError): - """Exception representing a non-OK response code.""" + """Raised when a non-OK HTTP response is received.""" def __init__( self, @@ -97,10 +97,8 @@ def loop_is_running() -> bool: Determine if there is a running asyncio event loop. This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`), - which is currently not provided by asyncio + which is currently not provided by asyncio. """ - # asyncio does not have a way to say "call this when the event - # loop is running", see e.g. `callWhenRunning` from twisted. try: asyncio.get_running_loop() @@ -140,10 +138,13 @@ class APILoggingHandler(logging.StreamHandler): def emit(self, record: logging.LogRecord) -> None: """ Determine if a log record should be shipped to the logging API. + + If the asyncio event loop is not yet running, log records will instead be put in a queue + which will be consumed once the event loop is running. The following two conditions are set: - 1. Do not log anything below DEBUG - 2. Ignore log records from the logging handler + 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level) + 2. Ignore log records originating from this logging handler itself to prevent infinite recursion """ # Two checks are performed here: if ( @@ -176,7 +177,7 @@ class APILoggingHandler(logging.StreamHandler): self.schedule_queued_tasks() def schedule_queued_tasks(self) -> None: - """Logging task scheduler.""" + """Consume the queue and schedule the logging of each queued record.""" for task in self.queue: asyncio.create_task(task) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 7be04693c..324d2ccd3 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -82,7 +82,7 @@ class Bot(Cog): embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, Optional[str]], str]]: + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: """ Strip msg in order to find Python code. @@ -196,7 +196,7 @@ class Bot(Cog): Tries to strip out REPL Python code out of msg and returns the stripped msg. - Returns a second boolean output if REPL code was found in the input msg. + Returns True for the boolean if REPL code was found in the input msg. """ final = "" for line in msg.splitlines(keepends=True): diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index da1ae8b9b..1c0c9a7a8 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -43,7 +43,7 @@ class Clean(Cog): ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: - """Returns true if the message was sent by a bot.""" + """Return True if the message was sent by a bot.""" return message.author.bot def predicate_specific_user(message: Message) -> bool: diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 9d530af64..936147c8f 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -69,7 +69,7 @@ class Defcon(Cog): @Cog.listener() async def on_member_join(self, member: Member) -> None: - """If DEFON is enabled, check newly joining users to see if they meet the account age threshold.""" + """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" if self.enabled and self.days.days > 0: now = datetime.utcnow() diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 2eb53e61b..9cd1b7203 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -104,7 +104,7 @@ class Filtering(Cog): """ Invoke message filter for message edits. - If there have been multiple edits, calculate the time delta from the previous edit + If there have been multiple edits, calculate the time delta from the previous edit. """ if not before.edited_at: delta = relativedelta(after.edited_at, before.created_at).microseconds diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 15c293d92..68424d268 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -67,7 +67,7 @@ class ModLog(Cog, name="ModLog"): return f"{URLs.site_logs_view}/{response['id']}" def ignore(self, event: Event, *items: int) -> None: - """Add event to ignored events to suppress log emitting.""" + """Add event to ignored events to suppress log emission.""" for item in items: if item not in self._ignored[event]: self._ignored[event].append(item) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 53e693b89..5a59dc663 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -20,7 +20,7 @@ class OffTopicName(Converter): @staticmethod async def convert(ctx: Context, argument: str) -> str: - """Attempt to replace any invalid characters with their approximate unicode equivalent.""" + """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" if not (2 <= len(argument) <= 96): diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a96684db3..8460de91f 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -32,7 +32,7 @@ class Reminders(Scheduler, Cog): @Cog.listener() async def on_ready(self) -> None: - """Reschedule all current reminders.""" + """Get all current reminders from the API and reschedule them.""" response = await self.bot.api_client.get( 'bot/reminders', params={'active': 'true'} @@ -77,7 +77,7 @@ class Reminders(Scheduler, Cog): self.cancel_task(reminder_id) async def _delete_reminder(self, reminder_id: str) -> None: - """Delete a reminder from the database, given its ID, and cancels the running task.""" + """Delete a reminder from the database, given its ID, and cancel the running task.""" await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) # Now we can remove it from the schedule list @@ -239,7 +239,7 @@ class Reminders(Scheduler, Cog): @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate) -> None: """ - Edit one of your reminders' expiration. + Edit one of your reminder's expiration. Expiration is parsed per: http://strftime.org/ """ @@ -258,7 +258,7 @@ class Reminders(Scheduler, Cog): @edit_reminder_group.command(name="content", aliases=("reason",)) async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminders' content.""" + """Edit one of your reminder's content.""" # Send the request to update the reminder in the database reminder = await self.bot.api_client.patch( 'bot/reminders/' + str(id_), diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 4960aa896..316b33d6b 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -14,11 +14,11 @@ class Security(Cog): self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM def check_not_bot(self, ctx: Context) -> bool: - """Check if Context instance author is not a bot.""" + """Check if the context is a bot user.""" return not ctx.author.bot def check_on_guild(self, ctx: Context) -> bool: - """Check if Context instance has a guild attribute.""" + """Check if the context is in a guild.""" if ctx.guild is None: raise NoPrivateMessage("This command cannot be used in private messages.") return True diff --git a/bot/cogs/site.py b/bot/cogs/site.py index aadc9c632..4a423faa9 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -95,7 +95,7 @@ class Site(Cog): @site_group.command(aliases=['r', 'rule'], name='rules') @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) async def site_rules(self, ctx: Context, *rules: int) -> None: - """Provides a link to the `rules` endpoint of the website, or displays specific rule(s), if requested.""" + """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) rules_embed.url = f"{PAGES_URL}/rules" diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 689d3736e..2cc5a66e1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -166,7 +166,7 @@ def get_users_for_sync( async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: """ - Synchronize users found on the given `guild` with the ones on the API. + Synchronize users found in the given `guild` with the ones in the API. Arguments: bot (discord.ext.commands.Bot): diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a437992ee..0c4819f66 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -148,7 +148,6 @@ class Verification(Cog): async def cog_command_error(ctx: Context, error: Exception) -> None: """Check for & ignore any InChannelCheckFailure.""" if isinstance(error, InChannelCheckFailure): - # Do nothing; just ignore this error error.handled = True @staticmethod diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 1a3f5b18c..3af97e7fe 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -42,7 +42,7 @@ def proxy_user(user_id: str) -> Object: @dataclass class MessageHistory: - """Represent the watch channel's message history.""" + """Represents a watch channel's message history.""" last_author: Optional[int] = None last_channel: Optional[int] = None diff --git a/bot/decorators.py b/bot/decorators.py index 9a14d8df4..33a6bcadd 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) class InChannelCheckFailure(CheckFailure): - """In channel check failure exception.""" + """Raised when a check fails for a message being sent in a whitelisted channel.""" def __init__(self, *channels: int): self.channels = channels diff --git a/bot/pagination.py b/bot/pagination.py index 473158b3f..32e289e6a 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) class EmptyPaginatorEmbed(Exception): - """Empty paginator embed exception.""" + """Raised when attempting to paginate with empty contents.""" pass diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 3dec42480..08abd91d7 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -65,6 +65,6 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine def _silent_exception(future: asyncio.Future) -> None: - """Suppress future exception.""" + """Suppress future's exception.""" with contextlib.suppress(Exception): future.exception() diff --git a/bot/utils/time.py b/bot/utils/time.py index fe1c4e3ee..fe3ccc271 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -26,7 +26,12 @@ def _stringify_time_unit(value: int, unit: str) -> str: def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: - """Returns a human-readable version of the relativedelta.""" + """ + Returns a human-readable version of the relativedelta. + + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). + max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + """ units = ( ("years", delta.years), ("months", delta.months), @@ -62,7 +67,12 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: - """Takes a datetime and returns a human-readable string that describes how long ago that datetime was.""" + """ + Takes a datetime and returns a human-readable string that describes how long ago that datetime was. + + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). + max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + """ now = datetime.datetime.utcnow() delta = abs(relativedelta(now, past_datetime)) -- cgit v1.2.3 From 452618c9d84c1cdf50ec0df5287a1fc167c19707 Mon Sep 17 00:00:00 2001 From: sco1 Date: Sun, 22 Sep 2019 21:07:28 -0400 Subject: Apply suggestions from code review Co-Authored-By: Mark --- bot/api.py | 13 +-- bot/cogs/cogs.py | 8 +- bot/cogs/doc.py | 6 +- bot/cogs/error_handler.py | 24 +++++- bot/cogs/help.py | 145 ++++++++++----------------------- bot/cogs/off_topic_names.py | 9 +- bot/cogs/snekbox.py | 9 +- bot/cogs/superstarify/__init__.py | 15 ++-- bot/cogs/utils.py | 14 ++-- bot/cogs/verification.py | 15 ++-- bot/cogs/watchchannels/watchchannel.py | 9 +- bot/cogs/wolfram.py | 2 +- bot/pagination.py | 37 ++++++--- bot/patches/message_edited_at.py | 5 +- bot/rules/attachments.py | 4 +- bot/rules/burst.py | 8 +- bot/rules/burst_shared.py | 8 +- bot/rules/chars.py | 8 +- bot/rules/discord_emojis.py | 8 +- bot/rules/duplicates.py | 8 +- bot/rules/links.py | 8 +- bot/rules/mentions.py | 8 +- bot/rules/newlines.py | 8 +- bot/rules/role_mentions.py | 8 +- bot/utils/time.py | 4 +- 25 files changed, 156 insertions(+), 235 deletions(-) diff --git a/bot/api.py b/bot/api.py index 3fca5db57..7f26e5305 100644 --- a/bot/api.py +++ b/bot/api.py @@ -99,7 +99,6 @@ def loop_is_running() -> bool: This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`), which is currently not provided by asyncio. """ - try: asyncio.get_running_loop() except RuntimeError: @@ -138,7 +137,7 @@ class APILoggingHandler(logging.StreamHandler): def emit(self, record: logging.LogRecord) -> None: """ Determine if a log record should be shipped to the logging API. - + If the asyncio event loop is not yet running, log records will instead be put in a queue which will be consumed once the event loop is running. @@ -146,18 +145,8 @@ class APILoggingHandler(logging.StreamHandler): 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level) 2. Ignore log records originating from this logging handler itself to prevent infinite recursion """ - # Two checks are performed here: if ( - # 1. Do not log anything below `DEBUG`. This is only applicable - # for the monkeypatched `TRACE` logging level, which has a - # lower numeric value than `DEBUG`. record.levelno >= logging.DEBUG - # 2. Ignore logging messages which are sent by this logging - # handler itself. This is required because if we were to - # not ignore messages emitted by this handler, we would - # infinitely recurse back down into this logging handler, - # making the reactor run like crazy, and eventually OOM - # something. Let's not do that... and not record.__dict__.get('via_handler') ): payload = { diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index 0caf503a8..117c77d4b 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -1,8 +1,7 @@ import logging import os -from typing import Optional -from discord import Colour, Embed, Message +from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, group from bot.constants import ( @@ -146,7 +145,7 @@ class Cogs(Cog): @cogs_group.command(name='reload', aliases=('r',)) @with_role(*MODERATION_ROLES, Roles.core_developer) - async def reload_command(self, ctx: Context, cog: str) -> Optional[Message]: + async def reload_command(self, ctx: Context, cog: str) -> None: """ Reload an unloaded cog, given the module containing it. @@ -227,7 +226,8 @@ class Cogs(Cog): log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n" f"{lines}") - return await LinePaginator.paginate(lines, ctx, embed, empty=False) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + return elif full_cog in self.bot.extensions: try: diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2dcbad6e0..e5c51748f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -140,11 +140,11 @@ class Doc(commands.Cog): Where: * `package_name` is the package name to use, appears in the log * `base_url` is the root documentation URL for the specified package, used to build - absolute paths that link to specific symbols + absolute paths that link to specific symbols * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running - `intersphinx.fetch_inventory` in an executor on the bot's event loop + `intersphinx.fetch_inventory` in an executor on the bot's event loop * `config` is a `SphinxConfiguration` instance to mock the regular sphinx - project layout, required for use with intersphinx + project layout, required for use with intersphinx """ self.base_urls[package_name] = base_url diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index e74030c16..49411814c 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -31,7 +31,26 @@ class ErrorHandler(Cog): @Cog.listener() async def on_command_error(self, ctx: Context, e: CommandError) -> None: - """Provide command error handling.""" + """ + Provide generic command error handling. + + Error handling is deferred to any local error handler, if present. + + Error handling emits a single error response, prioritized as follows: + 1. If the name fails to match a command but matches a tag, the tag is invoked + 2. Send a BadArgument error message to the invoking context & invoke the command's help + 3. Send a UserInputError error message to the invoking context & invoke the command's help + 4. Send a NoPrivateMessage error message to the invoking context + 5. Send a BotMissingPermissions error message to the invoking context + 6. Log a MissingPermissions error, no message is sent + 7. Send a InChannelCheckFailure error message to the invoking context + 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent + 9. For CommandInvokeErrors, response is based on the type of error: + * 404: Error message is sent to the invoking context + * 400: Log the resopnse JSON, no message is sent + * 500 <= status <= 600: Error message is sent to the invoking context + 10. Otherwise, handling is deferred to `handle_unexpected_error` + """ command = ctx.command parent = None @@ -58,7 +77,8 @@ class ErrorHandler(Cog): # Return to not raise the exception with contextlib.suppress(ResponseCodeError): - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + return elif isinstance(e, BadArgument): await ctx.send(f"Bad argument: {e}\n") await ctx.invoke(*help_command) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 0f2196e46..4971cd0bb 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -36,12 +36,8 @@ class HelpQueryNotFound(ValueError): Contains the custom attribute of ``possible_matches``. - Attributes - ---------- - possible_matches: dict - Any commands that were close to matching the Query. - The possible matched command names are the keys. - The likeness match scores are the values. + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. """ def __init__(self, arg: str, possible_matches: dict = None): @@ -53,50 +49,30 @@ class HelpSession: """ An interactive session for bot and command help output. - Attributes - ---------- - title: str - The title of the help message. - query: Union[:class:`discord.ext.commands.Bot`, - :class:`discord.ext.commands.Command] - description: str - The description of the query. - pages: list[str] - A list of the help content split into manageable pages. - message: :class:`discord.Message` - The message object that's showing the help contents. - destination: :class:`discord.abc.Messageable` - Where the help message is to be sent to. + Expected attributes include: + * title: str + The title of the help message. + * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] + * description: str + The description of the query. + * pages: list[str] + A list of the help content split into manageable pages. + * message: `discord.Message` + The message object that's showing the help contents. + * destination: `discord.abc.Messageable` + Where the help message is to be sent to. """ def __init__( - self, ctx: Context, *command, cleanup: bool = False, only_can_run: bool = True, - show_hidden: bool = False, max_lines: int = 15 + self, + ctx: Context, + *command, + cleanup: bool = False, + only_can_run: bool = True, + show_hidden: bool = False, + max_lines: int = 15 ): - """ - Creates an instance of the HelpSession class. - - Parameters - ---------- - ctx: :class:`discord.ext.commands.Context` - The context of the invoked help command. - *command: str - A variable argument of the command being queried. - cleanup: Optional[bool] - Set to ``True`` to have the message deleted on timeout. - If ``False``, it will clear all reactions on timeout. - Defaults to ``False``. - only_can_run: Optional[bool] - Set to ``True`` to hide commands the user can't run. - Defaults to ``False``. - show_hidden: Optional[bool] - Set to ``True`` to include hidden commands. - Defaults to ``False``. - max_lines: Optional[int] - Sets the max number of lines the paginator will add to a - single page. - Defaults to 20. - """ + """Creates an instance of the HelpSession class.""" self._ctx = ctx self._bot = ctx.bot self.title = "Command Help" @@ -145,18 +121,9 @@ class HelpSession: """ Handles when a query does not match a valid command or cog. - Will pass on possible close matches along with the ``HelpQueryNotFound`` exception. - - Parameters - ---------- - query: str - The full query that was requested. - - Raises - ------ - HelpQueryNotFound + Will pass on possible close matches along with the `HelpQueryNotFound` exception. """ - # combine command and cog names + # Combine command and cog names choices = list(self._bot.all_commands) + list(self._bot.cogs) result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) @@ -164,14 +131,7 @@ class HelpSession: raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) async def timeout(self, seconds: int = 30) -> None: - """ - Waits for a set number of seconds, then stops the help session. - - Parameters - ---------- - seconds: int - Number of seconds to wait. - """ + """Waits for a set number of seconds, then stops the help session.""" await asyncio.sleep(seconds) await self.stop() @@ -186,16 +146,7 @@ class HelpSession: self._timeout_task = self._bot.loop.create_task(self.timeout()) async def on_reaction_add(self, reaction: Reaction, user: User) -> None: - """ - Event handler for when reactions are added on the help message. - - Parameters - ---------- - reaction: :class:`discord.Reaction` - The reaction that was added. - user: :class:`discord.User` - The user who added the reaction. - """ + """Event handler for when reactions are added on the help message.""" # ensure it was the relevant session message if reaction.message.id != self.message.id: return @@ -252,7 +203,7 @@ class HelpSession: def _category_key(self, cmd: Command) -> str: """ - Returns a cog name of a given command for use as a key for ``sorted`` and ``groupby``. + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ @@ -263,7 +214,7 @@ class HelpSession: """ Returns the command usage signature. - This is a custom implementation of ``command.signature`` in order to format the command + This is a custom implementation of `command.signature` in order to format the command signature without aliases. """ results = [] @@ -456,25 +407,15 @@ class HelpSession: """ Create and begin a help session based on the given command context. - Parameters - ---------- - ctx: :class:`discord.ext.commands.Context` - The context of the invoked help command. - *command: str - A variable argument of the command being queried. - cleanup: Optional[bool] - Set to ``True`` to have the message deleted on session end. - Defaults to ``False``. - only_can_run: Optional[bool] - Set to ``True`` to hide commands the user can't run. - Defaults to ``False``. - show_hidden: Optional[bool] - Set to ``True`` to include hidden commands. - Defaults to ``False``. - max_lines: Optional[int] - Sets the max number of lines the paginator will add to a - single page. - Defaults to 20. + Available options kwargs: + * cleanup: Optional[bool] + Set to `True` to have the message deleted on session end. Defaults to `False`. + * only_can_run: Optional[bool] + Set to `True` to hide commands the user can't run. Defaults to `False`. + * show_hidden: Optional[bool] + Set to `True` to include hidden commands. Defaults to `False`. + * max_lines: Optional[int] + Sets the max number of lines the paginator will add to a single page. Defaults to 20. """ session = cls(ctx, *command, **options) await session.prepare() @@ -565,12 +506,12 @@ def setup(bot: Bot) -> None: This is called automatically on `bot.load_extension` being run. - Stores the original help command instance on the ``bot._old_help`` - attribute for later reinstatement, before removing it from the - command registry so the new help command can be loaded successfully. + Stores the original help command instance on the `bot._old_help` attribute for later + reinstatement, before removing it from the command registry so the new help command can be + loaded successfully. - If an exception is raised during the loading of the cog, ``unload`` - will be called in order to reinstate the original help command. + If an exception is raised during the loading of the cog, `unload` will be called in order to + reinstate the original help command. """ bot._old_help = bot.get_command('help') bot.remove_command('help') @@ -588,6 +529,6 @@ def teardown(bot: Bot) -> None: This is called automatically on `bot.unload_extension` being run. - Calls ``unload`` in order to reinstate the original help command. + Calls `unload` in order to reinstate the original help command. """ unload(bot) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 5a59dc663..8f1af347a 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -40,14 +40,7 @@ class OffTopicName(Converter): async def update_names(bot: Bot) -> None: - """ - The background updater task that performs a channel name update daily. - - Args: - bot (Bot): - The running bot instance, used for fetching data from the - website via the bot's `api_client`. - """ + """Background updater task that performs the daily channel name update.""" while True: # Since we truncate the compute timedelta to seconds, we add one second to ensure # we go past midnight in the `seconds_to_sleep` set below. diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 3e6f9299a..5accbdb5e 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,7 +5,6 @@ import textwrap from signal import Signals from typing import Optional, Tuple -from discord import Message from discord.ext.commands import Bot, Cog, Context, command, guild_only from bot.constants import Channels, STAFF_ROLES, URLs @@ -168,7 +167,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None) -> Optional[Message]: + async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. @@ -177,13 +176,15 @@ class Snekbox(Cog): issue with it! """ if ctx.author.id in self.jobs: - return await ctx.send( + await ctx.send( f"{ctx.author.mention} You've already got a job running - " f"please wait for it to finish!" ) + return if not code: # None or empty string - return await ctx.invoke(self.bot.get_command("help"), "eval") + await ctx.invoke(self.bot.get_command("help"), "eval") + return log.info( f"Received code from {ctx.author.name}#{ctx.author.discriminator} " diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index c42b94d28..f7d6a269d 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -1,9 +1,8 @@ import logging import random from datetime import datetime -from typing import Optional -from discord import Colour, Embed, Member, Message +from discord import Colour, Embed, Member from discord.errors import Forbidden from discord.ext.commands import Bot, Cog, Context, command @@ -155,7 +154,7 @@ class Superstarify(Cog): @with_role(*MODERATION_ROLES) async def superstarify( self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None - ) -> Optional[Message]: + ) -> None: """ Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. @@ -172,10 +171,11 @@ class Superstarify(Cog): } ) if active_superstarifies: - return await ctx.send( + await ctx.send( ":x: According to my records, this user is already superstarified. " f"See infraction **#{active_superstarifies[0]['id']}**." ) + return infraction = await post_infraction( ctx, member, @@ -225,7 +225,7 @@ class Superstarify(Cog): @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(*MODERATION_ROLES) - async def unsuperstarify(self, ctx: Context, member: Member) -> Optional[Message]: + async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify entry from our database, allowing the user to change their nickname.""" log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") @@ -241,9 +241,8 @@ class Superstarify(Cog): } ) if not active_superstarifies: - return await ctx.send( - ":x: There is no active superstarify infraction for this user." - ) + await ctx.send(":x: There is no active superstarify infraction for this user.") + return [infraction] = active_superstarifies await self.bot.api_client.patch( diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 965d532a0..62e2fb03f 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -3,9 +3,8 @@ import re import unicodedata from email.parser import HeaderParser from io import StringIO -from typing import Optional -from discord import Colour, Embed, Message +from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Context, command from bot.constants import Channels, STAFF_ROLES @@ -29,7 +28,8 @@ class Utils(Cog): if pep_number.isdigit(): pep_number = int(pep_number) else: - return await ctx.invoke(self.bot.get_command("help"), "pep") + await ctx.invoke(self.bot.get_command("help"), "pep") + return # Newer PEPs are written in RST instead of txt if pep_number > 542: @@ -85,7 +85,7 @@ class Utils(Cog): @command() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def charinfo(self, ctx: Context, *, characters: str) -> Optional[Message]: + async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: @@ -97,12 +97,14 @@ class Utils(Cog): ) ) embed.colour = Colour.red() - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return if len(characters) > 25: embed = Embed(title=f"Too many characters ({len(characters)}/25)") embed.colour = Colour.red() - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return def get_info(char): digit = f"{ord(char):x}" diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0c4819f66..b0c250603 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from discord import Message, NotFound, Object from discord.ext.commands import Bot, Cog, Context, command @@ -96,7 +95,7 @@ class Verification(Cog): @command(name='subscribe') @in_channel(Channels.bot) - async def subscribe_command(self, ctx: Context, *_) -> Optional[Message]: # We don't actually care about the args + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False @@ -106,9 +105,8 @@ class Verification(Cog): break if has_role: - return await ctx.send( - f"{ctx.author.mention} You're already subscribed!", - ) + await ctx.send(f"{ctx.author.mention} You're already subscribed!") + return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements") @@ -121,7 +119,7 @@ class Verification(Cog): @command(name='unsubscribe') @in_channel(Channels.bot) - async def unsubscribe_command(self, ctx: Context, *_) -> Optional[Message]: # We don't actually care about the args + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False @@ -131,9 +129,8 @@ class Verification(Cog): break if not has_role: - return await ctx.send( - f"{ctx.author.mention} You're already unsubscribed!" - ) + await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") + return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements") diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 3af97e7fe..e78282900 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -54,8 +54,13 @@ class WatchChannel(metaclass=CogABCMeta): @abstractmethod def __init__( - self, bot: Bot, destination: int, webhook_id: int, - api_endpoint: str, api_default_params: dict, logger: logging.Logger + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger ) -> None: self.bot = bot diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 7c218eb8c..ab0ed2472 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -95,7 +95,7 @@ def custom_cooldown(*ignore: List[int]) -> Callable: async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Give feedback that the bot is working.""" + """Get the Wolfram API pod pages for the provided query.""" async with ctx.channel.typing(): url_str = parse.urlencode({ "input": query, diff --git a/bot/pagination.py b/bot/pagination.py index 32e289e6a..76082f459 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -27,15 +27,14 @@ class LinePaginator(Paginator): """ A class that aids in paginating code blocks for Discord messages. - Attributes - ----------- - prefix: :class:`str` + Available attributes include: + * prefix: `str` The prefix inserted to every page. e.g. three backticks. - suffix: :class:`str` + * suffix: `str` The suffix appended at the end of every page. e.g. three backticks. - max_size: :class:`int` + * max_size: `int` The maximum amount of codepoints allowed in a page. - max_lines: :class:`int` + * max_lines: `int` The maximum amount of lines allowed in a page. """ @@ -87,10 +86,20 @@ class LinePaginator(Paginator): @classmethod async def paginate( - cls, lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, - empty: bool = True, restrict_to_user: User = None, timeout: int = 300, - footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False + cls, + lines: Iterable[str], + ctx: Context, + embed: Embed, + prefix: str = "", + suffix: str = "", + max_lines: Optional[int] = None, + max_size: int = 500, + empty: bool = True, + restrict_to_user: User = None, + timeout: int = 300, + footer_text: str = None, + url: str = None, + exception_on_empty_embed: bool = False ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -304,8 +313,12 @@ class ImagePaginator(Paginator): @classmethod async def paginate( - cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", timeout: int = 300, + cls, + pages: List[Tuple[str, str]], + ctx: Context, embed: Embed, + prefix: str = "", + suffix: str = "", + timeout: int = 300, exception_on_empty_embed: bool = False ) -> Optional[Message]: """ diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py index 6a73af8c9..a0154f12d 100644 --- a/bot/patches/message_edited_at.py +++ b/bot/patches/message_edited_at.py @@ -1,6 +1,5 @@ -# flake8: noqa """ -# message_edited_at patch +# message_edited_at patch. Date: 2019-09-16 Author: Scragly @@ -17,7 +16,7 @@ from discord import message, utils log = logging.getLogger(__name__) -def _handle_edited_timestamp(self, value) -> None: +def _handle_edited_timestamp(self: message.Message, value: str) -> None: """Helper function that takes care of parsing the edited timestamp.""" self._edited_timestamp = utils.parse_time(value) diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index 9cf9877fd..c550aed76 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -1,5 +1,3 @@ -"""Detects total attachments exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message @@ -8,7 +6,7 @@ from discord import Member, Message async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply attachment spam detection filter.""" + """Detects total attachments exceeding the limit sent by a single user.""" relevant_messages = [last_message] + [ msg for msg in recent_messages diff --git a/bot/rules/burst.py b/bot/rules/burst.py index 8859f8d51..25c5a2f33 100644 --- a/bot/rules/burst.py +++ b/bot/rules/burst.py @@ -1,16 +1,12 @@ -"""Detects repeated messages sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply burst message spam detection filter.""" + """Detects repeated messages sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index b8c73ecb4..bbe9271b3 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -1,16 +1,12 @@ -"""Detects repeated messages sent by multiple users.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply burst repeated message spam filter.""" + """Detects repeated messages sent by multiple users.""" total_recent = len(recent_messages) if total_recent > config['max']: diff --git a/bot/rules/chars.py b/bot/rules/chars.py index ae8ac93ef..1f587422c 100644 --- a/bot/rules/chars.py +++ b/bot/rules/chars.py @@ -1,16 +1,12 @@ -"""Detects total message char count exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply excessive character count detection filter.""" + """Detects total message char count exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 87d129f37..5bab514f2 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -1,5 +1,3 @@ -"""Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -10,11 +8,9 @@ DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply emoji spam detection filter.""" + """Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py index 8648fd955..455764b53 100644 --- a/bot/rules/duplicates.py +++ b/bot/rules/duplicates.py @@ -1,16 +1,12 @@ -"""Detects duplicated messages sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply duplicate message spam detection filter.""" + """Detects duplicated messages sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/links.py b/bot/rules/links.py index 924f092b1..ec75a19c5 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -1,5 +1,3 @@ -"""Detects total links exceeding the limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -10,11 +8,9 @@ LINK_RE = re.compile(r"(https?://[^\s]+)") async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply link spam detection filter.""" + """Detects total links exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 3372fd1e1..79725a4b1 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -1,16 +1,12 @@ -"""Detects total mentions exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply user mention spam detection filter.""" + """Detects total mentions exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py index d04f8c9ed..4e66e1359 100644 --- a/bot/rules/newlines.py +++ b/bot/rules/newlines.py @@ -1,5 +1,3 @@ -"""Detects total newlines exceeding the set limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -7,11 +5,9 @@ from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply newline spam detection filter.""" + """Detects total newlines exceeding the set limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py index a8b819d0d..0649540b6 100644 --- a/bot/rules/role_mentions.py +++ b/bot/rules/role_mentions.py @@ -1,16 +1,12 @@ -"""Detects total role mentions exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Apply role mention spam detection filter.""" + """Detects total role mentions exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/utils/time.py b/bot/utils/time.py index fe3ccc271..c529ccc2b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -28,7 +28,7 @@ def _stringify_time_unit(value: int, unit: str) -> str: def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: """ Returns a human-readable version of the relativedelta. - + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ @@ -69,7 +69,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: """ Takes a datetime and returns a human-readable string that describes how long ago that datetime was. - + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ -- cgit v1.2.3